Driving the Engine from a Host
--json-stream turns wayland-core into a headless subprocess driven entirely over stdin/stdout. One process per conversation; the process stays alive for as many turns as the host wants. Stderr carries diagnostic logs and is not part of the protocol.
This is the same path Wayland Desktop uses: run_json_stream_mode in wcore-cli/src/main.rs:1995 is the production entry point.
Spawning the process
Section titled “Spawning the process”wayland-core --json-stream [--provider P] [--model M] \ [--session-id ID] [--resume ID] [--force]The --json-stream flag is declared at wcore-cli/src/main.rs:278:
/// Enable JSON streaming mode for host client integration#[arg(long)]json_stream: bool,When set, main.rs:1171 dispatches to run_json_stream_mode(config, &cwd, resume, cli.session_id, cli.force) and returns.
Session flags
Section titled “Session flags”| Flag | Effect |
|---|---|
--session-id <ID> | Start a new session with an explicit ID instead of a generated one |
--resume <ID> | Resume a previous session by ID, restoring conversation history |
--resume latest | Resume the most recently active session |
--force | Start in Force mode; all tool calls are auto-approved without waiting for a tool_approve command. Use only when the host has its own approval layer. |
--provider <P> | Override the provider configured in config.toml |
--model <M> | Override the model for this session |
Session state (history, memory) is read from and written to ~/.wayland/sessions/ by default; the path is controlled by [session] directory in config.toml.
Transport
Section titled “Transport”All communication uses JSON Lines (NDJSON): one UTF-8 JSON object per line. Each object carries a "type" field as the discriminant.
- stdin: commands from the host to the engine (
ProtocolCommand) - stdout: events from the engine to the host (
ProtocolEvent) - stderr: diagnostic logs; not part of the protocol
ProtocolWriter and ProtocolEmitter
Section titled “ProtocolWriter and ProtocolEmitter”The engine-side writing surface is defined in crates/wcore-protocol/src/writer.rs.
ProtocolEmitter is the abstract emit trait:
pub trait ProtocolEmitter: Send + Sync { fn emit(&self, event: &ProtocolEvent) -> io::Result<()>;}The default implementation, ProtocolWriter, wraps BufWriter<Stdout> behind a Mutex so concurrent Tokio tasks can emit events safely:
pub struct ProtocolWriter { writer: Mutex<BufWriter<Stdout>>,}
impl ProtocolEmitter for ProtocolWriter { fn emit(&self, event: &ProtocolEvent) -> io::Result<()> { let mut w = self.writer.lock()...; serde_json::to_writer(&mut *w, event)...; writeln!(&mut *w)?; w.flush() }}Each call: serialize to JSON with serde_json::to_writer, append \n, flush. The mutex means event ordering is preserved even when the engine fans sub-agent events from multiple tasks.
The relay-sink pattern
Section titled “The relay-sink pattern”Alternative implementations of ProtocolEmitter are used internally without touching stdout:
- TUI bridge: the ratatui TUI implements
ProtocolEmitterover an internal channel, consuming the same event stream thatProtocolWriterwould write. This lets the TUI and the JSON-stream path share one engine. - Test sinks: unit and integration tests substitute an in-memory
Vec<ProtocolEvent>sink so no actual stdout is needed. - Channel sink: sub-agent traces route through a channel-backed emitter that wraps inner events in
SubAgentEventbefore forwarding to the parent session’s writer.
To build a relay sink for your own host, implement ProtocolEmitter on any type that is Send + Sync and supply it via AgentBootstrap::new(config, cwd, output).
Startup sequence
Section titled “Startup sequence”After spawning, the host must follow this sequence before sending a user message:
- Wait for
ready: the engine emitsreadyonce initialization completes. The host must not sendmessagebeforeready. - Optionally inject MCP servers: send
add_mcp_servercommands during the window betweenreadyand the firstmessage. The engine connects each server and replies withmcp_readylisting the tools it found. Once the firstmessagearrives, the injection window closes; lateradd_mcp_servercommands are rejected with a protocol error. - Send the first
message: the turn loop begins.
// Engine emits (stdout):{"type":"ready","version":"0.2.0","session_id":"s-abc123","capabilities":{"tool_approval":true,"mcp":true}}
// Host optionally sends (stdin) before the first message:{"type":"add_mcp_server","name":"team-tools","transport":"stdio","command":"node","args":["bridge.js"],"env":{"TOKEN":"abc"}}
// Engine replies (stdout):{"type":"mcp_ready","name":"team-tools","tools":["create_task","list_tasks"]}
// Host sends first message (stdin):{"type":"message","msg_id":"m1","content":"List all open tasks"}Command reference
Section titled “Command reference”All commands are defined in crates/wcore-protocol/src/commands.rs. The serde discriminant is "type", all variants in snake_case.
"type" | Key fields | Notes |
|---|---|---|
message | msg_id, content, files | files is an optional list of file paths to attach |
stop | (none) | Cancels the current response stream |
tool_approve | call_id, scope, answer | Responds to a pending tool_request; answer carries the user’s choice for AskUserQuestion-class tools (v0.9.3, additive) |
tool_deny | call_id, reason | Rejects a pending tool_request |
approval_resume | resume_token, approved, modifications | Resumes a HITL-suspended turn (W7, requires hitl_suspend capability) |
set_mode | mode | Switches approval mode: "default", "auto_edit", or "force" |
set_config | model, thinking, thinking_budget, effort, compaction | All fields are Option; omitted fields are unchanged |
add_mcp_server | name, transport, command, args, env, url, headers | Pre-message window only; transport values: "stdio", "sse", "streamable-http" |
init_history | text | Seeds the conversation with pre-existing history text |
ping | (none) | Heartbeat; engine replies with pong |
Node.js host example
Section titled “Node.js host example”import { spawn } from 'child_process';import * as readline from 'readline';
const proc = spawn('wayland-core', ['--json-stream', '--provider', 'anthropic'], { stdio: ['pipe', 'pipe', 'inherit'],});
const rl = readline.createInterface({ input: proc.stdout });
rl.on('line', (line) => { const event = JSON.parse(line); if (event.type === 'ready') { proc.stdin.write(JSON.stringify({ type: 'message', msg_id: 'm1', content: 'What files are in the current directory?', }) + '\n'); } else if (event.type === 'text_delta') { process.stdout.write(event.text); } else if (event.type === 'tool_request') { proc.stdin.write(JSON.stringify({ type: 'tool_approve', call_id: event.call_id, scope: 'once', }) + '\n'); } else if (event.type === 'stream_end') { proc.stdin.end(); }});Python host example
Section titled “Python host example”import subprocess, json, sys
proc = subprocess.Popen( ['wayland-core', '--json-stream', '--provider', 'anthropic'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
for raw in proc.stdout: event = json.loads(raw) if event['type'] == 'ready': proc.stdin.write(json.dumps({ 'type': 'message', 'msg_id': 'm1', 'content': 'Summarize src/lib.rs', }) + '\n') proc.stdin.flush() elif event['type'] == 'text_delta': sys.stdout.write(event['text']) sys.stdout.flush() elif event['type'] == 'tool_request': proc.stdin.write(json.dumps({ 'type': 'tool_approve', 'call_id': event['call_id'], 'scope': 'once', }) + '\n') proc.stdin.flush() elif event['type'] == 'stream_end': breakElectron integration (Desktop pattern)
Section titled “Electron integration (Desktop pattern)”Wayland Desktop uses run_json_stream_mode directly (same binary, not a subprocess). The production path in main.rs:1995 shows the setup sequence the Desktop performs:
- Construct
ProtocolWriterwrapping stdout. - Build
ProtocolSinkwith capability options:with_structured_traces(...),with_sub_agent_traces(true),with_advertised_capabilities(...)(cost attribution and online evolution flags). - Boot
AgentBootstrapwith the sink as theOutputSink. - Call
protocol_sink.emit_ready_with_plugins(...)to emitready. - Emit
mcp_readyfor boot-time MCP servers. - Wire the
ApprovalBridgeshared token redactor to the protocol sink (defense-in-depth: streaming tool output has in-flight approval correlation IDs scrubbed). - Start
spawn_stdin_reader()and loop oncmd_rx.recv().
For Electron hosts that communicate over IPC rather than a child process, the same ProtocolEmitter trait can be implemented to send events over Electron’s ipcMain/ipcRenderer channel rather than stdout.
See also
Section titled “See also”- JSON Stream Protocol - full command and event catalog
- Tool Approval Protocol - the
tool_requestpause flow - Protocol Versioning - capability flags and forward compat