A client
A working client is a loop that reads cards, picks an agent for each skill, and has a branch for every state a task can reach.
The planner has one job: deliver a change to payments-api. It must run the tests on commit a41d7c3, get the diff reviewed, and deploy build 2026.10.06-1 to staging. It starts with three port numbers, a token for the deployer, and the name of the base branch. The planner does the whole job in 86 lines, and its log records each decision.
When you finish this section, you can write a client that delegates by skill and handles every state a task reaches.
Discover: read every card first
Before it sends anything, the planner reads the card on each port. It takes the interface whose protocolBinding is JSONRPC at protocolVersion 1.0, and it files each skill id under the agent that offers it. It also notes each card's streaming flag and securityRequirements.
The rule of the specification is positional: the client selects the first transport that it supports. Both rules agree here, because JSON-RPC comes first on all three cards. Discovery and supported interfaces gives the full selection rule.
Delegate: send once, then switch on the reply
Each delegation is one SendMessage without returnImmediately. It blocks until the task reaches a terminal or an interrupted state, as SendMessage explains. The bearer token goes only to agents whose card asked for one, as security schemes describes. A loop then switches on the reply.
while True:
if "error" in reply:
self.note(f" error {reply['error']['code']}: {reply['error']['message']}")
return None
result = reply["result"]
if "message" in result:
self.note(" direct reply, no task to follow")
return result["message"]
task = result["task"] if "task" in result else result
state = task["status"]["state"]
self.note(f" task {task['id'][:8]} is {state}")
if state in TERMINAL:
return task
question = task["status"]["message"]["parts"][0]["text"]
if state == "TASK_STATE_INPUT_REQUIRED":
answer = self.answer(question)
self.note(f" it asks: {question} The planner answers from its own context: {answer}")
_, reply = self.client.rpc(f"SendMessage: answer {agent['name']}", agent["port"], "SendMessage", {"message": self.client.message(answer, task_id=task["id"], context_id=task["contextId"])}, headers=headers)
continue
if state == "TASK_STATE_AUTH_REQUIRED":
self.note(f" it needs approval: {question} The planner cannot approve, so it asks the operator and subscribes.")
return self.follow(agent, task, headers)The reply has three shapes, checked in order: an error, a direct message, or a task. The TERMINAL tuple holds all four terminal states, TASK_STATE_REJECTED included, the one clients forget most often.
On TASK_STATE_INPUT_REQUIRED, the planner answers on the same task, with its taskId and contextId, as interrupted states explains. On TASK_STATE_AUTH_REQUIRED, the planner has nothing to send, so it passes the task to follow.
Follow: subscribe, then call the operator
The first frame of a subscription is a snapshot of the task, as streaming shows. The planner subscribes first and calls the operator only when that snapshot arrives.
def follow(self, agent, task, headers):
def on_frame(index, event):
if index == 1:
self.operator(task)
_, events = self.client.rpc(f"SubscribeToTask on {agent['name']}", agent["port"], "SubscribeToTask", {"id": task["id"]}, headers=headers, on_frame=on_frame)
final = events[-1]["result"]["statusUpdate"]["status"]["state"]
self.note(f" the stream ends at {final}")
return {"id": task["id"], "status": {"state": final}}The operator call is a POST /approve/<task id>, outside A2A. The stream is open before the operator acts, so no update after the approval can pass the planner unseen. Three more frames follow the snapshot, and the last one carries TASK_STATE_COMPLETED.
The run, decision by decision
Between delegations, the ship method decides whether to go on. It stops if the test task did not complete, if any test failed, or if any finding has high severity. Figure 7.1 draws the run in 19-planner.log as three lanes, with the decisions below them.
Each decision reads a data part, never free text: passed and failed from summary.json, and severity from each finding in review.json. On commit 9f3c2e1, one test fails, and the planner would stop before the review.
What a production planner adds
Against agents it does not control, a planner needs more than the kit's loop.
- Safe retries. Resend a failed message with the same
messageId. Keep the first reply, because a resend withouttaskIdcreates a second task in the reference SDK. - Timeouts. The kit's client gives each request 15 seconds, in its network code, and the specification sets none. For long work, set
returnImmediatelyand follow the task. - Card checks. Cache cards with standard HTTP caching, and verify the deployer's
signaturesas extended and signed cards shows. - Capability checks. The planner records
streamingand never reads it. Against an agent without streaming, itsSubscribeToTaskgetsUnsupportedOperationError. - A branch for every state. The loop has no branch for
TASK_STATE_SUBMITTEDorTASK_STATE_WORKING. A server that answers a blocking send at once (conflict D2) would leave it looping on the same reply. - Checks on every result. The
shipmethod reads the review artifact without checking that the review task completed, andfollowneeds aGetTaskcall when a stream drops. - A real operator channel. Route each approval to a person through an authenticated channel, and record who approved.
A2A protocol 1.0.1 · 3303592 · 2026-05-28 · Edition 2026.10