DEV Community

Cover image for What the Claude Agent SDK's query() Streams: Every Message, Logged
Gabriel Anhaia
Gabriel Anhaia

Posted on AI-assisted

What the Claude Agent SDK's query() Streams: Every Message, Logged


You wire up query(), wait for the message with type === "result", print message.result, and ship it. Then a run takes four minutes instead of ten seconds, or the cost estimate comes back three times what you expected, and you have nothing to look at. Every message that explained what happened went past your loop and got dropped.

The TypeScript reference for the Claude Agent SDK lists 36 members in the SDKMessage union that query() can yield. Anthropic's own agent loop guide calls five of them the core types: the system init message, assistant messages, user messages, stream events, and the result. Most of the rest are observability events such as rate-limit status and task notifications.

The order and fields below come from Anthropic's docs for the current SDK (the releases page shows v0.3.296 as the latest at the time of writing). I haven't captured a run for this post, so run the code yourself and diff your log against the documented order.

A tiny agent with one tool

Install the SDK, Zod for the tool schema, and tsx to run TypeScript directly. The quickstart asks for Node.js 18+ and reads your key from ANTHROPIC_API_KEY.

npm install @anthropic-ai/claude-agent-sdk zod
npm install --save-dev tsx
Enter fullscreen mode Exit fullscreen mode

The tool is an order-status lookup backed by a Map. It returns isError: true for unknown ids so you can see what a failed tool result looks like in the stream.

import {
  query,
  tool,
  createSdkMcpServer,
  type SDKMessage,
} from "@anthropic-ai/claude-agent-sdk";
import { z } from "zod";

const orders = new Map([
  ["A-1001", "shipped"],
  ["A-1002", "awaiting payment"],
]);

const getOrderStatus = tool(
  "get_order_status",
  "Look up the status of one order by id",
  { id: z.string().describe("Order id, like A-1001") },
  async ({ id }) => {
    const status = orders.get(id);
    if (!status) {
      return {
        content: [{ type: "text", text: `No order ${id}` }],
        isError: true,
      };
    }
    return { content: [{ type: "text", text: `${id}: ${status}` }] };
  },
  { annotations: { readOnlyHint: true } },
);

const shop = createSdkMcpServer({
  name: "shop",
  version: "1.0.0",
  tools: [getOrderStatus],
});
Enter fullscreen mode Exit fullscreen mode

Two details from the custom tools guide matter for the log. The key you register the server under becomes part of the tool's full name, so this one shows up as mcp__shop__get_order_status. And readOnlyHint: true lets the SDK run this tool concurrently with other read-only calls (parallel tool execution). When Claude asks for both orders in one response, you get two tool_use blocks, and the two lookups can run at the same time instead of one after the other.

A logger that drops nothing

TypeScript messages are discriminated on a type string. Assistant and user messages wrap the raw API message, so content blocks live at message.message.content, not message.content (agent loop guide).

const ts = () => new Date().toISOString().slice(11, 23);

function log(m: SDKMessage): void {
  switch (m.type) {
    case "system":
      if (m.subtype === "init") {
        console.log(ts(), "init", {
          session: m.session_id,
          model: m.model,
          tools: m.tools,
          mcp: m.mcp_servers,
          mode: m.permissionMode,
        });
      } else {
        console.log(ts(), "system", m.subtype);
      }
      return;
    case "assistant":
      for (const b of m.message.content) {
        const id = m.message.id;
        if (b.type === "tool_use") {
          console.log(ts(), "tool_use", id, b.name, b.input);
        } else if (b.type === "text") {
          console.log(ts(), "text", id, b.text.slice(0, 60));
        } else {
          console.log(ts(), "block", id, b.type);
        }
      }
      return;
    case "user": {
      const c = m.message.content;
      if (typeof c === "string") {
        console.log(ts(), "user", c.slice(0, 60));
        return;
      }
      for (const b of c) {
        if (b.type !== "tool_result") continue;
        console.log(ts(), "tool_result", b.tool_use_id, {
          isError: b.is_error ?? false,
          replay: "isReplay" in m,
        });
      }
      return;
    }
    case "stream_event": {
      const e = m.event;
      const detail =
        e.type === "content_block_delta" ? e.delta.type : "";
      console.log(ts(), "stream", e.type, detail);
      return;
    }
    case "result":
      console.log(ts(), "result", {
        subtype: m.subtype,
        turns: m.num_turns,
        ms: m.duration_ms,
        apiMs: m.duration_api_ms,
        usd: m.total_cost_usd,
        stop: m.stop_reason,
        session: m.session_id,
        denials: m.permission_denials.length,
      });
      return;
    default:
      console.log(ts(), "other", m.type);
  }
}
Enter fullscreen mode Exit fullscreen mode

The run uses permissionMode: "dontAsk", which gives the agent a fixed tool surface: pre-approved tools run, anything that would prompt is denied (permission modes). The default branch above catches every remaining member of the union, so nothing disappears silently.

const run = query({
  prompt: "What is the status of orders A-1001 and A-1002?",
  options: {
    mcpServers: { shop },
    allowedTools: ["mcp__shop__get_order_status"],
    permissionMode: "dontAsk",
    includePartialMessages: true,
    maxTurns: 5,
    maxBudgetUsd: 0.5,
  },
});

try {
  for await (const m of run) log(m);
} catch (err) {
  console.error("query() threw:", err);
}
Enter fullscreen mode Exit fullscreen mode

The try is there for a documented reason: a single-shot query() yields the error result first and then throws, for example with Reached maximum number of turns. Also, don't break when the result arrives. A few trailing system events, such as prompt_suggestion, can come after it (message types).

A long paper ticker tape unspooling from a black box, each segment stamped with a different pictogram, a burnt orange magnifier over one segment
Most loops read the last segment of the tape. The rest of it explains the run.

What comes out, in order

system / init comes first. It carries session_id, model, tools, mcp_servers (each with a status), permissionMode, cwd, claude_code_version and apiKeySource (SDKSystemMessage). If a SessionStart or Setup hook runs, its hook lifecycle messages arrive before init. Check mcp_servers here: a server that failed to connect shows up in its status before the model ever tries to call it. With tool search on by default, MCP tool schemas are deferred, so your log may also show a ToolSearch call before the first real tool call (custom tools guide).

assistant messages come one per content block. A response with a text block and a tool call yields two assistant messages, each carrying only its own block, and both share message.message.id (streaming output). A tool_use block has the id, name and input the Claude API defines. parent_tool_use_id is null in the main loop and set when the message came from a subagent.

user messages carry the tool results. The SDK emits one after each tool execution, with the content it sent back to Claude (agent loop guide). Inside is a tool_result block whose tool_use_id matches the tool_use it answers, plus is_error when the tool failed. Ask the agent about order A-9999 and the isError path from the handler shows up right here. The SDK's SDKUserMessage type also has a tool_use_result field typed unknown, and a replay variant marked isReplay: true for turns injected from outside.

stream_event messages only appear with includePartialMessages: true. They wrap raw Claude API streaming events, not accumulated text, and the docs give the interleaving exactly (streaming output):

StreamEvent (message_start)
StreamEvent (content_block_start) - text block
StreamEvent (content_block_delta) - text chunks...
AssistantMessage - complete text block
StreamEvent (content_block_stop)
StreamEvent (content_block_start) - tool_use block
StreamEvent (content_block_delta) - tool input chunks...
AssistantMessage - complete tool_use block
StreamEvent (content_block_stop)
StreamEvent (message_delta)
StreamEvent (message_stop)
... tool executes ...
... more streaming events for next turn ...
ResultMessage - final result
Enter fullscreen mode Exit fullscreen mode

Text arrives as text_delta, tool arguments as input_json_delta. Subagent token deltas are not forwarded. If a stream is cut off, you still get message_stop, but a cut tool call gets no content_block_stop, so treat that tool input as incomplete. That guarantee needs SDK v0.3.290 or later (handle a stream that's cut off).

system / compact_boundary shows up when history gets compacted, with compact_metadata.trigger (manual or auto) and pre_tokens, the size before compaction (SDKCompactBoundaryMessage). If the agent forgets an instruction mid-run, look for this line first.

A flat vector shop receipt curling out of a small machine next to a stopwatch and a stack of coins, burnt orange accents
Every run gets one at the end, including the runs that failed.

The result message is the receipt

The last core message is type: "result". Its subtype is success or one of error_max_turns, error_max_budget_usd, error_during_execution and error_max_structured_output_retries (handle the result). Only success has result, the final text. Error results carry an errors array instead. Every subtype carries session_id, num_turns, total_cost_usd and usage, so you can still account for a run that failed.

The fields worth logging every time:

  • num_turns: how many turns the run took. maxTurns caps only the tool-use round trips, so the final text-only reply doesn't eat into that cap.
  • duration_ms and duration_api_ms: wall time versus time spent in API calls. A large gap means the time went somewhere outside the API calls, such as tool execution.
  • total_cost_usd: a client-side estimate from a price table bundled with the SDK, not billing data (cost tracking). Anthropic says not to bill users from it.
  • usage versus modelUsage: usage covers only the main loop. modelUsage breaks tokens and costUSD down per model and includes subagents and compaction.
  • permission_denials: the authoritative record of what got blocked. The separate permission-denied stream event is best-effort and can miss one (SDKResultMessage notes).
  • stop_reason: common values are end_turn, max_tokens and refusal, and it is null on the result synthesized after a session crash (handle the result). Check for refusal explicitly.

The fields you need to resume, budget and debug

Resuming. Store session_id from the init message, not only from the result. The TypeScript SDK exposes it as a direct field on init (sessions guide), so a crash halfway through still leaves you an id. Pass it back as options.resume; add forkSession: true to branch without touching the original; use continue: true to pick up the most recent session in the directory. Sessions are stored as JSONL under ~/.claude/projects/, so resuming on another host means shipping that file or using a sessionStore adapter.

Budgeting. maxBudgetUsd is checked after each model response arrives, so spend can pass the cap by up to one response, plus anything running subagents spend before they stop (budget headroom). Leave headroom. When you resume a session, each result already includes the earlier spend, so read the latest one and don't sum them. If you count per step, assistant messages from one response share an id and identical usage, and per-step output_tokens is a placeholder (cost tracking). The fold looks like this:

const seen = new Set<string>();
let inputTokens = 0;
let sessionId: string | undefined;

function account(m: SDKMessage): void {
  if (m.type === "system" && m.subtype === "init") {
    sessionId = m.session_id;
  }
  if (m.type === "assistant" && !m.parent_tool_use_id) {
    if (seen.has(m.message.id)) return;
    seen.add(m.message.id);
    inputTokens += m.message.usage.input_tokens;
  }
}
Enter fullscreen mode Exit fullscreen mode

Call account(m) next to log(m) in the same loop. Take output tokens from the result's usage or modelUsage.

Debugging. A failed tool shows up as is_error on its tool_result. A blocked action lands in the result's permission_denials. Lost context leaves a compact_boundary with its pre_tokens. Match every tool_use id against a tool_result; an id left unmatched points at a call that never got its answer. And result_index on results counts from 0, so a gap in the sequence means a result was lost (SDKResultMessage notes).


If this was useful

The tool_use → tool_result handshake you just logged is the core of every agent, and most agent bugs show up there: an error the model read as data, a call that never got its answer. AI That Acts, book 3 of the AI in TypeScript series, builds that layer in TypeScript, from the first function call to a working agent.

AI That Acts — AI in TypeScript

The whole series, from your first LLM call to agents in production: AI in TypeScript.

Top comments (0)