The result message
A ResultMessage is the object that closes every session, carrying whether the run succeeded, the final text, the number of turns it took, the session id, the cost and the token usage.
Last updated: 20 Oct, 2026 · Claude Agent SDK
The run in query() and ClaudeAgentOptions printed three type names and threw the objects away. The last of the three is the only one a background job needs: everything the run produced, in one place.
View the code here
import asyncio
import itertools
import json
import re
from claude_agent_sdk import ClaudeAgentOptions, Transport
ORDER = re.compile(r"\b[A-Z]\d+\b")
SESSIONS = itertools.count(1)
class ShopModel:
"""Stands in for Claude: reads the conversation, picks the next step."""
def __init__(self, script=None):
self.script = list(script or [])
def reply(self, said, results):
# a scripted reply lets a lesson force a step the stand-in would not pick
if self.script:
return self.script.pop(0)
if results:
return {"text": " ".join(results)}
# the newest message first, then the last id named earlier in the chat
orders = ORDER.findall(said[-1]) or ORDER.findall(" ".join(said))[-1:]
return {"text": f"I cannot help with order {orders[0]} yet." if orders
else "Which order is this about?"}
class ShopDesk(Transport):
"""Stands in for the Claude Code binary: it runs the loop over JSON lines."""
def __init__(self, options=None, model=None):
self.options = options or ClaudeAgentOptions()
self.model = model or ShopModel()
# a counted id, not a UUID, so the printed output is the same every run
self.session_id = f"session-{next(SESSIONS)}"
self.inbox, self.outbox = asyncio.Queue(), asyncio.Queue()
self.prompts, self.said = asyncio.Queue(), []
self.ids, self.setup = itertools.count(1), {}
self.ready = False
async def connect(self):
self.ready = True
def is_ready(self):
return self.ready
async def write(self, data):
# one JSON line at a time, which is what the SDK writes to a real process
await self.inbox.put(json.loads(data))
async def end_input(self):
# for a real process this would close its standard input
await self.inbox.put(None)
async def close(self):
self.ready = False
async def send(self, message):
await self.outbox.put(message)
async def read_messages(self):
# the listener and the worker run alongside the SDK's own read loop
asyncio.create_task(self.listen())
self.worker = asyncio.create_task(self.work())
while (message := await self.outbox.get()) is not None:
if isinstance(message, Exception):
raise message
yield message
async def listen(self):
while (message := await self.inbox.get()) is not None:
# a control request is the SDK asking a question; initialize is first
if message["type"] == "control_request":
if message["request"]["subtype"] == "initialize":
self.setup = message["request"]
await self.respond(message["request_id"])
elif message["type"] == "user":
await self.prompts.put(message["message"]["content"])
# the input has ended: finish the work in hand, then close the stream
await self.prompts.put(None)
await self.worker
await self.send(None)
async def respond(self, request_id):
# the stand-in says yes to every request the SDK makes of it
await self.send({"type": "control_response", "response": {
"subtype": "success", "request_id": request_id, "response": {}}})
async def work(self):
while (prompt := await self.prompts.get()) is not None:
await self.turn(prompt)
async def turn(self, prompt):
self.said.append(prompt)
await self.send({"type": "system", "subtype": "init", "model": "shop-desk",
"session_id": self.session_id, "tools": []})
# one round only for now: the tool-calling rounds arrive with the tools
step = self.model.reply(self.said, [])
await self.say(step["text"])
await self.finish("success", 1, step["text"])
async def say(self, text):
await self.send(self.message("assistant", [{"type": "text", "text": text}]))
async def finish(self, subtype, turns, text):
ok = subtype == "success"
result = {"type": "result", "subtype": subtype, "is_error": not ok,
"duration_ms": 0, "duration_api_ms": 0, "num_turns": turns,
"session_id": self.session_id, "total_cost_usd": 0.0,
"usage": {"input_tokens": len(" ".join(self.said).split()),
"output_tokens": len(text.split())}}
# cost is zero and usage counts words, because no model was called
result.update({"result": text} if ok else {"errors": [text]})
await self.send(result)
def message(self, kind, content, parent=None):
body = {"role": kind, "content": content}
if kind == "assistant":
body.update(model="shop-desk", id=f"msg_{next(self.ids)}")
return {"type": kind, "message": body, "parent_tool_use_id": parent,
"session_id": self.session_id}
Reading the result's fields
Pick the result out of the stream with isinstance and read it:
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from shop_desk import ShopDesk
options = ClaudeAgentOptions()
async for message in query(prompt="Where is my order A17?", options=options,
transport=ShopDesk(options)):
# the result is the last message of a run, so this branch fires once
if isinstance(message, ResultMessage):
print(message.subtype, message.is_error, message.num_turns)
print(message.result)
print(message.session_id, message.total_cost_usd, message.usage)success False 1
I cannot help with order A17 yet.
session-1 0.0 {'input_tokens': 5, 'output_tokens': 7}subtypeissuccess, and it is the field to read first. It is the only thing that says whether the run finished or stopped at a limit.is_erroris the same answer as a boolean, which is convenient and not enough: four different failures shareis_errorbeing true and differ in their subtype.num_turnsis 1: one round trip, which here is the stand-in answering without calling a tool.resultholds the final text, the same words the last assistant message carried.session_idis what you pass back to continue the conversation later. Resuming a session uses it.total_cost_usdis0.0andusagecounts words, because the stand-in calls no model. Against a real Claude Code session these two are what the turn spent and the tokens it used. Nothing in this course bills anything, and no number printed here is a price.
Checking the subtype before reading the text
The agent loop page lists five subtypes, and result is filled on one of them:
success: Claude finished the task. This is the only subtype that carriesresult.error_max_turns: the turn cap was reached before Claude finished.error_max_budget_usd: the spend cap was reached first.error_during_execution: something interrupted the loop, for example a cancelled request.error_max_structured_output_retries: no output passed the schema within the retry limit.- All five carry the cost, the usage, the turn count and the session id, so a failed run can still be accounted for and resumed.
Listing every field the result carries
There are more fields than the six above, and the installed version will tell you which:
from dataclasses import fields
from claude_agent_sdk import ResultMessage
names = [f.name for f in fields(ResultMessage)]
print(len(names), "fields")
for row in (names[:7], names[7:13], names[13:]):
print(" ".join(row))19 fields subtype duration_ms duration_api_ms is_error num_turns session_id stop_reason total_cost_usd usage result structured_output model_usage permission_denials deferred_tool_use errors api_error_status uuid terminal_reason origin
errorsholds the failure text on an error subtype, whereresultisNone.structured_outputholds the validated object when the session asked for one, which Structured output covers.model_usagebreaks the tokens down per model. The agent loop page is explicit thatusagecovers only the main agent loop, so a run that spawned subagents is undercounted byusageand complete inmodel_usage.stop_reasonsays why the model stopped generating on its final turn. The documented values includeend_turn,max_tokensandrefusal, which is how a refusal is detected.permission_denialslists the tool calls that were refused, which Allow and deny rules comes back to.duration_msandduration_api_msseparate the whole run from the time spent waiting on the API.
Adding up several runs
Each query() call is its own run with its own result, so a total across calls is yours to keep:
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
from shop_desk import ShopDesk
words = 0
for question in ["Where is A17?", "Hello", "Is B22 on its way?"]:
# a fresh desk per question, because each query() call is its own run
options = ClaudeAgentOptions()
async for message in query(prompt=question, options=options,
transport=ShopDesk(options)):
if isinstance(message, ResultMessage):
print(message.session_id, message.usage["output_tokens"])
words += message.usage["output_tokens"]
print("output across three runs:", words)session-1 7 session-2 5 session-3 7 output across three runs: 19
- Three sessions, three ids. Nothing is shared between
query()calls, which is the whole difference between this and a conversation. - The second question has no order id in it, so the stand-in asks which order the customer means, in five words rather than seven.
- The totals are your job. Cost works the same way: add
total_cost_usdfrom each result as it arrives, remembering that it is typed as optional and can beNone.
A success against an error result
| subtype success | An error subtype | |
|---|---|---|
result | The final text | None |
errors | None | The failure text |
is_error | False | True |
| Cost, usage, turns, session id | Present | Present |
| What query() does next | Returns | Raises, after yielding the result |
| What to read first | subtype, then result | subtype, to find out which limit |
Where the result is the only message you need
- A scheduled job, where nobody is watching the stream and the question is whether it worked and what it cost.
- A cost dashboard, where one number per run is the whole feed.
- A resumable workflow, where the session id is the only thing worth storing.
total_cost_usd, usage and model_usage are all typed as optional, and the troubleshooting page adds that after a session crash the final result is an error_during_execution whose cost fields may be zeroed. Code that reads them without a None check works until the first bad run, and then fails in the handler meant to report the failure.Related
- Previous: query() and ClaudeAgentOptions
- Next: Streaming output
- See also: Cost and usage
- Reference: ResultMessage
- Print
message.stop_reasonandmessage.structured_outputas well, and see which of them the stand-in fills. - Ask about an order the stand-in does not know, such as
"Where is C40?", and compareusagewith the A17 run. - Delete the
isinstancecheck and print every message'ssubtype. Read theAttributeErrorand put the check back.
Little by little, you're building something great.