Skip to content

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.


Terminal window
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.

FlagEffect
--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 latestResume the most recently active session
--forceStart 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.


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

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.

Alternative implementations of ProtocolEmitter are used internally without touching stdout:

  • TUI bridge: the ratatui TUI implements ProtocolEmitter over an internal channel, consuming the same event stream that ProtocolWriter would 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 SubAgentEvent before 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).


After spawning, the host must follow this sequence before sending a user message:

  1. Wait for ready: the engine emits ready once initialization completes. The host must not send message before ready.
  2. Optionally inject MCP servers: send add_mcp_server commands during the window between ready and the first message. The engine connects each server and replies with mcp_ready listing the tools it found. Once the first message arrives, the injection window closes; later add_mcp_server commands are rejected with a protocol error.
  3. 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"}

All commands are defined in crates/wcore-protocol/src/commands.rs. The serde discriminant is "type", all variants in snake_case.

"type"Key fieldsNotes
messagemsg_id, content, filesfiles is an optional list of file paths to attach
stop(none)Cancels the current response stream
tool_approvecall_id, scope, answerResponds to a pending tool_request; answer carries the user’s choice for AskUserQuestion-class tools (v0.9.3, additive)
tool_denycall_id, reasonRejects a pending tool_request
approval_resumeresume_token, approved, modificationsResumes a HITL-suspended turn (W7, requires hitl_suspend capability)
set_modemodeSwitches approval mode: "default", "auto_edit", or "force"
set_configmodel, thinking, thinking_budget, effort, compactionAll fields are Option; omitted fields are unchanged
add_mcp_servername, transport, command, args, env, url, headersPre-message window only; transport values: "stdio", "sse", "streamable-http"
init_historytextSeeds the conversation with pre-existing history text
ping(none)Heartbeat; engine replies with pong

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();
}
});

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':
break

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:

  1. Construct ProtocolWriter wrapping stdout.
  2. Build ProtocolSink with capability options: with_structured_traces(...), with_sub_agent_traces(true), with_advertised_capabilities(...) (cost attribution and online evolution flags).
  3. Boot AgentBootstrap with the sink as the OutputSink.
  4. Call protocol_sink.emit_ready_with_plugins(...) to emit ready.
  5. Emit mcp_ready for boot-time MCP servers.
  6. Wire the ApprovalBridge shared token redactor to the protocol sink (defense-in-depth: streaming tool output has in-flight approval correlation IDs scrubbed).
  7. Start spawn_stdin_reader() and loop on cmd_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.