Driving Mercury from a program
Drive Mercury from a program through the mercury run --format rows one-shot
stream or the mercury runner JSON-RPC session protocol over standard input
and output.
Usage history: mercury usage --history
mercury usage --history reports input tokens (including cache reads and writes), output tokens, cache-hit rate (cacheReadTokens / inputTokens), estimated USD and unpriced turns across saved local transcripts (including delegated crewmate work), grouped by UTC day, model and project without contacting providers or modifying transcripts. Pass --days <N> for today and the preceding N - 1 UTC days (for example --days 30), --project for the current project only, or --json for the structured report (totals, byDay, byModel, byProject, observations, filesRead, filesCached, skippedRecords and warnings). Unpriced turns are counted in unpricedTurns; their tokens are included in totals without inventing a dollar figure.
A bounded local cache in <config-home>/usage-history.json reuses unchanged transcript summaries (up to 4,096 files and 32,768 hourly rows; evicted summaries are rebuilt when needed) alongside up to 4,096 hourly provider meter observations (one latest observation per hour, provider, hashed account and window, with no message text stored). DeepSeek turns are priced by request start time (or recorded message time on older entries) against peak UTC hours (Monday to Friday 01:00 to 04:00 and 06:00 to 10:00 UTC, excluding the 2026 public holiday calendar) and half-price off-peak hours. Without --history, mercury usage samples the current provider meters like /usage (--json includes their windows, resets and observation times).
One-shot runs: mercury run --format rows
mercury run "<prompt>" --format rows runs a prompt without a terminal
interface and writes one newline-delimited JSON object per line to stdout
(schema: 1). Every row carries type, seq (numbered contiguously from 1
for the stream), timestamp and session_id; rows within a turn also carry
turn, and rows from a delegated crewmate carry parent_call_id.
The stream opens with a session row (version, optional build and
resume_of, cwd, model, mode, tools, optional tools_withheld,
mcp_servers, commands, agents, skills, extensions), followed by any session.start hook task rows,
turn (state: "started" or "waiting", turn_id) and the turn's settled
blocks and progress rows (text, reasoning, tool_call, tool_result,
tool_update, step, wait, heartbeat, compaction, mode,
rate_limit, task, notice, command_output, mission_updated,
samples_updated). A tool call whose arguments do not fit the tool's schema,
are not a JSON object, or name a tool the session does not have is answered
like any failed call: a tool_call row with the delivered arguments ({}
when not an object) and a tool_result row with status: "error" whose
output says what was wrong (the call never ran); a call that cannot be
paired at all (no call id, a reused id, no tool name, or a function call the
provider itself could not parse) gets a tool_result row with status: "refused" followed by the correction notice, and neither is a denial.
Adding --partial emits streaming delta rows
(block_start, text_delta, reasoning_delta, tool_input_delta,
retracted) ahead of each settled block.
Each turn ends with an outcome row (status, turn_id, steps, wall_ms,
optional api_ms and cost_usd, usage, models, denials, answer when
the turn produced one, error with message, class and optional detail
on failure, and optional structured and notices). A close row (state:
"stored", "skipped" or "none", optional note_chars, usage and
models) carries the note left for the next session: with the prompt given
as an argument the note is asked inside the last turn, so close precedes
that turn's outcome and the close ask's usage is counted in the outcome's
totals; with --input rows it follows the last outcome once stdin
closes. A completed run (status: "completed") exits 0, other turn
outcomes exit 1, and a command-line usage error exits 2. A one-shot run
has no host to answer interactive permission asks: a tool call that would ask
is refused, and a turn whose later tool calls never succeed ends blocked
with its answer kept and error.message naming the tool; an unanswered
AskUserQuestion ends blocked the same way with the question quoted in
error.message on the outcome row, read from the run's recorded asks. Pass --mode
(sovereign, flow, implement or dontAsk) or --allowed-tools to grant
what the run needs up front, and read error.message beside answer on an
outcome that is not completed. In headless runs and crewmates, a second consecutive failed call with the same tool and canonical JSON arguments (including permission or hook refusals) warns the model with the error text to change its call, and a third consecutive identical failing call ends the turn once the round settles with status: "loop_stopped" (exit 1), naming the tool, the call's field names and value shapes (never the values) and the error. When a headless run's input closes, every monitor and background shell ends with the run, and stdio MCP servers terminate their child process tree and close their pipes even if the session exits during server startup (an MCP tool call that is aborted, times out or loses its socket after the request could have been sent returns an error naming the interrupted or unknown outcome without replaying the call). A process that must stay up after the run exits (such as a server another step checks once the run has ended) is a Service started with lifecycle: "project", which outlives the session that started it.
Pass --input rows with --format rows to send one JSON input row per line on
stdin:
{"type":"prompt","content":"Summarise this repository."}wherecontentis a string or an array of{"type":"text","text":"..."}and{"type":"image","media_type":"image/png","data":"<base64>"}blocks.{"type":"shell","command":"git status --short"}to run a shell command in the session.{"type":"note","to":"<agent_id>","content":"..."}to deliver a note to a running crewmate at its next tool boundary.
Each input row accepts an optional id; prompt and shell rows also accept
priority ("now", "next" or "later"), sent_at and origin. An input
line whose shape is invalid emits a notice row with code: "input_refused"
and is skipped; a line that is not valid JSON ends the run with exit 1.
A prompt row can also include schema, a JSON Schema object for that turn's
structured output. For that turn, Mercury binds the StructuredOutput tool to
schema, the model replies through the tool, and the outcome row returns
the validated object in structured. A prompt without schema uses the
session's default schema (--schema or initialize.schema), and a prompt with
its own schema never batches into another prompt's turn. If schema is not a
valid JSON Schema, only that row is rejected and the session continues:
--input rows emits a notice row (code: "input_refused") naming the row
id and reason, and queue/add on the runner returns -32602 with the same
message under error.data.issues (path: ["schema"]).
TypeScript (Node 24, zero dependencies)
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
const proc = spawn(
'mercury',
['run', '--input', 'rows', '--format', 'rows', '--allowed-tools', 'Read', 'Glob', 'Grep'],
{
stdio: ['pipe', 'pipe', 'inherit'],
},
)
proc.stdin.write(`${JSON.stringify({ type: 'prompt', content: 'Summarise this repository.' })}\n`)
for await (const line of createInterface({ input: proc.stdout })) {
const row = JSON.parse(line)
if (row.type === 'outcome') {
if (row.status !== 'completed') {
console.error(row.error?.message ?? row.status)
process.exitCode = 1
}
if (row.answer) console.log(row.answer)
proc.stdin.end()
}
}
Python (3.10+, standard library only)
import json, subprocess, sys
proc = subprocess.Popen(
["mercury", "run", "--input", "rows", "--format", "rows", "--allowed-tools", "Read", "Glob", "Grep"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
)
assert proc.stdin is not None and proc.stdout is not None
proc.stdin.write(json.dumps({"type": "prompt", "content": "Summarise this repository."}) + "\n")
proc.stdin.flush()
for line in proc.stdout:
row = json.loads(line)
if row.get("type") == "outcome":
if row.get("status") != "completed":
print(row.get("error", {}).get("message", row.get("status")), file=sys.stderr)
if row.get("answer"):
print(row["answer"])
proc.stdin.close()
raise SystemExit(proc.wait())
Interactive sessions: mercury runner
mercury runner serves a multi-turn session over stdin and stdout using
newline-delimited JSON-RPC 2.0 messages (protocol: 1). Every request id is
a positive integer (1, 2, 3, and so on); a request with any other id
is answered -32600 with id: null. A runner your script or the SDK drives
is a headless session: an ask the host leaves unanswered expires on its
clock, and a turn that ends on a question nobody answers ends blocked. The
sessions the Mercury screen hosts through the same door are oculus
(Sessions); the door and its frames are the same
for both.
- Initialise: Send
initializefirst with{"protocol":1,"host":{"name":"...","version":"..."},"capabilities":{"holds_asks":true,"elicitation":false,"partial_rows":false}}and wait for its result ({"protocol":1,"runner":{"version":"...","pid":123},"session_id":"..."}) before sending other requests. A request sent beforeinitializeanswers-32002.initializealso accepts an optionalschema(a JSON Schema object used for any turn that does not specify its ownschema); an invalid schema returns-32602(path: ["schema"]), after whichinitializecan be sent again. - File checkpoints: A hosted runner does not capture per-turn file
checkpoints by default, so
session/rewindwithmode: "code"returnscapture-off. Pass"file_checkpoints": trueininitializeto enable them: each turn then records the pre-edit bytes of every file its tools modify,session/rewindcan restore those files,session/file_restorewith{"action":"restore","user_message_id":"...","file_path":"..."}(and optional"dry_run":true) restores a single file at that checkpoint while leaving other files and the conversation untouched,session/file_restorewith{"action":"undo","user_message_id":"..."}restores the pre-rewind bytes saved before the last file restore until the next prompt, and crewmates run normally. Both theinitializeresult andsession/factsincludefile_checkpointswithcapture(trueorfalse) andsource:initialize(enabled byinitialize),headless(the default when not requested),setting(disabled byfileCheckpointingEnabled: false, which overrides all modes),seat(the interactive screen's session runner) orinteractive.session/factsalso includesrestorable(the list of prompt IDs with saved checkpoints),files(tracked files in the session) andundo(the undo ID foraction: "undo"). - Send a prompt and read rows: Call
queue/addwith aprompt,shellornoteinput row; the runner answers{"accepted":true}(or{"accepted":false,"reason":"duplicate"}whenidwas already processed, so give each row anidand a resend is harmless).queue/withdrawrecalls an unconsumed queued row byid. Session rows arrive from the runner asrownotifications ({"jsonrpc":"2.0","method":"row","params":<Row>}).session/factsreads the current session state.session/set_modechanges the permission mode at once, during a turn included, and answers{"mode":"..."}.session/set_model,session/set_effortandsession/set_spawn_switchanswerat: "now"when no turn is running; while a turn runs they answerat: "turn_end"and asession/appliednotification (request_id,verb) follows when the turn ends. - Tell a reply from a request: The runner numbers its own requests to the
host (
permission/request) from1as well, so a message withid: 1is either the reply to yourinitializeor the runner's first ask. A reply carriesresultorerrorand nomethod; a request carriesmethod. - Answer a permission ask: When a tool call needs
approval, the runner sends a
permission/requestrequest to the host withkind: "tool"(tool_use_id,tool_name,input, optionaltitle,display_name,description,suggestions,cwd,reason,agent_id,mode); that is the one kind of ask. Reply with the request'sidand{"outcome":"allow"}(with optional modifiedinputandrules) or{"outcome":"deny"}(with optionalmessageandstop). When the runner withdraws a pending ask, it sends a$/cancel_requestnotification withrequest_id. - Interrupt and leave: Send
turn/interrupt({"hard":true}also stops running background shells) to abort an active turn; the runner answers{"interrupted":true}and flushes anoutcomerow withstatus: "interrupted". When no turn is running, sendsession/leave({}) to write the session's handoff note ({"note":"stored"|"skipped"|"none","ledger":"closed"|"absent"}), then closestdinto exit the runner. - Errors: Unknown methods answer
-32601, invalid parameters-32602, cancelled requests-32800, and rule refusals-32010with a reason inerror.data.kind(protocol,claim,model,effort,mode,spawn-switch,kit,rewind,pause-gate,quiesce,queue,agent,no-shell,nothing-to-resume,already-initializedorcapability). A malformed JSON line answers-32700; three consecutive malformed lines end the runner.
TypeScript (Node 24, zero dependencies)
import { spawn } from 'node:child_process'
import { createInterface } from 'node:readline'
const proc = spawn('mercury', ['runner'], { stdio: ['pipe', 'pipe', 'inherit'] })
const send = (msg: Record<string, unknown>) => proc.stdin.write(`${JSON.stringify(msg)}\n`)
send({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocol: 1,
host: { name: 'example-host', version: '1.0.0' },
capabilities: { holds_asks: true, elicitation: false, partial_rows: false },
},
})
for await (const line of createInterface({ input: proc.stdout })) {
const msg = JSON.parse(line)
const reply = msg.method === undefined
if (reply && msg.id === 1)
send({
jsonrpc: '2.0',
id: 2,
method: 'queue/add',
params: { type: 'prompt', id: 'note-1', content: 'Write note.txt and confirm.' },
})
if (msg.method === 'permission/request')
send({ jsonrpc: '2.0', id: msg.id, result: { outcome: 'allow' } })
if (msg.method === 'row' && msg.params.type === 'outcome') {
console.log(msg.params.answer)
send({ jsonrpc: '2.0', id: 3, method: 'session/leave', params: {} })
}
if (reply && msg.id === 3) proc.stdin.end()
}
Python (3.10+, standard library only)
import json, subprocess
proc = subprocess.Popen(["mercury", "runner"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
def send(msg: dict) -> None:
assert proc.stdin is not None
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
send({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {"protocol": 1, "host": {"name": "example-host", "version": "1.0.0"}, "capabilities": {"holds_asks": True, "elicitation": False, "partial_rows": False}},
})
assert proc.stdout is not None
for line in proc.stdout:
msg = json.loads(line)
reply = "method" not in msg
if reply and msg.get("id") == 1:
send({"jsonrpc": "2.0", "id": 2, "method": "queue/add", "params": {"type": "prompt", "id": "note-1", "content": "Write note.txt and confirm."}})
if msg.get("method") == "permission/request":
send({"jsonrpc": "2.0", "id": msg["id"], "result": {"outcome": "allow"}})
if msg.get("method") == "row" and msg["params"].get("type") == "outcome":
print(msg["params"].get("answer", ""))
send({"jsonrpc": "2.0", "id": 3, "method": "session/leave", "params": {}})
if reply and msg.get("id") == 3 and proc.stdin is not None:
proc.stdin.close()
raise SystemExit(proc.wait())