How the harness drives the CLI

The Claude Code harness turns a chat round into one spawn of the claude CLI. This page is the record of how that spawn is shaped, why it is shaped that way, and what was measured on 2026-09-11 against Claude Code 2.1.269 before the shape was chosen (Systemorph/MeshWeaver.Plugins PR feat/claude-code-stream-json).

Why there is no SDK in between

Until this change the harness drove the CLI through the ClaudeAgentSdk NuGet package — a community C# port; Anthropic ships the Agent SDK for TypeScript and Python only. The port threw on every event type it did not know (rate_limit_event, system/status, …), which took the whole chat down, so the client already carried a workaround that ended the stream on the first unknown event. Two further gaps were structural rather than bugs:

The documented, language-agnostic way to embed Claude Code is the headless protocol:

claude --print --verbose \
       --input-format stream-json --output-format stream-json --include-partial-messages \
       --session-id <uuid> | --resume <uuid> \
       --append-system-prompt "<agent instructions>" \
       --allowedTools mcp__meshweaver \
       --mcp-config '{"mcpServers":{"meshweaver":{"type":"http","url":…,"headers":{"Authorization":"Bearer …"}}}}' \
       --setting-sources user,project

One user message goes in on stdin as {"type":"user","message":{"role":"user","content":[{"type":"text","text":…}]}}; the CLI answers with one JSON object per line and exits when the turn is complete.

Browser login from Memex

Run /login in a Claude Code thread and choose the browser method. Open the authorization page in a new tab, approve access, then return to Memex and paste the complete code (including #state). The portal runs claude auth login --claudeai with plain stdin/stdout pipes under your own CLAUDE_CONFIG_DIR. Claude owns PKCE, validates the code/state, and stores the refreshable login. Memex requires exit zero and a fresh claude auth status --json confirming subscription authentication before reporting success.

The selected provider uses AuthMethod: cli; chat subprocesses leave credential environment variables unset so Claude can refresh its native login. Existing API keys and gateway settings remain saved but do not override browser authentication. Token paste and gateway login remain available as explicit alternatives. Hosted replicas must share persistent per-user CLI storage.

See Claude CLI authentication and CLI reference.

Remote Control is not an alternative. claude --remote-control runs a session on the machine that started it, and its only clients are claude.ai/code and the Claude mobile app — there is no protocol a third-party UI can speak. A manually pasted claude setup-token token does not support that mode. It is a personal workflow — run claude locally with the mesh MCP server configured, drive it from the phone — not a harness.

The events the reader understands

Recorded from the real CLI; the reader (StreamJsonRound) takes exactly these and ignores the rest:

Event What the harness does with it
system / init Session id and model, for the log and the status bar.
stream_event / message_start Remembers the message id.
stream_event / content_block_delta with text_delta The streamed text — yielded token by token, and the message is marked as streamed.
assistant with a text block Yielded only when no deltas were streamed for that message (an older CLI, or a run without partial messages) — otherwise the text would appear twice.
assistant with a tool_use block A FunctionCallContent (id, name, arguments), so the thread renders the tool call.
user with a tool_result block A FunctionResultContent keyed by the call id; is_error becomes the fault.
result The verdict: subtype, is_error, result text, cost, duration, turns.
anything else Nothing. rate_limit_event, system/status, thinking_tokens, thinking deltas, a non-JSON line, an event a newer CLI adds — none may fault the round.

Two verdict shapes measured rather than assumed:

How a failed round reaches the thread

The verdict is decided only after the process has exited and its stderr has been read, so a failure always carries the process's own account of it. Measured on 2026-09-16 (#4462): the community SDK's ProcessException: Command failed with exit code 1 on the public instance was this harness's 401 shape — the thread cell kept the streamed Failed to authenticate. API Error: 401 Invalid bearer token above the bare exit code — and the first stream-json client had two gaps of its own: it completed that round as if the error sentence were the answer (no phrase it matched says "Failed to authenticate", and an error verdict after output was only logged), and a CLI that exited before reading stdin surfaced as IOException: Broken pipe, losing the exit code and stderr.

What the process did What the thread shows
Resume found no conversation Nothing — the round restarts fresh under the same session id
Authentication failure (api_error_status: 401, error: authentication_failed, "Not logged in", …) — with or without streamed text AuthRequiredException → the /login affordance, in the viewer's language
An error verdict (is_error, a non-success subtype, or any api_error_status), even after streamed text HarnessProcessException (Failed)
Exited before reading the message (broken stdin pipe) HarnessProcessException (Failed), the pipe fault kept as the inner exception
Non-zero exit with nothing produced and no verdict HarnessProcessException (Failed)
Output (or a clean exit) with no closing result event HarnessProcessException (Incomplete)
Could not be started (missing CLI) HarnessProcessException (NotStarted)
A success verdict with output and a non-zero exit The answer stands; the exit code and stderr are logged as a warning

HarnessProcessException lives in the AI engine, beside AuthRequiredException. ThreadExecution renders it on the response cell as a sentence from the module's own text table (HarnessFailureTexts, English and German, resolved from the round's AccessContext.Locale) followed by the CLI's verdict and the tail of its stderr verbatim, each in a fence the text cannot close; any text streamed before the failure stays above it. Stderr is drained into a rolling buffer of its last 64 KB, so a verbose CLI keeps its closing diagnostic rather than its first lines. Every credential the spawn carried — the user's token and the MCP back-connection's bearer token (ClaudeCliInvocation.Secrets) — is redacted from the verdict, the stderr, the streamed text and the tool results before the thread persists them: an argument error echoes the argument, and a tool the CLI runs can print its environment. A token split across two streamed text deltas is the one shape this cannot match.

One CLI session per thread

The session id is a name-based UUID (version 5) of the thread path, so it is the same on every portal replica and never collides between threads. Each round the harness spawns the CLI with --resume <id>; the CLI holds the thread's earlier turns, its tool calls and their results itself, and only the new user message is sent. When the resume finds no conversation — the first round of a thread, a thread that switched to this harness mid-way, a session the CLI pruned, a recreated volume — the harness spawns once more with --session-id <id> and hands the CLI the thread's earlier user and assistant turns as a bounded transcript, so nothing starts from zero.

The CLI stores conversations per working directory, under CLAUDE_CONFIG_DIR/projects/<cwd>/. The harness therefore always runs in the same directory (the shared skills workspace when one is configured), and the config dir is the user's own — which is also what keeps two users' sessions apart on one replica.

--append-system-prompt keeps the CLI's own system prompt (its tool and MCP guidance) and appends the agent's instructions; the CLI records the prompt with the session and reuses it on resume until the conversation is compacted.

Permissions and the idle bound

Where it lives and how it is tested