Claude Agent SDKclaude-agent-sdk 0.2.157 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
46 small wins to finish your pathNext lesson →

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.

Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports this file. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep it in the same folder.
View the code here
shop_desk.py
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:

Example
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)
  • subtype is success, 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_error is the same answer as a boolean, which is convenient and not enough: four different failures share is_error being true and differ in their subtype.
  • num_turns is 1: one round trip, which here is the stand-in answering without calling a tool.
  • result holds the final text, the same words the last assistant message carried.
  • session_id is what you pass back to continue the conversation later. Resuming a session uses it.
  • total_cost_usd is 0.0 and usage counts 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 carries result.
  • 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:

Example
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))
  • errors holds the failure text on an error subtype, where result is None.
  • structured_output holds the validated object when the session asked for one, which Structured output covers.
  • model_usage breaks the tokens down per model. The agent loop page is explicit that usage covers only the main agent loop, so a run that spawned subagents is undercounted by usage and complete in model_usage.
  • stop_reason says why the model stopped generating on its final turn. The documented values include end_turn, max_tokens and refusal, which is how a refusal is detected.
  • permission_denials lists the tool calls that were refused, which Allow and deny rules comes back to.
  • duration_ms and duration_api_ms separate 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:

Example
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)
  • 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_usd from each result as it arrives, remembering that it is typed as optional and can be None.

A success against an error result

subtype successAn error subtype
resultThe final textNone
errorsNoneThe failure text
is_errorFalseTrue
Cost, usage, turns, session idPresentPresent
What query() does nextReturnsRaises, after yielding the result
What to read firstsubtype, then resultsubtype, 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.
Watch out. In Python 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.
Try it yourself
  • Print message.stop_reason and message.structured_output as 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 compare usage with the A17 run.
  • Delete the isinstance check and print every message's subtype. Read the AttributeError and put the check back.

Little by little, you're building something great.