Files
claude-nova/docs/features/session-transport.md
T
2026-08-11 12:38:02 -03:00

3.8 KiB

Session transport

Status: Implemented · Code: Scripts/client.js, Scripts/cli.js

Drives the claude executable in streaming JSON mode and owns the wire protocol. Everything above this layer deals in parsed messages.

Command line

claude --print
       --input-format stream-json --output-format stream-json --verbose
       --permission-prompt-tool stdio
       [--include-partial-messages]
       (--session-id <uuid> | --resume <id>)
       [--model <alias>] [--effort <level>] [--permission-mode <mode>]
       [--add-dir <path>]… [extra args]

--permission-prompt-tool stdio is load-bearing and undocumented. It does not appear in claude --help; it was found in the Agent SDK bundle, which passes it whenever a canUseTool callback is supplied. Without it the CLI has nowhere to send permission prompts and auto-denies every tool that needs one — the session appears to work and silently accomplishes nothing.

--verbose is required for stream-json output under --print.

We deliberately do not pass --replay-user-messages: it echoes our own user turns back, which would double them in the transcript since we render them locally on send.

Protocol

Two kinds of traffic share stdout, distinguished by type.

Session messagessystem (subtype: init carries session_id, model, permissionMode, cwd, tools), assistant, user, stream_event, result. Also seen and ignored: rate_limit_event, system:status, system:thinking_tokens, keep_alive, transcript_mirror.

Control traffic — request/response in both directions:

// out: we ask the CLI to do something
{"type":"control_request","request_id":"…","request":{"subtype":"initialize"}}
// in:  the reply, matched by request_id
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{}}}
// in:  the CLI asks us something (permission prompts)
{"type":"control_request","request_id":"…","request":{"subtype":"can_use_tool",}}
// out: our answer
{"type":"control_response","response":{"subtype":"success","request_id":"…","response":{}}}
{"type":"control_response","response":{"subtype":"error","request_id":"…","error":"…"}}
// in:  a prompt was withdrawn
{"type":"control_cancel_request","request_id":"…"}

Outbound control requests used: initialize, interrupt, set_permission_mode, set_model. Inbound: can_use_tool. Any other inbound subtype is answered with an error response rather than ignored.

A control request must always be answered. The CLI blocks on can_use_tool, so a handler that throws — or silently returns — hangs the session forever. _handleControlRequest wraps the handler and converts a throw into an error response for exactly this reason.

User input

{"type":"user","message":{"role":"user","content":"…"},"parent_tool_use_id":null}

content may be a string or an array of content blocks. No session_id is required on input.

Framing

Process.onStdout delivers chunks, not guaranteed whole lines, so _onStdout buffers and splits on \n. Non-JSON lines are logged and skipped — the CLI occasionally emits human-readable chatter.

Binary resolution

cli.js looks in order: the configured path, ~/.local/bin/claude, ~/.claude/local/claude, /opt/homebrew/bin, /usr/local/bin, /usr/bin, then asks the user's login shell ($SHELL -l -c 'command -v claude') — which is what catches nvm, mise and asdf installs. The result is cached until the claudenova.binaryPath setting changes.

Gotchas

  • Process stdin is a WritableStream that also has a convenience write(). processWriter() in util.js handles both shapes.
  • Never close stdin: the session stays open for streaming input.
  • Exit status 143 is normal — it is SIGTERM from our own stop().