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:
- No continuity. Every round was a fresh query carrying only the last user message. The CLI never saw the thread's earlier turns or its own tool results.
- Every mesh tool call was denied. Headless mode (
--print) has nobody to answer a permission prompt, so a tool that is not on--allowedToolsis refused with "Claude requested permissions to use …, but you haven't granted it yet". The port was never given an allow-list, so the mesh MCP server — the whole point of the harness — answered every call with a denial.
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:
- Not logged in arrives as
resultwithsubtype: success,is_error: false, the textNot logged in · Please run /login, and exit code 1. The harness raisesAuthRequiredException, which the chat turns into the/loginaffordance. - No conversation to resume arrives as
resulterror_during_execution,is_error: true, zero turns, exit code 1, andNo conversation found with session ID: …on stderr. - A rejected token (measured against 2.1.273 on 2026-09-16 with an invalid
CLAUDE_CODE_OAUTH_TOKEN, Systemorph/MeshWeaver#4462) arrives assystem/api_retrynotices witherror_status: 401, then a syntheticassistanttext block —"model":"<synthetic>", textFailed to authenticate. API Error: 401 OAuth access token is invalid., top-level"error":"authentication_failed"— thenresultwithsubtype: success,is_error: true,api_error_status: 401,terminal_reason: api_error, exit code 1 and an empty stderr. The error sentence is streamed as TEXT, so the round has already produced output when the verdict arrives. The harness reads the structured fields (api_error_status,error) and raisesAuthRequiredException. - A rejected argument (
error: unknown option '…') is printed to stderr and the CLI exits 1 without reading stdin — so the harness's write of the user message can fail with a broken pipe.
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
AllowedToolsdefaults tomcp__meshweaver— every tool of the mesh back-connection server, which acts as the user under the user's own bearer token, so mesh access control applies unchanged. The CLI's built-in tools keep the CLI's own headless defaults.PermissionModepasses--permission-modethrough when set.SessionTimeoutMs(default 120 s) is an idle bound, re-armed on every event: a long tool loop that keeps emitting is never cut off; a CLI that has gone silent is killed and the round fails with aTimeoutExceptionnaming the bound.
Where it lives and how it is tested
src/MeshWeaver.AI.ClaudeCode/ClaudeCliInvocation.cs— the spawn (arguments, environment, stdin line), pure;StreamJsonRound.cs— the reader, pure;ClaudeCodeChatClient.cs— the reactive composition (token → config dir → MCP back-connection → the process as oneIIoPool.InvokeStreamleaf).src/MeshWeaver.AI.ClaudeCode.Test— the spawn and the reader against recorded CLI lines, and the process path executed against the fake CLI (MeshWeaver.AI.Test.FakeCli,FAKE_CLI_MODE=stream-json): streaming without duplication, tool call and result, resume with the fresh-session fallback, the transcript hand-off, noise lines, the not-logged-in verdict, the persisted-credential fallback, the API-key method, the idle bound — and the failure verdicts above: the recorded 401 shape, an error verdict after output, a CLI that exits before reading stdin (a 1 MB prompt makes the broken pipe deterministic) with its stderr redacted, and a missing CLI.ClaudeCodeChatClientE2ETestruns the real CLI on demand (CLAUDE_CODE_E2E=1).- The thread path reaches the harness as
HarnessExecutionContext.ThreadPath(AI 1.9), an init-only property rather than a constructor parameter, so a harness module built before it still constructs the record.