7.1

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.

delegate, the loop that switches on every replypythonFull log
        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 loop of the delegate method, complete. The lines above it, which pick the agent and send the message, are cut.

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.

follow, until the stream endspythonFull log
    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 whole method. The operator call stands in for a person who approves outside A2A.

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.

Fig. 7.1the planner's run, decision by decisionflow
The planner run, decision by decision The planner reads three cards and maps skills to agents. Lane one: SendMessage for run-tests on commit a41d7c3 returns task 954267b7 completed, and summary.json shows 13 passed and 0 failed. Lane two: SendMessage for review-diff returns TASK_STATE_INPUT_REQUIRED asking for the base branch, the planner answers main on the same task, the task completes, and review.json holds one low-severity finding. Lane three: SendMessage with a bearer token for deploy returns TASK_STATE_AUTH_REQUIRED, the planner subscribes, an operator approves outside A2A on the first frame, and the stream ends at TASK_STATE_COMPLETED. The planner decides after each lane and would stop on a failed test or a high-severity finding. DISCOVER: ONE CARD PER PORT, SKILLS MAPPED TO AGENTS DECISIONS stop if the test task does not complete, a test fails, or a finding is high severity test-runner run-tests code-reviewer review-diff, answer-question deployer deploy, needs a bearer token delegate run-tests SendMessage commit a41d7c3 TASK_STATE_COMPLETED task 954267b7 summary.json passed 13, failed 0 tests 13 passed, go on delegate review-diff SendMessage refunds.diff as a raw part TASK_STATE_INPUT_REQUIRED which base branch? answer SendMessage, same task answer from context: main TASK_STATE_COMPLETED task 80bc7784 review.json 1 finding, severity low review 1 finding, 0 blocking delegate deploy SendMessage build 2026.10.06-1, token TASK_STATE_AUTH_REQUIRED an operator must approve follow SubscribeToTask frame 1: the Task snapshot on frame 1 operator approves POST /approve, not A2A 3 more frames TASK_STATE_COMPLETED task a31361b3, stream ends deploy TASK_STATE_COMPLETED
The planner reads three cards, delegates three skills in turn, answers a question, escalates an approval, and decides from data parts before each next step. Read the three lanes left to right, then the decisions row. Each lane is one delegation, top to bottom, with the states the replies carried. Task ids are shortened to 8 characters.

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 without taskId creates 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 returnImmediately and follow the task.
  • Card checks. Cache cards with standard HTTP caching, and verify the deployer's signatures as extended and signed cards shows.
  • Capability checks. The planner records streaming and never reads it. Against an agent without streaming, its SubscribeToTask gets UnsupportedOperationError.
  • A branch for every state. The loop has no branch for TASK_STATE_SUBMITTED or TASK_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 ship method reads the review artifact without checking that the review task completed, and follow needs a GetTask call 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