Headless
How to call Jazz from your own code and get a parseable result back.
Create an agent first with jazz agent create and name it assistant, or replace
assistant below with an existing agent name. jazz run takes a prompt, runs one
agent turn, and prints the answer.
jazz run --agent assistant "summarize the last 5 commits"
The stream contract
stdout contains the answer: Markdown by default, or a single JSON object with --json.
Status messages, warnings, tool output, and NDJSON progress events go to stderr.
Output modes
Plain (default)
stdout is the answer as raw Markdown, trimmed, with a trailing newline.
$ 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 contains one single-line JSON object on success or failure.
An invalid configuration file or a missing --config path also returns this failure
envelope with code: "failed" and costUSD: 0, even before the agent starts. Recovery
instructions stay on stderr. jazz workflow run --json follows the same rule.
// 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,
"code": "failed",
"error": "Run exceeded the 300000ms timeout.",
"costUSD": 0.0041,
"costKnown": true,
"tokenUsage": { "totalTokens": 5210 },
}
Failure results include spend and token usage once the run reaches the model. A failure
before the first model call reports costUSD: 0.
A force-killed process cannot return a result. To track its spend, request the spend
event category and save the latest run_spend event, which reports totals after each
model call and tool batch.
code says why a run failed, so a script can branch without parsing error:
code | Meaning |
|---|---|
failed | The run errored: provider, tool, configuration, or timeout |
empty_response | The model returned an empty completion with zero tokens: usually a misconfigured or overloaded provider |
no_answer | The model stopped for a reason other than stop (for example length) before writing anything; see finishReason |
content_filtered | The provider’s content filter withheld the answer |
interrupted | SIGINT or SIGTERM stopped the run; signal names which |
A run that finishes without a usable answer is a failure, never ok:true with an empty
answer.
What else the envelope tells you
These fields appear on the success envelope only when they apply:
| Field | Meaning |
|---|---|
finishReason | Why the model stopped writing the answer: stop, length, tool-calls, other, or unknown |
truncated | true when the answer was cut off at the model’s output limit (finishReason: "length"). The answer is still returned, and stderr carries a warning |
iterationLimited | true when the run used every allowed iteration (--max-iterations) without a final answer |
toolsDisabled | true when the agent has tools but none were sent, because Jazz does not know the model supports tool calling. The agent could only talk. stderr carries a warning with the fix |
costCapped, tokenCapped, durationCapped | true when a run budget stopped the run early |
Jazz assumes a cloud model supports tools when neither its catalog nor your config says
otherwise, so a newly released model keeps its tools. A local server’s model gets tools once
the server reports them. When Jazz cannot tell, override it with
jazz config set 'llm.capabilityOverrides.<provider>."<model>".supportsTools' true.
Exit codes
| Code | Meaning |
|---|---|
0 | An answer was produced |
1 | The run failed, or finished without a usable answer (see code) |
2 | The run is parked on an approval (--park); resume it with jazz runs approve |
130 | SIGINT (Ctrl+C) stopped the run |
143 | SIGTERM stopped the run |
On SIGINT or SIGTERM, --json stdout still carries exactly one envelope:
{"ok":false,"error":"interrupted","code":"interrupted","signal":"SIGTERM",...}. The shutdown
notice goes to stderr. A second signal exits at once with the same code and envelope.
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. |
--watch <categories> | Watch the run: NDJSON progress on stderr: tools,reasoning,text,usage,approval,subagent,spend,all. Alias of --events; pass one or the other. |
--reasoning <effort> | minimal | low | medium | high | xhigh | max | disable. Overrides the agent’s config for this run; a level the model does not accept runs at the nearest one it does, with a warning on stderr. |
--with-vision <p/m> | Bind the analyze:image companion for this run, e.g. openrouter/inclusionai/ling-3.0-flash-vl. 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). |
--input-stdin | Read the prompt (and, with --ephemeral, prior history) from the first stdin line as JSON. See below. |
--stream / --no-stream | Force streaming on/off. Streaming auto-disables for non-TTY stdout; --watch reasoning/text re-enable it on their own, since those events exist only on the streaming path. |
Prompt input: argument, stdin, or an input frame
The prompt comes from the positional argument, from an --input-stdin frame, or: when
neither is given 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
echo '{"prompt":"- buy milk"}' | jazz run --agent bot --input-stdin # frame
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.)
Keep relayed messages off the command line. Every account on a host can read another
process’s arguments through ps and /proc/<pid>/cmdline, and Linux caps a single argument at
128 KiB. A bridge that relays a person’s message, and for an incognito chat that person’s whole
transcript, sends them in the --input-stdin frame: one JSON line, then the rest of stdin is
free for --interactive-stdin answers.
{ "prompt": "what did I say about the dentist?", "history": [] }
history is read only with --ephemeral: pass back the messages array of the previous
--ephemeral --json envelope to keep multi-turn context without anything on disk. A framed
prompt is the caller’s own message, so like a positional prompt it may back a memory write; a
body piped without the frame never can.
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.
Your bridge stores nothing. Each agent keeps its 100 most recently used conversations
(history.maxConversationsPerAgent), so give each external chat its own key and let old ones
age out: they are archived under ~/.jazz/history/archive/, not deleted. See
sizes and retention.
Without --conversation, each invocation is a clean slate.
Live progress with --watch
For a chat bridge you usually want to show something before the final answer lands.
--watch (alias of --events, both take the same categories) streams
newline-delimited JSON on stderr while stdout stays pristine.
jazz run --json --stream --watch 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 | command_risk_classifying, command_risk_classified, approval_required, approval_resolved |
subagent | subagent_start, subagent_complete, subagent_result |
spend | run_spend: the run’s total spend so far, after each model call and tool batch |
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.
Only the approval category makes a run wait for an answer: a gated call then emits
approval_required and blocks until an approval_decision line arrives on stdin. Any other
selection only watches, so a gated call is declined at once, as without --watch. When stdin
closes, every approval or question still waiting is declined.
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
Unattended runs omit ask_user_question, ask_file_picker, and ask_user_secret
from the tool list.
When stdin is a terminal, these tools are available without an extra flag. Answer a question by typing an option number or your own response.
❓ 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.
Questions are never truncated. Telegram and Discord render suggestions as buttons.
CI=true disables terminal input detection. An explicit --interactive-stdin
still enables input events.
Adding to a running request
With --interactive-stdin, a bridge can hand the run a message the person sent while it
works, instead of starting a second run for it:
{ "type": "user_message", "id": "message-1", "text": "make it for 4 people" }
Media paths in text are ingested with the same model support and size limits as the initial
prompt. Runs that disable path ingestion, including peer and webhook calls, keep that restriction
for follow-ups too. Background tool output is never scanned for attachments.
The run reads every message waiting before its next model call, as one user message, and reports the ids it took on the event stream:
{ "type": "user_message_delivered", "ids": ["message-1"] }
A message written while the run is giving its final answer is never read and never reported. Answer it in a run of its own. The text counts as the person’s own words, like the first message, so the agent may quote it for a memory write.
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 | + memory writes, reminders, triggers, low-risk classified commands |
high-risk | + file writes, shell commands, git commit and push |
Omitting the policy grants nothing, here and in the interactive terminal alike: with nobody to
ask, every gated call is declined. To run everything unasked, pass --approval-policy high-risk
explicitly (see Running fully unattended). 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.
Skills such as email, calendar, and Obsidian run commands through execute_command.
Their mutations may require a higher tier. Prefer human approval or a narrowly scoped
command grant; see email and calendar setup.
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
Use a fresh container per job, mount the input read-only, and give Jazz a writable, ephemeral data directory:
docker run --rm --read-only --tmpfs /tmp \
-e JAZZ_HOME=/tmp/jazz-home -e OPENAI_API_KEY \
-v /etc/myapp/jazz-config:/config/jazz:ro \
-v "$PWD:/workspace:ro" -w /workspace \
my-image sh -c '
umask 077
mkdir -p "$JAZZ_HOME"
cp -r /config/jazz/. "$JAZZ_HOME/"
exec jazz run --agent reviewer --approval-policy read-only \
--max-cost-usd 2 --timeout 300000 "Review the files in this checkout."
'
my-image must have Jazz installed. The seed directory must be readable by the
container user and contain agents/reviewer.json; this example assumes an OpenAI agent
and OPENAI_API_KEY supplied through the host environment. Use the matching credential
for another provider.
JAZZ_HOME holds runtime state and caches as well as configuration, so it must be writable.
The seed configuration and checkout stay read-only; the copied configuration can change
inside the container. Its changes and runtime state disappear when the container exits.
Keep writable host mounts and unrelated credentials out of the job. See
Unattended runs for tool and credential restrictions.
Calling Jazz from Node.js
This function passes a message to Jazz and reads its JSON result:
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.on("error", (error) => {
resolve({ ok: false, error: error.message, costUSD: 0, costKnown: false });
});
child.stdin.write(message);
child.stdin.end();
child.on("error", (error) => {
resolve({ ok: false, error: error.message, costUSD: 0, costKnown: false });
});
child.on("close", () => {
try {
resolve(JSON.parse(stdout) as JazzResult);
} catch {
resolve({
ok: false,
error: "jazz produced no JSON envelope",
costUSD: 0,
costKnown: false,
});
}
});
});
}
For platform authentication, message delivery, and interactive approvals, see Chat platforms.
Related
- Chat platforms: this contract, wired to a real transport
- CI/CD: the same contract inside GitHub Actions
- Approvals: what runs without asking
- Commands and flags: every command and flag