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.
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, and it needs the full interactive login (a claude setup-token
token, which is what the Connect flow stores, is documented as not supporting it). 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.
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.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.