Skip to article
HB CodeDocsGet the app

HandbookArchitecture

The Claude Agent SDK integration

How the desktop runs Claude through a Node agent host: the JSON-lines pipe, the HB Code tool bridge, the projection of Claude messages into the shared timeline rows, delivery proof, the run loop, subagents, and accounts.

Checked against the source on 5 October 2026

On this page

The agent host process

Claude runs through the agent host, a small TypeScript program in desktop/agent-host that uses @anthropic-ai/claude-agent-sdk. The desktop starts it with Node and passes the path of the packaged Claude executable as its only argument. A development build runs node from the PATH with agent-host/dist/src/entry.js. A packaged app runs its bundled node with claude/host/entry.mjs from its resources. The macOS build and the Windows builds package this runtime.

The two sides exchange one JSON object per line over stdin and stdout. A request carries an id of 1 to 200 characters, and its reply carries the same id with a result or an error. A line without an id is an event. The host checks every request against a strict schema, so an unknown field fails. The desktop gives each request 35 seconds. A request without a reply by then fails with “Claude host request timed out”.

RequestFields, limits, and what the host does
openFields: cwd, sessionId (UUID), model, effort (low, medium, high, xhigh, or max, optional), permissionMode (plan, acceptEdits, auto, or bypassPermissions), instructions, appCapabilities. Starts one SDK query for the session in its project folder. instructions is the text that Claude appends to its system prompt, and the desktop writes it. The folder and the executable must be absolute paths. It resumes the conversation when Claude has a saved transcript for that id, and otherwise starts a new one with that id. One host owns at most one session, so a second open fails.
openWorkerFields: cwd, model, effort (optional), instructions, prompt, appCapabilities. Starts the private query of an HB Code worker in the project folder. The query has only Read, Glob, Grep, and the tools in appCapabilities, uses the permission mode dontAsk, loads no user settings, and saves no transcript. It runs one turn. A host owns one session or one worker, so a second open or openWorker fails.
sendFields: messageId (UUID), text. Writes the message into the open query with the UUID that the desktop saved for this operation in claude_operations. Claude takes a message at any time. A message that arrives while a turn runs joins that turn after Claude’s current step. When the turn ends first, the message starts the next turn.
cancelFields: messageId (UUID). Takes back a message that Claude has not started, with the SDK call cancelAsyncMessage. The result is cancelled: true, or false when Claude has the message already.
permissionFields: requestId, allow. Answers a pending tool permission with allow or deny.
toolResultFields: requestId (UUID), result with success and contentItems. Gives the result of an HB Code tool call back to the waiting SDK tool handler. A result for a call that is no longer open is ignored.
interruptNo fields. Denies every open permission, stops every open HB Code tool call, and interrupts the turn. It also drops the messages that still wait for the turn, so none of them starts a new turn after the stop.
closeNo fields. Closes the query. The host process exits when the desktop closes its stdin.
historyAlso subagentHistory. Fields: cwd, sessionId, before (UUID, optional), limit 1 to 100. subagentHistory adds agentId, 1 to 64 lowercase letters and digits. Returns one page of the saved conversation or of one subagent.
messageSavedFields: cwd, sessionId, messageId (UUID). Returns saved: true when the saved conversation has the user message with this UUID. This is the delivery proof. The host keeps the saved IDs of one transcript and reads only the bytes that Claude added after the last read.
historyAroundFields: cwd, sessionId, agentId (optional), place, limit 1 to 100. The place is a message uuid, a toolUseId, an apiMessageId, a time with atOrAfter or atOrBefore, or the messages after a uuid. Returns in one read the page of the conversation or of one subagent that holds the place. A place in the newest limit messages gives newest: true. A place that the transcript does not have gives found: false.
toolOutputFields: cwd, sessionId, agentId (optional), toolUseId, 1 to 128 letters, digits, _ or -. Returns the saved result of one tool call.
list, inspectFields: cwd, and offset and limit 1 to 100, or sessionId. Lists the saved conversations of the folder, or returns one conversation with the model that answered last.
subagentsFields: cwd, sessionId, known, endedRuns, openRuns, each at most 10,000 entries. Returns the subagents and workflow runs of the conversation. It reads only what the desktop does not hold yet or what changed.
catalog, usage, titleFields: cwd for catalog. cwd and prompt, at most 40,000 characters, for title. Reads the model list, the plan usage, or a short session title.
EventFields and meaning
messageFields: message. One SDK message as the query returns it: stream events, assistant and user messages, system messages, and the turn result.
permissionFields: requestId, toolUseId, tool, input, title, reason, agentId. Claude waits for an answer before it uses a tool. agentId is set when a subagent asks.
permission settledThe event permissionResolved. Fields: requestId, allow. The request is settled, by an answer or by a stop.
toolCallFields: requestId, toolUseId, tool, arguments. Claude called an HB Code tool, and the handler waits for toolResult.
toolCancelFields: requestId. The call was stopped. The desktop must stop its work for it.
failedFields: error. The SDK stream threw an error.
closedNo fields. The SDK stream ended although nobody closed the session.
A host exists for one run, and each other read starts its own host
A host exists for one run, and each other read starts its own hostFour lanes over one run, not to scale. The desktop spawns an agent host and sends open and send. The host holds one SDK query, and the SDK starts the claude executable. When the session turns idle, the host reports it. The desktop sends close, then closes stdin, and the host exits. A host that still runs 10 seconds later is killed with every process that it started. The host of the run also answers the delivery-proof reads of the run. Separate one-shot hosts answer a history page read, a command-output read, and the final read, and each exits after one request.
DesktopRust runtime
Agent hostNode, this run
claudestarted by the SDK
One-shot hostsone request each
one SDK query for one session
host exits
claude executable
spawn
open
send
idle: the run is over
close, 3 s at most
stdin closed
history page
command output
final read
each answers one request and exits
not to scale
  • Run hostClaudeHost::spawn

    Every run starts a new host with the session’s model, effort, and permission mode. It opens one SDK query in the project folder, resumes the saved conversation if there is one, and never outlives the run.

  • Shutdownclose · stdin · killpg

    At the idle state the desktop sends close and gives it 3 seconds, then closes stdin. If the host still runs 10 seconds later, the desktop kills it with every process that it started, so the claude child goes too.

  • One-shot hostsClaudeHost::one_shot

    History pages, command output, subagent transcripts, the final delivery proof, the model catalog, usage, and titles each start a host that answers one request and exits. During a run, the host of the run answers the delivery-proof reads. Transcript reads always use the main Claude folder.

  • Inherited markersCLAUDECODE · CLAUDE_CODE_CHILD_SESSION · CLAUDE_PID

    Removed with six more before every start. A desktop launched from inside Claude Code would otherwise run its sessions as child sessions, and Claude doesn’t save their transcripts.

Every run gets a new host, and the host never survives its run. When the run ends, the desktop sends close and waits at most 3 seconds for the reply. Then it closes stdin and gives the host 10 seconds to exit, and after that it kills the host and every process that the host started: the process group on macOS, and the process tree with taskkill on Windows. Reads that need no open conversation, such as the model catalog or a title, run in a one-shot host of their own. Before the start, the desktop removes nine environment markers that a parent Claude Code session gives its children, among them CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID, and CLAUDE_PID. A desktop that you start from inside Claude Code would otherwise run its sessions as child sessions, and Claude doesn’t save their transcripts.

A request that fails the schema gets an error reply with its id. A line that the host can’t read as JSON gets a reply with the id null, and the desktop ignores it. The desktop buffers at most 256 events from one host. When the host’s stdout closes or a line is not JSON, the desktop fails every waiting request and makes a failed event itself. The host’s standard error goes to the desktop debug log.

  • Settings and prompt: the query loads user, project, and local settings and uses the claude_code system prompt. The host appends the instructions of open to it. For each run, agents/session_rules.rs writes this text in this order: the questions rule, the session messages rule, the directions rule, and the text of the reply style. The questions rule says that Claude asks the user with AskUserQuestion and that the call doesn’t wait: the user answers in a later message. The session messages rule says that Claude reaches another HB Code session only with the hbcode tools send_session_message and maintain_sessions, because SendMessage and ListAgents reach only Claude Code sessions outside HB Code. The directions rule tells Claude to do all of an explicit direction, to ask one question, or to object with evidence. Simplified Technical English adds its reply text last, and Default adds no text. The app-tool text is not in this text: it is the instructions of the MCP server hbcode.
  • Stream: includePartialMessages gives the live deltas, and forwardSubagentText sends each subagent turn in full. persistSession saves the transcript.
  • Tools: the query gets the MCP server hbcode and its allowedTools. A PreToolUse hook answers every AskUserQuestion call at once. allowDangerouslySkipPermissions is set only for bypassPermissions.
  • Run end: the host turns on Claude’s session state events with an environment variable, because a turn that starts background subagents ends before they do. Only the idle state tells the host that the whole run is over.
  • Input: the query reads its messages from one stream that stays open for the whole run. The host writes each send into it at once, also while a turn runs.

The title request is a separate query. It uses the model haiku with no tools, one turn, no settings, and no saved session, and its system prompt forbids Claude to act on the message. The desktop asks for a title after send, in a one-shot host on the active account.

HB Code tools over the host

Codex and Claude get the same HB Code tools from one catalog in services/agent_tools. For Claude, the desktop builds the list for each run and sends it in open.appCapabilities: one instructions text and, for each tool, its name, description, input schema, and approval class. The host turns the list into an in-process SDK MCP server named hbcode, so Claude sees each tool as mcp__hbcode__<name>. A duplicate name or an input schema that is not an object fails the open.

Approval classTools and who approves a call
read-onlyTools: maintain_sessions. No one approves. The host puts the tool in allowedTools and marks it with readOnlyHint, so Claude doesn’t ask.
desktopTools: send_session_message. The desktop approves, inside the tool. The host puts the tool in allowedTools. A message that needs you waits in the recipient’s queue.
agentTools: use_user_browser, manage_sessions, send_mobile_notification, synthesize_speech, create_explainer_video, extract_video_context. Claude asks through canUseTool as its permission mode says. You get the same approval card as for a built-in tool.

The list depends on the settings. send_mobile_notification is there only while notifications are on, and synthesize_speech, create_explainer_video, and extract_video_context only with a Gemini key.

When Claude calls a tool, the SDK handler checks the arguments against the full schema, makes a new requestId, and emits toolCall. Then the handler waits. The event can’t choose its target. The desktop rejects a toolCall with any field besides requestId, toolUseId, tool, and arguments, and it routes the call by the session and the run that own this host. A rejected event or a second call with the same requestId fails the run.

A slow HB Code tool runs in its own task, so the run loop still handles Stop
A slow HB Code tool runs in its own task, so the run loop still handles StopFive lanes, not to scale. Claude calls two HB Code tools in one turn. The agent host holds both calls and sends one toolCall event for each. The run loop starts one task per call and goes on with approvals, rows, and its heartbeat. Task A finishes and sends toolResult to the host, which gives Claude the tool result. Task B still runs when you press Stop. The loop sends interrupt, the host answers Claude with “Run stopped.” and sends toolCancel, and the desktop aborts task B.
ClaudeSDK query
Agent hostMCP server hbcode
Run loopin the desktop
Tool task Aown task
Tool task Bown task
turn
waits for both tool results
two tool_use
holds both calls, one requestId each
toolCall
synthesize_speech
use_user_browser, still running
approvals, rows, and heartbeat go on
toolResult
tool_result
Stop
interrupt
“Run stopped.”
toolCancel
aborted
not to scale
  • Tool listopen.appCapabilities

    The desktop sends the instructions and each tool’s name, description, input schema, and approval class with open. The host builds the SDK MCP server hbcode from them.

  • CalltoolCall { requestId, toolUseId, tool, arguments }

    The host keeps the SDK tool handler open under a new requestId. The event can’t name a session or a run. The desktop routes the call by the run that owns the host.

  • Tool taskAppToolCalls · toolResult

    Each call runs in a task of its own beside the loop, and the task sends toolResult itself. If that request fails, the run fails.

  • CanceltoolCancel { requestId }

    On Stop or an aborted call, the host answers Claude with “Run stopped.” and sends toolCancel. The desktop aborts the task, and a result that crosses the cancel is ignored.

Each call runs in a task of its own beside the run loop. A slow tool therefore can’t block Stop, approvals, timeline rows, or the result of another tool. The task runs the shared tool handler with Claude’s tool use id as its call id and then sends toolResult itself. A tool that fails returns a result with success false, and Claude sees a tool error. If the toolResult request fails, the run fails.

When the SDK aborts a call or the run stops, the host answers the handler with “Run stopped.” and emits toolCancel, and the desktop aborts that task. A result that crosses the cancel is ignored. At the end of the run the desktop stops every task that is left. In the timeline the desktop makes the title of an HB Code tool row from its request, for example “Create session” or “Revise explainer video”.

History and the shared timeline rows

The desktop never reads Claude’s transcript files itself. The host reads them with getSessionMessages and returns pages of at most 100 complete messages. It joins adjacent assistant envelopes that have the same API message id and drops messages with nothing to show. A page boundary is a message uuid, and when that message is gone the reply asks the desktop to reload. A conversation without a transcript loads as empty. The SDK returns a compacted conversation from its last compaction on, so older turns of such a conversation don’t appear in HB Code. A command’s full output is read on demand through toolOutput, also for a subagent, the same way a Codex protocol output is read.

The cursor for older pages is a JSON object with session, before, anchor, and revision, at most 4096 characters long. The desktop checks it against the conversation or the subagent that it pages. A cursor for another target fails with a conflict that asks you to reload the conversation.

Reads are not cheap. Every transcript read starts a one-shot host, and getSessionMessages reads the whole transcript, because the SDK call has no range. A timeline load runs two hosts side by side: one for the page and one for the subagent list. A delivery proof is one messageSaved read. During a run the host of the run answers it. After the run a one-shot host answers it.

Claude saves one line per content block, and HB Code builds timeline rows from the lines
Claude saves one line per content block, and HB Code builds timeline rows from the linesOn the left, eight lines of a saved Claude conversation. On the right, the timeline rows they become. Three assistant lines share the API message id msg_01, so the host joins them into one message before the adapter projects its thinking, text, and Bash call into a reasoning row, a reply row, and a command row. The Bash result arrives in a later user line and completes the command row through its tool_use_id. An Edit call and its result become one file change row. A synthetic user line makes no row.
Saved transcript
user · "Fix the login test"
assistant · msg_01 · thinking
assistant · msg_01 · text
assistant · msg_01 · tool_use Bash
user · tool_result
assistant · msg_02 · tool_use Edit
user · tool_result
user · isSynthetic
Timeline rows
Your message
Reasoning
Reply text
Command · npm testoutput stays in the transcript until you open it
File change · login.test.tsdiff from the old and new text, bare @@ hunks
one messagemsg_01
tool_use_id
tool_use_id
no row
  • Transcript pagegetSessionMessages · 100 messages

    The host reads Claude’s saved conversation and returns at most 100 messages per page. A page starts at a message uuid, and when that message is gone the desktop reloads.

  • One API messagemessage.id

    Claude saves one line per content block. The host joins adjacent assistant lines with the same API message id, so a message keeps one identity whether it is live or saved.

  • Tool resulttool_use_id

    Arrives in a later user line, and the adapter completes the call row in place. A Bash result becomes an output row that is read from the transcript only when you open it.

  • Hidden linesisSynthetic

    Synthetic user messages, meta lines, and empty thinking make no row. Terminal housekeeping such as /exit is hidden too.

The Claude adapter projects each message into the same timeline rows that Codex uses. A row takes its identity from stable data only, so the saved row replaces the live one in place.

RowIdentity key
A message that HB Code sentuser-message::{operationId}, the same for the outbox row, the live row, and the saved row
Reply text{messageId}:text, with the API message id
Thinking{messageId}:thinking:{position}, the position among the thinking blocks of the message
Tool calltool:{toolUseId}:call
Command output, or a result whose call is on an older pagetool:{toolUseId}:result
Image in a tool resulttool:{toolUseId}:image:{n}
Any other saved user row or noteThe uuid of the saved message

The SDK sends a final envelope for each content block. Such an envelope replaces its own text fragment, so a delta is never added twice. Saved rows sort by their position in the transcript, and live rows by arrival. A live view also sends the rows that it showed before and no longer shows, so every client removes them. A live prompt row exists only for a message that HB Code sent. Its row shows the display text and the attachments from claude_operations. The text that Claude gets is longer, because the desktop adds the skills context and the attachment paths. Every other prompt shows from the saved transcript.

Claude message or blockTimeline row
PromptA user row. Pasted images become its attachments, and <system-reminder> blocks are removed.
Bash tool call and its resultA command row with its output. The output row names the saved result, which is read only when you open it. A failed command takes its exit code from the first result line “Exit code N”.
File editsEdit, MultiEdit, Write, and NotebookEdit. A file change row. Claude gives the old and the new text, so the adapter builds the unified diff. An edit’s hunks have bare @@ headers, because Claude gives no line numbers. A Write shows a new file with a full @@ -0,0 +1,N @@ header. When its result says that it replaced a file, the row becomes an update with a bare @@. A failed edit shows the reason and no patch. A NotebookEdit that deletes a cell has no patch.
ReadA tool row titled with the file name. Its detail shows the file text without <system-reminder> blocks and opens the file.
Any other toolOne row that its result completes in place: Tool call, then Tool output, Tool error, or Tool declined. It shows a readable label, such as “Search in files” for Grep, and an MCP tool shows as “server: tool”. A result whose call is on an older page is a row of its own, Tool result or Tool error. A result block that is not text shows as its kind in brackets.
Tool call without a saved resultFinished when a later assistant message arrives. A command then shows “Claude did not save the output of this command.”
SendMessage callA message row “Message to <name>” from the call on. When the call ends, the row shows Sent or Not sent. A message to a subagent of this session links to the subagent thread at the time of the call. A call without a recipient or without text stays a tool row.
HB Code toolA tool row with a title that the desktop makes from the request, for example “Create session”. A finished send_session_message call is a message row.
Question for the userAskUserQuestion. A message row “Question for you” with the questions and their options, not a tool row. A conversation from the terminal also shows the saved answers.
Plan toolsTodoWrite, EnterPlanMode, and ExitPlanMode. A plan with its steps, or a plan event row with the plan as Markdown.
Image in a tool resultAn image row after the tool row.
ThinkingA Reasoning row. Empty thinking makes no row.
Interrupt noteA turn aborted marker.
Compaction summaryA “Conversation compacted” marker.
Local slash commandIts name and its output. Terminal housekeeping such as /exit is hidden.
Message from the lead agent or from another Claude sessionA message row: “Message from Claude” for the lead agent, and “Message from <name>” for another Claude session, else “Message from Another Claude session”.
User message inside a subagent’s viewA message row: “Task from Claude” for the first one, then “Message from Claude”. Each one links to the main timeline at its time.
Reply that Claude writes itself, such as a usage limit or an API errorA system row.
Rows without content for youHidden: a synthetic user message (isSynthetic), other meta rows, and the notice that a background subagent finished.
Call that starts a subagent or a workflowNo row. The call and its result leave the view. The agents list shows the subagent or the workflow.

Find and continue a saved conversation

To continue an existing Claude Code conversation, the desktop lists the saved conversations of exactly that workspace folder, newest first. The host calls listSessions with includeWorktrees false and includeProgrammatic false. A conversation from another checkout or worktree can’t resume in this folder, so it isn’t listed. A page holds 25 conversations by default and 100 at most, and its cursor is an offset into Claude’s own sorted list. Each row carries linkedAppSessionId when an HB Code session already owns the conversation. A title shows only when it says more than the first prompt.

A preview reads one page of the transcript. It binds nothing, writes nothing, and has no older pages.

When you continue, inspect must find the transcript in this exact folder. The new session takes Claude’s own name for the conversation, the custom title or else the summary. The table claude_sessions then binds the two ids. It has one row per session, and native_session_id is unique. A conversation therefore belongs to at most one HB Code session, and choosing it again opens that session instead of a second one. If that session belongs to another folder, the request fails with a conflict that names the session. A copy of a session gets a new conversation id. Transcript reads need no sign-in, and they always run with the default account’s folder.

Models and effort

The model list comes from a one-shot catalog host, one read per account and workspace folder. The desktop keeps a list for 10 minutes and clears all lists on a sign-in, a sign-out, an account switch, and an account removal. Claude lists aliases such as default and opus beside pinned model ids. The desktop keeps one row per resolved model id, and the alias default only marks the model that the account recommends.

A model’s default effort is high when the model offers it, and otherwise its first level. A model without effort levels gets the effort default, and open then carries no effort field. A new conversation starts with the recommended model. A continued conversation starts with the model that answered last, if the catalog still has it.

Live runs, delivery proof, and approvals

agents/dispatch sends a queued message to the adapter of the session’s agent. A Claude session can’t replace the latest turn or use a speed mode. It can steer a running turn. A session has one active Claude run at a time. The registry RUNS holds one run per app session, and a second run fails with “The Claude session already has an active run.”

The run opens the host with the session’s model, effort, and permission mode, records the operation, and then sends the text. The record is one row in claude_operations with a new UUID for the message. The insert keeps an existing row, so a retry of the same operation keeps the first UUID. A retry with another text or other attachments fails as a conflict. A retry from another run reads the transcript first. When the transcript has the message, the run sends nothing and the operation counts as sent. When it does not, the operation moves to the new run and goes out with the same UUID. The host of the earlier run has stopped, so Claude can’t take the message from it any more.

A message that you send while the run is active is a steer. The run loop owns every write to the host, so the steer goes to the loop. The loop records the operation with a UUID of its own and sends it. Claude reads the message after its current step, between two tool rounds. When the turn ends first, the message starts the next turn of the same run. The outbox entry stays sending with an acceptance that names the run, the Claude session ID as the thread, and the Claude ID of the message that started the run as the turn. A steer that finds no active run, or a run other than the one it names, is deferred, and the message stays queued.

Stop and send now takes a joined message back. The loop sends cancel to the host. When Claude has not started the message, the desktop stops the run and sends the same operation as a new run. When Claude has the message already, nothing is sent again.

Claude’s live stream never echoes the prompt, and a stdin write or a delta is never proof of delivery. Claude saves the message that starts a run before it calls the model. So the first message frame after send starts one transcript read. After a read that found nothing, each frame that names the message starts another. A frame names a message in user_message_uuid or user_message_uuids, or as a command_lifecycle frame with the state started or completed. The thinking counter and the idle frame start none. Claude saves a joined message later, with its next reply. So the frame that says that Claude started the message starts a read, and each of the next 3 replies of the conversation starts one more. When a read finds a message, the saved row appears first and the outbox row goes after it. At run end, one more read for each message without proof is the final proof, also for a stopped or failed run. A message that started the run and that no read finds is never sent again automatically. A joined message that no read finds returns to the queue.

A Claude message counts as sent once a read finds it in the transcript
A Claude message counts as sent once a read finds it in the transcriptTimes from one measured run for the message that starts the run, with a break in the axis. The desktop sends the message at 0 ms, and Claude saves the user line to its transcript before it calls the model. The first live frame arrives after about 10 ms and starts a messageSaved read in the host of the run. That read finds the line at about 160 ms, so the outbox row turns into the saved row. The stream’s message_start, which names the message’s uuid, comes only at about 1.5 seconds, followed by the reply’s deltas. Neither counts as proof. A message that joins a running turn is saved later, with Claude’s next reply, so its reads start when Claude starts the message.
Your messagedesktop and phones
Live streamhost to desktop
Proof readsmessageSaved
Transcript~/.claude/projects
outbox row · Sending
saved row
saved row first, then the outbox entry goes
send
first frame starts a read
message_start names the uuid
reply deltas
neither is proof
messageSaved read
user line saved before the model call
found
0 ms
100 ms
200 ms
300 ms
1400 ms
1600 ms
1800 ms
2000 ms
At run end, one more read decides, also for a stopped or failed run.
  • Operation uuidclaude_operations

    Each message gets a new UUID, which the host sends as the SDK user message uuid. The desktop saves the pair, and a saved user line with that uuid is the only proof.

  • First readfirst message frame

    For the message that starts a run, the read starts on the first frame after send. Claude saves that message before it calls the model, so this read usually finds it before the reply starts.

  • Stamped frameuser_message_uuid · user_message_uuids

    A frame that names the operation’s uuid in user_message_uuid or user_message_uuids. It proves nothing by itself. After a read that found nothing, such a frame starts another read. The thinking counter repeats the uuid many times a second and starts none.

  • Final readreconcile_operation

    Runs after the host shuts down, for finished, stopped, and failed runs alike. A message that started the run and that no read finds is never sent again automatically. A joined message that no read finds returns to the queue.

Access levelClaude permission mode
read-onlyplan
full-autoacceptEdits
full-accessauto. The open fails when the model doesn’t support Auto mode.
unrestrictedbypassPermissions

When Claude asks to use a tool, the host holds the SDK’s canUseTool promise and sends a permission event. The desktop shows the same approval card as for Codex, with Accept and Decline and no other decision. A Bash call is a command approval, and every other tool is a permissions approval. A phone answers through run.chatResolveApproval, and the dispatch lets the Claude adapter answer first when it holds that request id.

Claude waits inside canUseTool until the desktop or a phone answers
Claude waits inside canUseTool until the desktop or a phone answersFour lanes, not to scale. Claude wants to run a command, and the SDK calls canUseTool. The host keeps that promise open and sends a permission event, so the desktop shows an approval card, and a phone that watches the session shows the same card. The phone answers with run.chatResolveApproval. The desktop sends the host a permission request with allow, the host resolves the promise, and Claude runs the command. Both cards then show the answer.
ClaudeSDK query
Agent hostholds the promise
Desktopapproval card
Phonesame card
working
waits
runs npm install
canUseTool
promise held
permission event
Accept · Decline
Accepted
live event
Accept · Decline
tap Accept
run.chatResolveApproval
permission: allow
Accepted
not to scale
  • canUseToolapprovals.ts

    The SDK asks before each tool use that the permission mode doesn’t allow by itself. The host keeps the promise open, and Claude waits.

  • Approval cardpermission { requestId, toolUseId, agentId }

    The desktop shows the same card as for Codex, and phones get it as a live event. A Bash call becomes a command approval, and a subagent’s request names the subagent.

  • Answerrun.chatResolveApproval

    A phone and the desktop take the same dispatch path. The Claude adapter checks the thread, turn, and item, then sends the host a permission request with allow or deny.

  • Stopinterrupt

    The host denies every open request with “Run stopped.”, and the cards end as Cancelled when the run ends. A question (AskUserQuestion) never opens a card: the host answers it at once, and the user answers in a later message.

  • Reason text: for ExitPlanMode the card shows “Claude wants to carry out this plan:” and the plan. For Bash it shows Claude’s own description of the command. For another tool it shows the description, or else the tool name with its file path or URL, or else the tool name with its input as JSON.
  • Subagents: a subagent’s request starts with “Requested by” and the name that the agents list shows.
  • Answer: the adapter checks the thread, the turn, the item, and the decision. When no run holds the request any more, the answer fails with “The Claude run has ended.”
  • Decline: Claude gets “The user declined this action.”, and the tool row becomes Tool declined.
  • Stop: the host denies every open request and stops every open HB Code tool call with “Run stopped.” The end of the run marks the remaining cards as cancelled.
  • Questions: a question never holds the run. A PreToolUse hook in the host answers AskUserQuestion at once in every permission mode, and Claude gets the notice that the question is shown and that the answer arrives as a later message. The timeline shows the call as a “Question for you” row, its options put an answer in the composer, and phones get a notification. No card opens.
  • Duplicates: the host denies a second request with a request id that is still open.

The run loop, failures, and Stop

After send, one loop owns the run. It waits on seven inputs at the same time and handles the one that is ready first.

InputWhat the loop does
A tool task finishedForgets the task. A task error ends the loop as a failure.
The stop signalCloses the event channel, sends interrupt to the host, and leaves the loop as cancelled. The interrupt drops the messages that still wait for Claude, and the exit returns them to the queue.
The heartbeat, every 2 secondsCalls touch_chat_run, which tells the run registry that the run is alive. It also leaves the loop as cancelled when the run is marked as aborted.
A proof read settledWhen the read found the message, it adds the saved user row, publishes the rows, and then removes the outbox row.
A request for the running turnA steer or a take-back from another task. The loop sends the steer to the host with send, or asks the host to cancel a waiting message, and answers the task.
The wait after a rest, 10 secondsRuns only after Claude rested while a joined message still waited. When no frame arrives in that time, the loop ends.
A host eventtoolCall starts a tool task, and toolCancel aborts one. permission and permissionResolved update the approval cards. A message goes to the delivery proof and the projection. failed, closed, and the end of the event channel end the loop as a failure.

Two message frames get special handling. The result frame records the success or the failure of the turn, and the loop goes on. The idle state ends the loop, with one exception. Claude can report that it rests just before it reads a message that arrived at that moment, and it then starts a turn for that message. So when a joined message still waits for Claude and the run has no failure, the loop goes on and waits 10 seconds for the next frame.

One loop waits on seven inputs, and send divides the failures
One loop waits on seven inputs, and send divides the failuresSeven lanes over one run, not to scale, and a strip with the exit order. The run lane shows spawn, open, and record, then send, then the select loop, then the exit. Every failure before send is ProvenUnadmitted. From send on, the saved transcript decides. Six lanes show inputs of the loop: host events that end with the idle state, a heartbeat every 2 seconds, a tool task that finishes, a proof read that finds the message, a message that joins the running turn, and the stop signal, which is not used in this run. The seventh input, the 10 second wait after a rest, has no lane. The strip below lists the seven exit steps in their fixed order.
before send: every failure is ProvenUnadmitted
from send on: the saved transcript decides, and a message that is not found is AdmissionUncertain
Runone host
Host eventsevents.recv()
Heartbeatinterval, 2 s
Tool taskstool_calls.completed()
Proof readsdelivery.settled()
Turn requestssteers.recv()
Stop signalstop.changed()
spawn, open, record
select loop
exit, fixed order
app tool
read
message, permission, toolCall. The idle state ends the loop.
touch_chat_run and the abort check
the task sent toolResult
found: saved row first, then the outbox row goes
a steer: send to the host. A take-back: cancel.
not pressed here. Stop sends interrupt and leaves the loop.
send
idle state
not to scale
Exit order, the same after idle, Stop, and a failure
1. tool tasks stopped
2. host shut down
3. approval cards cancelled
4. subagent views settled
5. final proof reads
6. terminal event
7. unsent messages back to the queue
  • Before sendProvenUnadmitted

    Spawn, open, the Auto mode check, a second run, a record conflict, or a Stop before send. Claude did not get the message.

  • From send onAdmissionUncertain · reconcile_operation

    Claude can have the message. The final read decides: a saved message counts as sent, also after a failure. A message that no read finds is never sent again automatically.

  • Select looptokio::select! · touch_chat_run

    Seven inputs: a finished tool task, the stop signal, the 2 second heartbeat, a settled proof read, a request for the running turn, the 10 second wait after a rest, and a host event. The idle state ends the loop, unless a joined message still waits for Claude. The result frame does not.

  • Exit orderturn.rs · execute

    The same after idle, Stop, and a failure: tool tasks, host, approval cards, subagent views, final reads, terminal event, and the return of joined messages that Claude did not take.

The exit order is fixed, whatever ended the loop.

  1. The desktop closes the event channel and stops every tool task that is left.
  2. It shuts the host down: close, 3 seconds, stdin closed, 10 seconds, kill.
  3. It marks every open approval card as cancelled.
  4. It settles every subagent view that is still open: as aborted after a stop or a failure, and as completed otherwise.
  5. It runs the final proof reads: first for each joined message without proof, then for the message that started the run.
  6. It sends the terminal event of the run: aborted, failed with the reason, or completed.
  7. It returns each joined message that the transcript does not have to the queue.

Then the desktop forgets the account’s usage reading, finishes the run in the run registry, and removes it from RUNS. The send boundary decides what a failure means. ProvenUnadmitted says that Claude did not get the message. AdmissionUncertain says that Claude can have it, so the saved history must decide before a retry.

WhenFailure and outcome
A steerNo run is active, or the active run is not the run that the steer names. Outcome: Deferred. The message stays queued.
A steerThe send request to the host fails, or the run’s task stops while it gives the message to Claude. Outcome: AdmissionUncertain.
Before sendNo account folder, a runtime that is not packaged, a spawn error, an open error such as a model without Auto mode, a second run for the session, a child thread as the target, or a record conflict. Outcome: ProvenUnadmitted.
Before sendStop arrives after open. Outcome: ProvenUnadmitted: “The run was stopped before sending.”
Before sendThe operation has an earlier attempt from another run, and the transcript read for it fails. Outcome: AdmissionUncertain.
At sendThe send request fails or gets no reply in 35 seconds. Outcome: AdmissionUncertain.
In the loopA failed event, “Claude closed before the run completed.”, “The Claude host disconnected.”, a tool task error, or a result with is_error. Outcome: The exit order runs, and the final read decides. If the message is saved, the message counts as sent and the run ends with a failed event that carries the reason.
At the endThe final read does not find the message that started the run. Outcome: AdmissionUncertain: “Claude has not confirmed this message in its saved history. It will not be sent again automatically.”
At the endThe final read does not find a joined message. Outcome: The ledger marks the operation as canceled, and the entry returns to the queue with automatic drain off and the text “The active run stopped before this message reached the agent. Review and send it again.”
At the endThe host shutdown or the final read fails. Outcome: AdmissionUncertain.

The reason of a failed result comes from Claude’s errors list, or from its result text for API errors such as a missing sign-in. A failed run keeps its reason in the desktop error log under claude_run, with the session, run, and operation ids. Packaged apps discard standard error, so the log line alone would be lost.

A cancel answers only for the exact run id. It sets the stop flag and waits until the run reports that it is done, which happens after the host has exited. If the run’s task died and can no longer report, the cancel itself shuts that host down, finishes the run, and removes it from RUNS.

The shared run registry reclaims a run as stale when it shows no activity for 45 seconds. A Claude host can need longer than that to open a conversation, and the first heartbeat comes only from the loop. So the registry asks the Claude adapter first, and a run whose exact host process is alive is never reclaimed.

Subagents as child threads

Claude subagents use the same list, timeline, and live paths as Codex child threads. During a run, Claude reports each subagent through task events and forwards its turns with the id of the spawning tool call. Only a task_started event with the task type local_agent makes a subagent. The adapter sends the forwarded turns to the subagent’s own view. The call that started the subagent and its result leave the parent’s view, so no row stands for the subagent. A subagent that the conversation started has depth 1, and each nesting adds 1. A live subagent carries no parent id, so the depth of a nested subagent is known only from its saved transcript.

Claude task statusThread status
pendingPendingInit
running, pausedRunning
completedCompleted
failedErrored
killed, stoppedInterrupted

A workflow is a script that the conversation starts with one call of the Workflow tool, and the script starts subagents in phases. The running turn follows a workflow as one task: a task_started event with the task type local_workflow, which gives the id of the starting tool call, the workflow name, and a summary. The later task events of that task change its status as the table above shows. Claude does not forward the turns of a workflow’s subagents, and no task event names them. They appear in the list only through their saved files.

Background subagents keep a run going after the turn’s result
Background subagents keep a run going after the turn’s resultFour lanes, not to scale. The main turn spawns subagents A and B in the background and ends with a result. The run goes on. When A finishes, Claude answers its report with a short follow-up turn, and later the same for B. Only then does the session turn idle, which ends the run and shuts the host down. During the run each subagent’s turns go to its own view. Afterwards the saved subagent transcripts and the files beside them are the only source.
Main threadyour conversation
Subagent Abackground
Subagent Bbackground
Runhost alive
turn spawns A and B
result
the run goes on after the result
follow-up
follow-up
Explore
test runner
report
report
live rows, approvals, subagent views
idle: run ends, host shut down
not to scale
  • Turn resultresult

    Records success or failure and nothing else. Background subagents keep running, and Claude answers each finished report with a follow-up turn.

  • IdleCLAUDE_CODE_EMIT_SESSION_STATE_EVENTS

    Set by the host on the session query. Only the idle state ends the run. Then the desktop shuts the host down and runs the final delivery read.

  • Subagent viewparent_tool_use_id

    During the run, each subagent’s turns go to its own view, addressed like a Codex child thread. The parent gets one spawn row that links to it. The agents list uses the row, and the chat timeline does not show it.

  • After the rungetSubagentMessages

    The saved transcripts and the files beside them are the only source. A view without a finished task event is closed: aborted after a stop or failure, completed otherwise.

The saved files are the source for every subagent that the running turn does not report, and after the run they are the only source. The SDK gives the list of subagent ids and each transcript. The host also reads three kinds of Claude files itself, because the SDK exposes none of them.

FilePath in the session folder and content
Sidecaragent-<id>.meta.json beside each transcript, in subagents, or in subagents/workflows/<runId> for a workflow subagent. It gives description, agentType, and workflowPhase. The folder name gives the run id.
Run recordworkflows/<runId>.json, which Claude writes when a workflow run ends: workflowName, summary, status, startTime, durationMs, the phases, and for each subagent how it ended and the start of its task.
Transcriptagent-<id>.jsonl itself: the effort field of the first saved assistant line, and the file size in bytes.

Claude names a project folder after the working directory by a rule of its own. So the host looks in every folder under projects in CLAUDE_CONFIG_DIR, or under ~/.claude/projects, for a folder with the session id that has a subagents folder with entries. Each reader returns nothing when a file is absent or has another shape, and the list then shows fewer facts.

One subagents request reads all of this in a one-shot host. A transcript read costs the full file, so the desktop names what it already holds, and the host reads only the rest. The desktop keeps these facts per conversation until it quits.

FieldContent
Request: knownEach ended subagent that the desktop holds, as agentId and the size of its transcript at that read. The host reads it again only when the size is different, which means that its parent continued it.
Request: endedRunsThe ids of the workflow runs with a record. They don’t change again, so the host skips them.
Request: openRunsThe ids of the runs without a record that the desktop holds. The host reads the main conversation, the largest file, only for a run that is in neither list, or for a subagent without a sidecar description. There it finds the Workflow call of the run and the input of each spawn call.
Result: idsThe id of every subagent of the conversation.
Result: subagentsThe facts of each subagent that the host read.
Result: runIdsThe id of every workflow run, from the folder names under subagents/workflows.
Result: workflowsFor each run that is not in endedRuns: runId, toolUseId, name, summary, phases, outcome, startedAt, and endedAt.
Subagent fieldSource
IdentityagentId, and from the first transcript message toolUseId, the spawning tool call, and parentAgentId, the parent subagent.
Name and kinddescription and subagentType: from the sidecar, or else from the input of the spawning tool call.
taskThe first user message without its workflow frame, or else the start of the task in the run record.
modelThe model of the last assistant message that is not an API error.
effortThe effort field of the first saved assistant line.
tokensThe size of the context at the last assistant turn: input_tokens plus cache_creation_input_tokens plus cache_read_input_tokens.
toolCallsThe number of tool_use blocks in all assistant messages.
runId, phaseThe workflow run from the folder name, and workflowPhase from the sidecar.
startedAt, lastActivityThe timestamps of the first and the last transcript message.
sizeThe size of the transcript file in bytes.
outcomecompleted, failed, stopped, or null while the subagent can still change.

The host decides the outcome in this order. The run record says completed for the state done and failed for the state error. Otherwise a last message with the model <synthetic> is an API error and means failed. Otherwise a last assistant message with stop_reason end_turn, with text, and with no tool call means completed. Text that comes before a tool call in the same turn does not finish the subagent. Otherwise a subagent of a run that has a record is stopped, because the run ended before the subagent did. Every other subagent has the outcome null.

A workflow run takes its name, summary, phases, status, start, and duration from its record. A run without a record is still open. The line “Run ID:” in the result of its Workflow call then links the run to that call, and the line “Summary:” gives its summary. Its name and its phase titles come from the meta statement of its script.

A workflow gives each subagent its task inside a frame. A frame is one line that starts with “[Workflow harness — user request]” or “[Workflow harness — computed task]”, and the text follows with each line indented by two spaces. The host removes the frame line and the indent for the task field. subagentHistory returns the computed task without its frame and leaves out the framed user request, because the conversation already shows that request.

The desktop then gives each subagent a status. The first rule that applies wins.

  1. The running turn of this session reports the subagent as a task: the status of that task.
  2. The files give an outcome: completed is Completed, failed is Errored, and stopped is Interrupted.
  3. The subagent belongs to a workflow that the running turn reports as running: Running.
  4. The last transcript message is less than 10 minutes old, and it is later than the end of this session’s last run in this desktop run: Running. Another Claude app can run the same conversation, so recent activity is the only sign.
  5. Otherwise: Interrupted.

The 10 minutes come from Claude’s longest normal silence, a shell command, which Claude stops after 10 minutes at most. A workflow run takes the outcome of its record, or else the status of its task in the running turn, or else rule 4 with the latest activity of its subagents. A subagent that the turn reports and that has no transcript yet is added to the list from its task event. Subagents that are still open when a run ends stop with it, so activity from before that end does not count.

The list result uses the agent thread contract that Codex child threads use. Claude fills fields that Codex leaves empty.

FieldContent of the AgentThread field for a Claude subagent
WorkflowworkflowId and phase: the workflow run and the phase of the subagent. Absent for a subagent that the conversation spawned itself.
usagetokens, which can be absent, and toolCalls. The object is absent for a subagent without a saved turn.
reasoningThe effort.
agentRoleThe subagent type. Claude’s default kinds general-purpose and workflow-subagent are not sent.
title, taskThe title is the description, or else the first line of the task that is not empty, or else “Subagent”. The task in the list is cut after 240 characters, and the subagent’s own view has the full task.
Parent and depthparentThreadId is the parent subagent, or else the conversation id. The depth follows the saved parents.
canSteerAlways false. Only the parent agent writes to a subagent, and a send that targets a child thread fails before the host opens the conversation.
Workflow listworkflows in the list result, one entry per run: workflowId, name, summary, phases, status, startedAt, and endedAt. The name is “Workflow” when no file names the run. An open run takes startedAt from its first subagent and has no endedAt.

No timeline row stands for a subagent or a workflow. The call that starts one and its result leave the view, and the agents list shows the subagents and the workflow runs. A task event that changes the list, and the end of a run with open subagents, send the agent-threads signal. Each client then loads the list again. On the desktop, the agents list groups workflow subagents under their workflow and phase and shows model, effort, tokens, tool calls, and duration.

Accounts and usage

The default account is this computer’s Claude sign-in and runs with Claude’s own folder. That folder is ~/.claude, or the folder in the desktop’s own CLAUDE_CONFIG_DIR. Each added account is a folder claude/accounts/<uuid> in HB Code’s local application data. It keeps its own sign-in and links every other entry to the main folder. All accounts therefore read and continue the same sessions with the same settings, and a switch changes only who pays for a turn.

Every Claude account sees the same sessions, and only the sign-in is its own
Every Claude account sees the same sessions, and only the sign-in is its ownTwo folders side by side. On the left, the folder of this computer’s own sign-in: ~/.claude, or the folder in the desktop’s own CLAUDE_CONFIG_DIR. On the right, the folder of an account added in HB Code. Its conversations, settings, and every other shared entry are symbolic links into that main folder, so both accounts read and continue the same sessions. Only the account files stay separate: .claude.json, the credential, backups, and statsig. The next run starts with the active account’s folder.
Default account: ~/.claude
Shared by every account
projects/settings.jsonCLAUDE.mdagents/commands/plugins/todos/…
This account only
.claude.jsonKeychain itembackups/statsig/
Added account: claude/accounts/<uuid>
Links into the main folder
projects/settings.jsonCLAUDE.mdagents/commands/plugins/todos/…
This account only
.claude.jsonKeychain itembackups/statsig/
one symbolic link per entry
never linked
Switching changes who pays for the next run. A running turn keeps its account, and transcript reads always use the default account’s folder.
  • Default account~/.claude · CLAUDE_CONFIG_DIR

    This computer’s own Claude sign-in. Its folder is ~/.claude, or the folder in the desktop’s own CLAUDE_CONFIG_DIR. HB Code sets no folder for it.

  • Added accountclaude/accounts/<uuid>

    A folder in HB Code’s application data. HB Code runs claude auth login --claudeai with CLAUDE_CONFIG_DIR set to it, and Claude keeps the credential. On macOS the Keychain item belongs to the folder.

  • Linksfolders.rs

    Every top-level entry except .claude.json, its lock, .credentials.json, backups, and statsig links to the main folder. An entry that the account folder already has is never overwritten.

  • Usagefive_hour · seven_day

    Read per account through the SDK: the five-hour window, the weekly window, and weekly windows per model. One reading per account per minute serves the sidebar, settings, and phones.

The setting agent.chat.claude_account_id names the active account, and no value means the default account. The next turn runs on the active account, and a removed account leaves the default account active. The desktop makes the links before every start of a Claude process. It creates projects in the main folder first, so an added account never starts a transcript store of its own. A projects link that fails stops the start. Any other link that fails is only logged. On macOS each link is a symbolic link. On Windows a folder gets a directory junction, and a file gets a symbolic link, or a hard link when Windows refuses the symbolic link, so no Developer Mode is needed.

HB Code runs Claude’s own sign-in commands for one account folder. It never reads a token or runs its own authorization flow.

CommandDeadline and notes
claude auth status --jsonDeadline: 15 seconds. Claude exits with an error code when it is signed out and still prints the same JSON.
claude auth login --claudeaiDeadline: 600 seconds. One sign-in runs at a time, and you can cancel it. A second sign-in to an account that is already on this computer is signed out and removed again.
claude auth logoutDeadline: 15 seconds. Removal signs the account out and then deletes its folder. It is refused while a run uses the account.

Usage comes from the SDK: the rolling five-hour window and the weekly windows. The weekly window for third-party apps is left out. An API-key account has no plan windows. The usage host loads no settings and skips the scan of local transcripts. Usage is read only for a signed-in account, and a read that fails shows as an error text on that account while the list still loads. For the weekly windows per model, the server’s own labels come first, and the fixed Opus and Sonnet fields fill in a model that it did not list. Reset times are rounded to the nearest minute, and each reading goes to the usage history. The sidebar, the settings page, and the phones share one reading per account per minute, and a finished run clears that account’s reading.