Headless — the jazz run contract
How to call Jazz from your own code and get a parseable result back.
jazz run is the surface every non-terminal integration is built on. It takes a dynamic
prompt, runs exactly one agent turn, and prints a clean payload. It is the difference
between “a CLI you use” and “a runtime you build on”.
jazz run --agent assistant "summarize the last 5 commits"
The stream contract
This is the design decision that makes everything else possible:
stdout carries the payload. stderr carries everything else.
flowchart LR
RUN["jazz run --json<br/>--agent dev<br/>--events tools"]
RUN -->|stdout| OUT["<b>Exactly one line</b><br/>the answer, or one JSON object"]
RUN -->|stderr| ERR["Status notices<br/>tool chatter<br/>the ◉ Agent header<br/>the ✔ completed footer<br/>NDJSON progress events"]
RUN -->|exit code| CODE["0 = ok<br/>1 = failure"]
OUT --> PARSE["Your code:<br/>JSON.parse(stdout)"]
ERR --> LOG["Your code:<br/>log it, or render<br/>a live progress bubble"]
classDef good fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef noise fill:#e8e8e8,stroke:#999999,color:#1a1a1a
class OUT,PARSE good
class ERR,LOG noise
No mode flags to remember, no log lines to filter out of your JSON, no Ink TUI writing
escape codes into your pipe (jazz run forces JAZZ_NO_TUI=1 internally). You parse
stdout and you’re done.
Output modes
Plain (default)
stdout is the answer as raw markdown, trimmed, with a trailing newline. Raw markdown is
deliberate — it’s the easiest thing to translate downstream into Slack mrkdwn, Google
Chat formatting, or Telegram HTML.
$ jazz run --agent assistant "what is 2+2?"
4
On failure stdout is empty and the message goes to stderr, so $(...) capture never
silently yields an error string.
JSON (--json)
stdout is exactly one single-line object. Always one line, always one object — on success and on failure.
// success
{
"ok": true,
"answer": "4",
"costUSD": 0.000182,
"costKnown": true,
"tokenUsage": { "promptTokens": 1204, "completionTokens": 6, "totalTokens": 1210 },
"toolCalls": [{ "id": "call_1", "name": "read_file", "arguments": "{\"path\":\"…\"}" }]
}
// failure
{ "ok": false, "error": "Run exceeded the 300000ms timeout.", "costUSD": 0.0041 }
Note that the failure envelope still reports costUSD — a run that timed out still
spent money, and an unattended deployment needs to account for it.
Successful envelopes also include costKnown. When pricing metadata is unavailable,
costUSD remains 0 for compatibility and costKnown is false; consumers must not
interpret that fallback as a free run.
Flags
| Flag | Purpose |
|---|---|
--agent <id> | Required. Agent id or name. |
--json | Emit the single-object envelope instead of raw text. |
--conversation <id> | Stable conversation key. Loads prior history before the run, saves the updated transcript after. Omit for a stateless one-shot. |
--approval-policy <p> | read-only | low-risk | high-risk. Tools above the tier are declined, not queued. |
--events <categories> | Emit NDJSON progress on stderr: tools,reasoning,text,usage,approval,subagent,all. |
--reasoning <effort> | low | medium | high | disable. Overrides the agent’s config for this run. |
--with-vision <p/m> | Bind the analyze:image companion for this run, e.g. anthropic/claude-sonnet-4-5. Overrides the agent’s config. Without a bound companion (flag or config), analyze_media fails loudly rather than guessing. |
--with-audio <p/m> | Same, for the analyze:audio companion. |
--with-video <p/m> | Same, for the analyze:video companion. |
--timeout <ms> | Abort the run after this many milliseconds. |
--max-iterations <n> | Cap the agent’s reasoning iterations (default 100). |
--stream / --no-stream | Force streaming on/off. Streaming auto-disables for non-TTY stdout; --events reasoning/text re-enable it on their own, since those events exist only on the streaming path. |
Prompt input: argument or stdin
The prompt comes from the positional argument, or — when that’s absent and stdin isn’t a TTY — from piped stdin.
jazz run --agent dev "review this diff" # argument
git diff | jazz run --agent dev # stdin
echo "$UNTRUSTED_WEBHOOK_TEXT" | jazz run --agent bot # stdin, preferred
Use stdin for anything a stranger typed. Webhook text is untrusted; piping it avoids shell-escaping it into an argv, which is a whole class of injection bug you don’t have to think about. (It does not make the content trusted — see Security.)
Memory without a database
--conversation <id> is the feature that makes stateless bridges practical. Pass any
stable key — a Telegram chat id, a Slack thread ts, a support ticket number — and Jazz
handles the transcript for you.
sequenceDiagram
autonumber
participant U as User
participant B as Your bridge<br/>(stateless)
participant J as jazz run
participant H as ~/.jazz/history/
U->>B: "what did I ask you yesterday?"
B->>J: jazz run --json --conversation 4815162342 "…"
J->>H: load transcript for key 4815162342
H-->>J: prior messages
Note over J: agent runs with full context
J->>H: save updated transcript
J-->>B: {"ok":true,"answer":"You asked about…"}
B->>U: post answer
Your bridge stores nothing. Storage is LRU-bounded per agent (100 conversations), so give each external chat its own key and let old ones age out.
Without --conversation, each invocation is a clean slate.
Live progress with --events
For a chat bridge you usually want to show something before the final answer lands.
--events streams newline-delimited JSON on stderr while stdout stays pristine.
jazz run --json --stream --events tools,subagent --agent dev "audit this repo" \
2> >(while read -r line; do render_progress "$line"; done)
| Category | Event types emitted |
|---|---|
tools | tools_detected, tool_call, tool_execution_start, tool_execution_complete |
reasoning | thinking_start, thinking_chunk, thinking_complete |
text | text_start, text_chunk |
usage | stream_start, usage_update, complete |
approval | approval_required, approval_resolved |
subagent | subagent_start, subagent_complete |
all | every category above |
error events are always included regardless of what you select, so a failure can
never be invisible on the live stream.
Streaming auto-disables when stdout is a pipe, which is every headless caller. Tool,
approval and subagent events survive that — the batch path routes them through the same
renderer — but reasoning and text deltas exist only on the streaming path. Asking for
either category therefore turns streaming back on for you; pass --no-stream if you would
rather keep the batch path and take tool events only.
Asking the human something
An unattended run has nobody to ask, so by default the tools that solicit an answer —
ask_user_question, ask_file_picker — are not offered to the model at all. It never
sees them, so it cannot spend a round on a question that will not be answered, and cannot
mistake a blank for a reply and act on it. A run in CI or cron that stopped to ask
something would hang until its timeout for nobody’s benefit.
Where a human is reachable, the tools come back. That is detected rather than declared
wherever it can be: stdin being a terminal is enough on its own, so running jazz run
by hand needs no flag — the question is printed and you answer by typing a line, either the
number of an option or something of your own.
❓ Which database?
1) Postgres — the default
2) SQLite
Answer (number, or type your own; empty to skip):
A chat bridge is the case that cannot be detected: through a pipe it looks exactly like a
cron job. It declares itself with --interactive-stdin, and the question then becomes a
line on the event stream instead of a prompt:
{"type":"user_input_required","requestId":"ui-1","question":"When is your appointment?",
"suggestions":[{"value":"today","label":"Today"},{"value":"tomorrow","label":"Tomorrow"}],
"allowCustom":true}
The run blocks until you write the answer back on stdin, exactly as approvals work:
{"type":"user_input_response","requestId":"ui-1","response":"tomorrow"}
response should be one of the suggestions’ value fields, though any string is accepted
when allowCustom is true. An empty response is treated as no answer: the tool reports
that it could not ask and the model is told to state an assumption or put the question in
its reply instead. Time spent waiting does not count against --timeout, so a human can
take as long as they like.
The question is never truncated, unlike other event payloads — a clipped option is one nobody can meaningfully choose. Both shipped chat bridges pass this flag and render the suggestions as buttons.
CI=true overrides the terminal check, since some runners allocate a pty and a job that
stops to ask something would wait out its timeout for nobody. An explicit
--interactive-stdin still wins there, for a bridge running inside a pipeline.
Autonomy
Unattended runs have nobody to ask, so --approval-policy decides in advance. Tools
above the tier are declined — the agent gets a refusal it can reason about and route
around, rather than hanging forever on a prompt nobody will answer.
| Policy | Auto-approves |
|---|---|
| (omitted) | Nothing. Every gated tool is declined. |
read-only | Reading files, search, web requests, git status/log/diff |
low-risk | + manage_todos, spawn_subagent |
high-risk | + file writes, shell commands, git commit and push |
Omitting the policy really does grant nothing here. The interactive default auto-approves
read-only and low-risk tools, but that is a statement about prompts it is not worth showing
a person — with nobody to show, an absent policy falls back to declining everything. Shell
commands under read-only and low-risk are admitted per command by the
classifier, which is what lets
git log through without also unlocking git push.
⚠️
low-riskis narrower than it sounds. In the built-in toolset it adds onlymanage_todos,update_work_state, andspawn_subagent. Email, calendar, and Obsidian are skills that shell out viaexecute_command(unknown), so alow-riskrun cannot archive an email. Keep the tier low and allowlist the binary instead:{"autoApprovedCommands": ["himalaya"]}in~/.jazz/config.json. See Tools reference.
Pick the lowest tier that lets the job finish. high-risk on a surface that accepts
input from strangers means a prompt injection can run shell commands on that host — see
Security.
One-shot run in a sandbox
CI, a review bot, any service that spins up one ephemeral container per job — these all want the same guarantee: the agent can do whatever the task needs, but nothing it writes should outlive the container, and it shouldn’t be able to tamper with the config it was seeded with. That’s a security requirement, not a filesystem preference — a compromised or misbehaving task shouldn’t be able to plant a persona, poison the model config, or otherwise leave something behind for the next run to pick up.
The natural-looking way to get there is a read-only root filesystem with a read-only bind mount
straight at JAZZ_HOME:
docker run --rm --read-only --tmpfs /tmp \
-v /etc/myapp/jazz-config:/home/jazz/.jazz:ro \
my-image jazz run --agent reviewer
This breaks. JAZZ_HOME isn’t read-only config — jazz writes there too: custom personas
(jazz persona create), per-conversation work state and the compaction journal, cached model
metadata. A live read-only mount at that path fails those writes, and depending on what’s
running, that shows up anywhere from a hard crash at startup (persona resolution falls through
to listing ~/.jazz/personas, which tries to create the directory) to a silently-dropped
write nobody notices until task state that was supposed to survive compaction just isn’t there.
Stage the config somewhere else, and copy it into a genuinely writable JAZZ_HOME on
container start:
docker run --rm --read-only --tmpfs /tmp --tmpfs /home/jazz/.jazz:rw,mode=1777 \
-v /etc/myapp/jazz-config:/config/jazz:ro \
my-image sh -c 'cp -r /config/jazz/. /home/jazz/.jazz/ && exec jazz run --agent reviewer'
The security guarantee this was after — the agent can’t tamper with its own durable config, and
nothing it writes survives past the job — doesn’t actually need a read-only permission bit on
JAZZ_HOME itself. It only needs whatever the agent writes to be discarded, which --rm (or
the container simply never being reused) already does. Copying the seed config into an ephemeral
tmpfs at startup gets you that guarantee while jazz still gets one ordinary, fully-writable home
directory — exactly like every other environment it runs in. The container’s read-only root
filesystem is still doing real work here (nothing outside /tmp and the seeded tmpfs can be
touched at all); it’s specifically a read-only JAZZ_HOME that’s the wrong tool for isolating
this agent.
If you’d rather not do the copy in the container’s own entrypoint, JAZZ_HOME also just
respects the environment variable of the same name, so a wrapper script or your own image’s
entrypoint can do the copy into any writable location and point JAZZ_HOME there instead of
~/.jazz.
A complete bridge
Everything above, in one function. This is genuinely the whole integration:
import { spawn } from "node:child_process";
interface JazzResult {
ok: boolean;
answer?: string;
error?: string;
costUSD: number;
costKnown?: boolean;
}
export function askJazz(chatId: string, message: string): Promise<JazzResult> {
return new Promise((resolve) => {
const child = spawn("jazz", [
"run",
"--json",
"--agent", "assistant",
"--conversation", chatId,
"--approval-policy", "low-risk",
"--timeout", "300000",
]);
let stdout = "";
child.stdout.on("data", (chunk) => (stdout += chunk));
child.stderr.on("data", (chunk) => console.error(chunk.toString()));
child.stdin.write(message);
child.stdin.end();
child.on("close", () => {
try {
resolve(JSON.parse(stdout) as JazzResult);
} catch {
resolve({ ok: false, error: "jazz produced no JSON envelope", costUSD: 0 });
}
});
});
}
Swap spawn for your platform’s SDK around it and you have a bot. That’s exactly what
the Telegram and Discord bridges do.
Related
- Chat platforms — this contract, wired to a real transport
- CI/CD — the same contract inside GitHub Actions
- Tools & approval — how risk tiers are decided
- CLI Reference — every command and flag