
Your agent asked a human a question. The answer died with the task
There is a number in your agent pilot that nobody put on the dashboard: questions asked per hundred messages.
Week one it is high. Everyone expects that; the thing is new. Week six is the number that tells you what you actually built. If it hasn't moved, you don't have a system that learns. You have an expensive form that a human fills in, wearing a chat interface.
Here is the uncomfortable part. That flat line usually has nothing to do with your model.
The moment nobody specified
Every agent that decides anything real will eventually meet an input it cannot decide.
A2A handles that moment properly. The task moves to input-required, a human answers, the task resumes. Clean, specified, implemented in every SDK. Good protocol design.
What no protocol specifies is the moment after.
An operator has just told your software a true fact about the world: this address belongs to that customer. That fact is evidence, not a token that unblocked a task. And there is nowhere in the stack to put it. So the task completes, the fact evaporates, and the next message from the same sender asks the same question. Then the one after that.
Six weeks later, somebody notices the line is flat.
The plateau is not a model quality problem. It is an architecture problem, and it has a shape.
Three bad options, all of them in production somewhere
When an operator answers, an implementation does one of three things today.
It throws the answer away. The default, because it requires writing no code at all. The agent asked, the human answered, the task completed, the knowledge lived for the length of one task. This is the option that keeps your line flat.
It writes the answer into a prompt or a config file, and redeploys. Better, and it is where most teams land. The knowledge now lives in a repository. It also needs an engineer, a PR, a review and a deploy, so it arrives on Thursday — four days after the one person who knew the answer answered it. Multiply that by every operator in every warehouse and you have built a system where domain knowledge queues behind an engineering backlog. That queue is where enthusiasm goes to die.
It lets the agent silently self-modify. Fast. Looks like magic in a demo. Completely unauditable. Nobody can say what the agent knew on the day it made a decision, and nothing can be taken back. The first time it learns something wrong at 2am, you will find out there is no git revert for a vector store.
There is a fourth option. The rest of this post is what it looks like in code, including the two places we got it wrong first.
The fourth option: agent proposes, application decides, authority records
human
│ answers a question, ticks "remember this"
▼
application ──────── posts a Lesson ───────► authority
▲ (holds definitions,
│ renders the question versions, activates)
│ + the teach block │
│ │ publishes v12
agent ◄──── resolves the active definition ────────┘
│
└─ next task recognises the sender. Nobody is asked.
Three properties make that loop work, and each one is a decision somebody has to make on purpose:
- The agent publishes what it can be taught. Not the application guessing on its behalf. The agent is the only party that knows what it recognises.
- The application decides what actually gets written. The agent proposes. It does not write to itself. Whoever holds the human holds the authority.
- The write produces a new immutable version. Not an edit. This is the whole difference between "the agent learns" and "the agent changes unpredictably underneath a running task".
We wrote this up as an A2A extension, published at https://rsaxb.com/a2a/learnables/v1. What follows is the reasoning behind the parts that were not obvious, which is most of them.
The agent's card tells you what it can be taught
A2A gives you the slot: capabilities.extensions[], each entry carrying a uri, a description, a required flag and a free-form params object. Our declaration lives in params.
{
"collection": "customers",
"entry": "customer",
"teaches": "which customer a message belongs to",
"how": "Each customer carries marks - text that gives them away. Every mark that matches scores; the highest score wins if it clears the identification margin. Nothing matches, nothing is claimed.",
"decidedBy": "rules",
"lessonFields": [
{ "name": "pattern", "label": "Text that gives it away", "kind": "text",
"help": "An address, a reference, a phrase. Case-insensitive; regular expressions work if you want one." },
{ "name": "where", "label": "Where to look for it", "kind": "choice",
"choices": ["from", "subject", "body", "attachments", "any"], "default": "from",
"help": "Narrower is safer. 'any' matches the whole message." }
]
}
Two fields there are load-bearing, and neither is the one you would guess.
how is a prose sentence describing how the agent decides today. It is required, and it is required to be true. Somebody is about to change the way software makes a decision that affects a shipment. They are entitled to know what it currently does, in a sentence, from the thing doing it. An agent that cannot describe its own behaviour has no business offering to be taught.
decidedBy is "rules", "model" or "hybrid", and it changes what the interface is allowed to honestly promise. Under "rules", a taught mark takes effect deterministically and the operator can predict tomorrow from today. Under "model", the lesson is one example among many and the effect is statistical. Presenting the second as if it were the first is precisely how "I taught it and it still gets it wrong" becomes a support ticket you cannot answer.
One form renderer, two agents with nothing in common
Here is the design decision everything else hangs off.
The obvious implementation of "teach the email agent" is a form with two inputs, a pattern and a where, because that is what an email agent needs. Ship that and your application now contains the email agent's idea of a message. Hard-coded. In a different repository. Maintained by a different team.
Then someone adds an agent that reads spreadsheet manifests. It does not want a pattern and a where. It wants to be told which column holds the waybill number:
{
"collection": "mappings",
"entry": "field",
"teaches": "which column of a manifest holds which field",
"how": "Each field carries the column headings it has been seen under. Headings match case-insensitively and the first hit wins. No heading matches, the field comes back empty.",
"decidedBy": "rules",
"lessonFields": [
{ "name": "column", "label": "Column heading", "kind": "text",
"help": "Exactly as it appears in the sheet's header row." }
]
}
Different collection, different vocabulary, different number of fields. Nothing in common with the first one except the shape of the descriptor.
So the agent describes the form and the application renders it blind:
{lessonFields.map((f) =>
f.kind === 'choice'
? <select name={f.name} defaultValue={f.default}>
{f.choices.map((c) => <option key={c}>{c}</option>)}
</select>
: <input name={f.name} type="text" defaultValue={suggested[f.name] ?? ''} />
)}
That is the entire rendering layer, and it draws both forms. The strings pattern, where, from, subject, column appear nowhere in the application. A third agent with an entirely different notion of what a lesson is becomes a deployment rather than a release.
One rule keeps this alive on contact with reality: a client that meets a kind it does not recognise must render it as a text box. Not skip it. Not error. A skipped field produces a lesson the agent will reject, at the exact moment a human is trying to fix something. An unstyled input box is a much better failure than a rejection nobody can explain.
The rule that has to be enforced by construction, not by agreement
The lesson that travels from application to authority looks like this:
{
"collection": "customers",
"entry": "northgate",
"value": { "pattern": "billing@origin\\.example", "where": "from" },
"taughtBy": "dana@warehouse.example",
"reason": "Northgate's billing desk sends the pre-alerts"
}
value is an open object, and nothing between the client and the learnable may read a named key out of it. Not the HTTP layer. Not storage. Not the audit trail.
Easy to agree with. Hard to hold. Ours did not hold it.
The first version of our control plane had a teach(pattern=..., where=...) signature, because at the time there was one agent and it dealt in text. Every layer knew the word pattern. Adding a learnable that was not about text — a column mapping, a threshold, a unit of measure — would have meant a coordinated release across four components. Which is a polite way of saying it would never have happened, and the second agent would have got its own bespoke teaching endpoint instead.
The refactor pushed the vocabulary down into the only thing that owns it:
class Learnable(BaseModel):
"""Something on a definition that a human's answer may extend."""
@property
def key(self) -> str: ...
def learn(self, value: dict[str, Any]) -> tuple["Learnable", bool]:
"""Absorb a value. Returns the amended copy and whether anything changed."""
def describes(self, value: dict[str, Any]) -> str:
"""Phrase what this value means, for the audit line."""
The control plane now does three things: find the entry, call learn, publish the result if something changed. It contains no field called pattern anywhere.
You do not prove that claim by asserting it in a design document. You prove it by writing a learnable that has nothing to do with text and driving it down the identical code path:
class ColumnMapping(Learnable):
field: str
columns: list[str]
def learn(self, value):
column = str(value.get("column", "")).strip()
if not column:
raise UnlearnableValue("a mapping needs a column")
if column in self.columns:
return self, False
return self.model_copy(update={"columns": [*self.columns, column]}), True
def describes(self, value):
return f"reads {self.field} from the {value.get('column')!r} column"
Its value has no pattern key at all. The test asserts the audit line reads reads waybill from the 'Air Waybill No' column — phrased by the learnable, in words a human can audit six months later. If anything in that chain were quietly peeking at value["pattern"], the test fails today. Otherwise the design rots slowly and you find out during the integration that was supposed to take a week.
Refusals belong to whoever knows why
The person who typed the value is standing in a warehouse at 14:20 on a Tuesday trying to make software work. When the value cannot be used, they get this:
"billing@origin\.example(" is not a usable pattern:
missing ), unterminated subpattern at position 24
and not this:
400 Bad Request: invalid value
The learnable is the only party that knows why. It compiled the regex. The transport did not, and cannot, and should not pretend to. So learn() raises UnlearnableValue carrying its own wording, and the boundary translates the type without laying a finger on the message.
The bug that turned a convenience into a correctness requirement
The teach block attached to a question carries a suggested value, prefilled from the message, so the common case is one click.
Most people confirm a prefill. Which quietly makes the prefill's scope a safety property. We got it wrong.
Our reference extractor pulled shipment references out of message text with this:
re.findall(r"\b[A-Z]{2,6}[-/]\d{3,8}\b", text)
Look at it for a second before reading on. It is fine, isn't it?
Given PA-2026-0901, it returns PA-2026.
So the agent asks "is this reference a good way to recognise this customer?", shows PA-2026, and an operator glancing at something that looks exactly like a shipment reference ticks yes. They have now taught the directory to claim every reference issued in 2026. Silently. For every message. For everyone.
The fix is one repeating group, which keeps consuming further -nnnn segments instead of stopping at the first:
re.findall(r"\b[A-Z]{2,6}[-/]\d{3,8}(?:[-/]\d{1,8})*\b", text)
The regex is not the interesting part. The interesting part is that a test caught this and a code review would not have. The pattern looks right. It only becomes visibly wrong when you write down the sentence a human is actually agreeing to when they tick the box.
That is now a MUST in the spec: a suggestion must never be wider than the evidence it was drawn from.
Publish, never edit
Here is the requirement that separates this from an agent rewriting its own config file.
A lesson that changes anything produces a new immutable version of the definition. Tasks already in flight stay pinned to the version they started on and finish on it. The new version lands in an activation history with an author and a reason, and it is undone by the same rollback that undoes any other activation:
v12 taught by dana@warehouse.example
northgate learned: matches 'billing@origin\.example' in the from
reason: Northgate's billing desk sends the pre-alerts
Without pinning, "the agent learns" and "the agent changes mid-task" are the same sentence with different marketing. A shipment classified half under one rule set and half under another is not a bug anyone enjoys reproducing at quarter end.
Pinning also hands you the only question that matters after an incident: which rules were in force when this decision was made, and who put them there. Answered from stored state, in seconds, without anybody reconstructing deploy history from a Slack thread.
"Saved" answers a question nobody asked
The last piece is the one most implementations skip, and it is the one that makes the loop honest.
An operator writes a rule. The write succeeds. What do you show her?
Show her "Saved" and you have answered a question she did not ask. Her question is did that do what I meant. So the agent exposes a read-only re-read of the same message under the definition that is live right now. It asks nothing, proposes nothing, pins to nothing:
{
"customer": {
"settled": true,
"winner": "northgate",
"reads": "Reads as Northgate Motors Ltd, on billing@origin.example"
}
}
That is the happy path, and it is not the case this exists for. This is:
{ "customer": { "settled": false, "reads": "Still cannot tell whose this is." } }
A rule that is saved, valid, and does nothing at all. learned: true is completely true and completely useless. Showing the unsettled reading is what lets her fix it in the next sixty seconds instead of the next shipment. A success toast hides that perfectly, which is the problem with success toasts.
The test that was measuring nothing
While writing the test that proves the whole loop — read, teach, read again, second read settles — it failed. The lesson was landing. The definition was amended. The second read still said "cannot tell".
The cause was in the test, and it is worth the detour because it will apply to yours.
Both reads used the same task id. Our runtime journals every step so an interrupted task can resume exactly where it stopped, which means re-running a task replays the journal. The second read was the first read, handed back from cache, faithfully.
Sit with that for a moment. The test would have passed whether the lesson worked or not. In either direction. It was measuring nothing, and it was green.
The fix is one line — two reads, two tasks — but the generalisation is the point. If your agent runtime has replay semantics, any test that re-runs the same task to observe a change is verification theatre. Ours failed loudly enough to be caught. The dangerous version is the one that passes.
Why bolt this onto A2A instead of inventing something
We could have shipped a private protocol. Two reasons not to.
The slots already exist. Extensions are declared as AgentExtension objects inside AgentCapabilities, each with a uri, description, required flag and params. A client activates one by listing its URI in an A2A-Extensions request header, and the agent echoes back what it activated. The spec names four categories — data-only, profile, method and state-machine — and this extension spans three: the card declaration is data-only, the teach block on a question is a profile augmentation carried in metadata, and the optional self-teaching RPC is a method extension.
And the governance is genuinely open. Anyone may publish an extension under a URI they control, without permission. Only the a2aproject prefixes are reserved. Reaching official status is a real process — a proposal issue, a maintainer sponsor, an experimental repo, then a TSC vote — with adoption evidence as a graduation requirement. Which is the honest gap in our position: we have a reference implementation and two applications, all of them ours.
Our required flag is fixed at false, deliberately. A client that ignores this extension entirely sees an agent card with one extra entry and a question with one extra key in metadata. Both ignorable by construction. An extension that breaks core clients in exchange for a feature they did not ask for deserves to be ignored.
The A that isn't an agent
A2A means Agent-to-Agent. Our second party is not an agent. It is an application with a human behind it.
That asymmetry is the entire point.
In agent-to-agent, human input is an inconvenience: a task parks in input-required and something upstream is supposed to deal with it. In app-to-agent, the human is the product. The application owns the interface, owns the operator, and owns the judgement about whether one person's answer deserves to become everyone's rule.
Which is why the ownership rule came out the way it did. The agent proposes; the application decides. The agent does not know whether the person answering is authorised, whether she was confident or guessing, or whether her answer should bind the whole tenant. The application knows all three.
So if you want the number on that dashboard to fall, the question is not which model you are using. It is a much less glamorous one:
When your agent asks a human a question, where does the answer go?
If the honest answer is "into the task, and then nowhere", you already know why week six looks like week one.
The spec is here. It is a draft, published under a URI we control, and it is open to being wrong in public.
