` | none | Stable conversation key. Loads prior history before the run, saves the transcript after — gives stateless bridges per-chat memory |
| `--approval-policy ` | none | `read-only` \| `low-risk` \| `high-risk`. Tools above the tier are **declined** |
| `--events ` | none | NDJSON progress on stderr: `tools`, `reasoning`, `text`, `usage`, `approval`, `subagent`, `all` (comma-separated) |
| `--reasoning ` | agent config | `low` \| `medium` \| `high` \| `disable` |
| `--timeout ` | none | Abort the run after this many milliseconds (hard external kill, no warning) |
| `--max-iterations ` | 80 | Cap reasoning iterations |
| `--max-cost-usd <$>` | none | Abort once cumulative spend (own + sub-agent) reaches this many dollars, checked between iterations |
| `--max-tokens ` | none | Abort once cumulative prompt + completion tokens (own run only, not sub-agents) reach this count, checked between iterations — needs no model pricing |
| `--max-duration-ms `| none | Abort once elapsed wall-clock time reaches this budget, with agent pressure nudges at 50/80/90%, checked between iterations |
| `--stream` | auto | Force streaming. Required for `--events` in non-TTY contexts, where streaming auto-disables |
| `--no-stream` | — | Disable streaming |
`--max-cost-usd`, `--max-tokens`, and `--max-duration-ms` are soft checkpoints, not preemptive
interrupts — see [Configuration → `maxCostUSD`, `maxTokens`, and `maxDurationMs`](./configuration.md#maxcostusd-maxtokens-and-maxdurationms)
for the enforcement model and how they differ from `--timeout`.
**Exit codes:** `0` on success, `1` on failure. In plain mode stdout is empty on failure and
the message goes to stderr; in `--json` mode stdout always carries exactly one object.
Full contract, examples, and a complete bridge implementation:
[Surfaces → Headless](../use-cases/headless.md).
---
## `jazz agent`
| Command | Purpose |
| ----------------------------------- | ----------------------------------------------------------------- |
| `jazz agent list` | List all agents |
| `jazz agent create` | Create an agent (interactive) |
| `jazz agent show ` | Show an agent's details |
| `jazz agent edit ` | Edit an agent |
| `jazz agent delete ` | Delete an agent. `-y, --yes` / `-f, --force` to skip confirmation |
| `jazz agent chat ` | Interactive session with a specific agent, by id or name |
`agent chat` accepts `--stream` / `--no-stream` and `--max-iterations `.
---
## `jazz workflow`
| Command | Purpose |
| --------------------------------- | -------------------------------------------------- |
| `jazz workflow list` | List available workflows (built-in, global, local) |
| `jazz workflow show ` | Show a workflow's prompt and metadata |
| `jazz workflow run ` | Run once — see flags below |
| `jazz workflow schedule ` | Install into launchd (macOS) or cron (Linux) |
| `jazz workflow unschedule ` | Remove from the scheduler |
| `jazz workflow scheduled` | List scheduled workflows |
| `jazz workflow catchup` | List workflows that missed a slot, select, run |
| `jazz workflow history [name]` | Show run history |
### `jazz workflow run` flags
| Flag | Purpose |
| ----------------------- | ------------------------------------------------------------------------ |
| `--auto-approve` | Apply the workflow's own `autoApprove:` policy instead of prompting |
| `--agent ` | Override the agent for this run |
| `--max-iterations ` | Override the workflow's iteration cap |
| `--max-cost-usd <$>` | Override the workflow's spend cap |
| `--max-tokens ` | Override the workflow's token cap |
| `--max-duration-ms `| Override the workflow's wall-clock budget (50/80/90% agent pressure nudges) |
| `--json` | One JSON envelope on stdout; all chatter suppressed |
| `--timeout ` | Abort after this many milliseconds (hard external kill, no warning) |
| `--events ` | NDJSON progress on stderr. **Requires `--json`** — otherwise it errors |
| `--scheduled` | Marks the run as scheduler-triggered (set automatically by launchd/cron) |
Frontmatter fields: [Workflow frontmatter](./workflow-frontmatter.md).
---
## `jazz mcp`
| Command | Purpose |
| --------------------- | -------------------------------------------------------------------- |
| `jazz mcp add [json]` | Add a server from inline JSON, `-f, --file `, or interactively |
| `jazz mcp list` | List configured servers |
| `jazz mcp remove` | Remove a server |
| `jazz mcp enable` | Enable a disabled server |
| `jazz mcp disable` | Disable a server |
See [Integrations → MCP](../integrations/mcp.md).
---
## `jazz runs`
Inspect runs still in flight — including your own parked ones, and, once a daemon started
one, runs begun from somewhere else entirely.
| Command | Purpose |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `jazz runs list` | List unfinished runs, newest first. `--agent`, `--conversation`, `--all` (include finished, with cost), `--json` |
| `jazz runs show ` | Show one run, including what it's waiting for. `--json` |
| `jazz runs approve ` | Approve what a parked run is waiting for; blocks until it finishes |
| `jazz runs reject ` | Refuse what it's waiting for; `--note ` tells it why |
| `jazz runs answer ` | Answer a question the run asked, in your own words: `--response ` (empty declines it) |
A run parks when it hits something needing your approval and nobody is there to give it — see
[Daemon](#jazz-daemon) for answering one from a different process than the one that started it.
---
## `jazz daemon`
Serves runs over HTTP: start one, poll it, approve or reject what a parked one is waiting for
— from a different terminal, a different process, or a different machine than the one that
began it. Runs in the foreground; supervision (restart on crash, start on boot) is the host's
job, not the daemon's — `jazz daemon install` wires it into that supervisor (systemd/launchd)
instead of leaving that hand-written.
| Flag | Purpose |
| ------------------------- | ------------------------------------------------------------------------------- |
| `--port ` | Port to listen on. Default `4747` |
| `--host ` | Interface to bind. Default `127.0.0.1`. Anything else requires a daemon token |
| `--serve-peers ` | Also answer questions from configured peers, using this agent. Off unless given |
A bearer token authenticates the operator routes (`/runs`, `/health`) whenever `--host` is
anything but loopback; loopback needs none. The first time this daemon binds a non-loopback
host with no token already set, Jazz generates one and stores it — an OS keyring when one's
reachable, otherwise a `chmod 600` file at `$JAZZ_HOME/secrets.json` (see
[Setting up peers](../start/peers-setup.md)) — printing it once so it can be copied to a
client. No setup command required. `/peer/ask` (when `--serve-peers` is given) uses a separate,
per-peer credential instead — see [`jazz peers`](#jazz-peers).
| Command | Purpose |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `jazz daemon set-token` | Generate (or store `$JAZZ_DAEMON_TOKEN` if set) a token before the daemon's first run — useful when a client needs the value in advance |
| `jazz daemon forget-token` | Remove the stored token |
| `jazz daemon install` | Install this as a persistent system service (systemd/launchd). Needs root; generates and stores its own token if none is set (no keyring or `$JAZZ_DAEMON_TOKEN` needed); doesn't report success until `/health` answers; `--serve-peers ` (required), `--host`, `--port`, `--yes` |
| `jazz daemon uninstall` | Remove the service installed by `install`. Needs root; `--yes` |
Set `$JAZZ_DAEMON_TOKEN` yourself instead of letting Jazz generate one when the value needs to
be known ahead of time — a client config written before the daemon has ever run, or an
ephemeral container whose `$JAZZ_HOME` doesn't survive to the next deploy.
See [Setting up peers](../start/peers-setup.md) for a full walkthrough, and
[Agent-to-agent](../concepts/agent-to-agent.md) for the tier model this exists to serve.
`jazz wake-trigger fire --agent --id ` is internal plumbing, not something you run
by hand: it's what `register_trigger` schedules with `launchd`/`at` to fire a wake trigger without
`jazz daemon` running. See [Wake Triggers](./tools.md#wake-triggers).
---
## `jazz peers`
Other people's agents this machine talks to, and what has been said to or by them.
| Command | Purpose |
| -------------------------------- | ---------------------------------------------------------------------------------------- |
| `jazz peers list` | List configured peers and what each may learn. `--json` |
| `jazz peers set-token ` | Store a peer's token, read from `$JAZZ_PEER_TOKEN` (or `--from-env `) |
| `jazz peers forget-token ` | Remove a peer's stored token |
| `jazz peers log` | Everything said to and by a peer, newest first. `--peer `, `--limit `, `--json` |
Peers can be added by [invite](../start/peers-setup.md) — `jazz peers invite create/accept`
— or by editing `~/.jazz/config.json` directly. See [Setting up peers](../start/peers-setup.md)
for both paths.
### `jazz peers invite`
| Command | Purpose |
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `jazz peers invite create ` | Create a one-time invite link granting `` a tier once accepted. `--disclosure ` (required), `--persona ` (which persona answers them), `--expires `, `--host`/`--port` or `--public-url` (reverse-proxy setups), `--as `, `--qr`, `--json` |
| `jazz peers invite accept ` | Accept an invite link. `--as `, `--yes` (skip confirmation), `--json` |
| `jazz peers invite list` | Invites created on this machine. `--json` |
| `jazz peers invite revoke ` | Invalidate an invite before it's redeemed |
---
## `jazz persona`
| Command | Purpose |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `jazz persona list` | List personas (built-in + custom) |
| `jazz persona create` | Create a custom persona (interactive) |
| `jazz persona show ` | Show a persona by name or id |
| `jazz persona edit ` | Edit a custom persona |
| `jazz persona delete ` | Delete a custom persona |
| `jazz persona browse` | Browse the marketplace and install a persona (interactive). `--refresh` |
| `jazz persona search` | List every persona the marketplace offers. `--refresh` |
| `jazz persona install ` | Install a marketplace persona. `--as ` (local name), `-y`/`--yes` (skip confirmation), `--refresh` |
`install` prints the full system prompt and asks before writing it — a persona becomes an agent's
instructions, so non-interactive runs must pass `--yes`. The catalog is cached under
`/cache/persona-registry.json` and keeps working offline; `JAZZ_PERSONA_REGISTRY_URL`
points Jazz at a self-hosted catalog.
See [Personas](../concepts/personas.md).
---
## `jazz config`
| Command | Purpose |
| ------------------------------- | ----------------------------- |
| `jazz config show` | Show all configuration values |
| `jazz config get ` | Get one value |
| `jazz config set [value]` | Set one value |
See [Configuration](./configuration.md).
---
## `jazz update`
| Command | Purpose |
| --------------------- | ------------------------------------ |
| `jazz update` | Update Jazz to the latest version |
| `jazz update --check` | Check for updates without installing |
---
## In-chat commands
Available inside an interactive session. Type `/help` for the current list.
| Command | Purpose |
| ------------ | ----------------------------------------------------- |
| `/help` | List commands |
| `/tools` | Show available tools |
| `/skills` | Browse skills |
| `/workflows` | Browse workflows |
| `/mode` | Change approval mode (also Shift+Tab) |
| `/cost` | Tokens and USD for this session, including sub-agents |
| `/context` | Context window usage and the biggest consumers |
| `/compact` | Force context compaction now |
| `/switch` | Switch agent |
| `/peers` | List configured peers and what each may learn or do |
| `/new` | Start a fresh conversation |
**Keys:** double-Escape interrupts generation or a running tool. Shift+Tab cycles the
approval policy. Shift+Enter inserts a newline in the composer; Enter sends.
### Shell escapes
In the interactive terminal, type `! ` when the agent asks you to run a command
yourself. Jazz executes the command in the current session directory and sends its bounded
stdout, stderr, and exit code to the agent as context for the next response:
```text
> ! ssh user@test rm -r folder
> Did that remove the folder successfully?
```
The command is executed because you entered it explicitly; it does not wait for the model to
call `execute_command`. The built-in shell denylist, sanitized environment, timeout, process
interruption, and 256 KiB per-stream output cap still apply. Output is treated as command data,
not as instructions. A non-zero exit code is still passed to the agent so it can explain or
suggest the next step. `!` is an interactive terminal feature and is not interpreted by
`jazz run`, scheduled jobs, CI, or chat bridges.
---
## Output modes
`--output` controls formatting; it does not change what goes to stdout vs stderr.
| Mode | Behavior |
| ---------- | ------------------------------------------ |
| `rendered` | Full markdown rendering |
| `hybrid` | Default — rendered with plain fallbacks |
| `raw` | No formatting, no ANSI. **Use this in CI** |
| `quiet` | Suppress output |
---
## Related
- [Surfaces → Headless](../use-cases/headless.md) — the `jazz run` contract in depth
- [Configuration](./configuration.md) — config file and environment variables
- [Tools](./tools.md) — every tool and its risk tier
- [Workflow frontmatter](./workflow-frontmatter.md) — the `WORKFLOW.md` fields
---
## Configuration Reference
Source: https://jazz-cli.vercel.app/docs/reference/configuration.md
# Configuration Reference
Jazz is configured via configuration files and environment variables.
## Configuration File Locations
Jazz loads configuration from two layers:
1. **`~/.jazz/config.json`** — Global config (agents, API keys, defaults). All writes go here.
2. **`./.jazz/config.json`** — Optional project-local overrides (like `.claude/`). Read-only merge on top of global.
Merge order: defaults → `~/.jazz/config.json` → `./.jazz/config.json`.
Agents and storage always live in the Jazz home directory (`~/.jazz` or `JAZZ_HOME`), even when project overrides exist.
You can replace the global config path with `$JAZZ_CONFIG_PATH` or `--config`. Project-local `./.jazz/config.json` still merges on top.
Legacy `./jazz.config.json` in the project root is no longer discovered automatically. Move settings to `~/.jazz/config.json`.
## MCP Servers: `.agents/mcp.json`
Jazz also loads MCP servers from the `.agents` convention paths. These are merged with the main config (project overrides user):
- `~/.agents/mcp.json` — User-level MCP config
- `./.agents/mcp.json` — Project-level MCP config
See [MCP Servers](../integrations/index.md#mcp-servers) for format details.
## Main Config: `~/.jazz/config.json`
```json
{
"defaultModel": "anthropic:claude-3-5-sonnet",
"theme": "dark",
"notifications": true,
"autoUpdate": true,
"logLevel": "info"
}
```
### `maxIterations` and `maxSubagentIterations`
Iteration budgets — how many reasoning loops a run gets before it stops and asks whether to
continue.
```json
{
"maxIterations": 100,
"maxSubagentIterations": 30
}
```
| Key | Default | Applies to |
| ----------------------- | ------- | ------------------ |
| `maxIterations` | 100 | A top-level run |
| `maxSubagentIterations` | 30 | Each sub-agent run |
They are **separate knobs on purpose.** A sub-agent gets its own budget rather than the parent's
remainder — a child spawned on the parent's last iteration would be useless with one round — so
without a lower default, every level of delegation would be as expensive as the run that spawned
it. A sub-agent answers one scoped task; a child still working after 30 iterations has usually
misunderstood the brief rather than found more to do.
An explicit `--max-iterations` on the command line, or a workflow's own `maxIterations`, still
wins over `maxIterations` here. Both values are floored at 1.
### `maxCostUSD`, `maxTokens`, and `maxDurationMs`
Three more per-run budgets, alongside `maxIterations`: a dollar ceiling, a token ceiling, and a
wall-clock ceiling. Unlike `maxIterations` none of them has a default — leave a key unset and that
dimension is uncapped.
```json
{
"maxCostUSD": 0.2,
"maxTokens": 200000,
"maxDurationMs": 1800000
}
```
| Key | Unit | Checks |
| --------------- | ------------ | ----------------------------------------------- |
| `maxCostUSD` | US dollars | Own tokens priced via models.dev, plus any sub-agent spend rolled up through `childCostUSD`. |
| `maxTokens` | token count | Own prompt + completion tokens. Sub-agent tokens are not rolled up (cost is; tokens are not). |
| `maxDurationMs` | milliseconds | Wall-clock time since the run started. |
All three share the same enforcement model, distinct from `maxIterations`/`maxSubagentIterations`
in one important way:
- **Checked between iterations, not preemptively.** Each is evaluated once after an iteration's
LLM call and tool phase both finish, the same timing as the iteration-budget check. A single
expensive iteration — one that calls an expensive tool, or delegates to a sub-agent that itself
runs for a while — can push the total past the cap before the next check trips. None of them
interrupt an in-flight LLM call or tool execution.
- **`maxCostUSD` never guess-aborts an unpriced run.** If the model's pricing is unknown (a local
model with no catalog entry, say), `costUSD` stays undefined and the cap is skipped entirely
rather than assumed to be zero or exceeded. `maxTokens` has no such gap — it needs no pricing
metadata, so it still enforces where `maxCostUSD` cannot.
- **All three nudge the agent before they stop it.** Ephemeral pressure messages — never
persisted to the conversation, same reasoning as the iteration-budget nudge — are injected at
50%, 80%, and 90% of whichever budget is closest to being spent, mirroring the context-window
and iteration-budget warnings. The 90% message asks the agent to wrap up immediately; at 100%
the run is stopped between iterations regardless of what it answers back. `maxCostUSD`'s nudge
is skipped under the same unknown-pricing condition as its hard stop.
A run stopped by one of these reports it on the `AgentResponse`: `costCapped`, `tokenCapped`, or
`durationCapped`, each `true` only when that specific cap tripped. `jazz run --json` and
`jazz workflow run --json` surface the same fields in their envelope.
An explicit `--max-cost-usd`, `--max-tokens`, or `--max-duration-ms` on the command line, or a
workflow's own frontmatter key, still wins over the config value here.
Distinct from `--timeout` (`jazz run --timeout ` / `jazz workflow run --timeout `), which
is an *external* hard kill — a race against the whole run with no pressure warning — rather than a
soft, in-loop checkpoint. Use `--timeout` as the outer safety net and `maxDurationMs` for the
warned, graceful budget.
### `maxSubagentDepth`
How many levels of sub-agent may nest below a top-level run. Defaults to **3**.
```json
{
"maxSubagentDepth": 3
}
```
Each level of delegation gets a *fresh* iteration budget rather than its parent's remainder — a
child spawned on the parent's last iteration would be useless otherwise — so this depth, not the
parent's remaining budget, is what bounds how much a nest of sub-agents can spend. Past the
limit `spawn_subagent` returns an error telling the agent to do the work itself; it never
silently runs the child anyway. Set `0` to stop agents delegating at all. See
[Sub-agents](../internals/subagents.md#nesting-depth).
### `context`
When to warn the model that its context is filling, and when to compact history automatically. Both are fractions of the run's context budget — the effective model window, after any `numCtx` pin or agent `maxContextTokens` ceiling.
```json
{
"context": {
"warnThresholdRatio": 0.7,
"compactThresholdRatio": 0.8
}
}
```
| Key | Default | Effect |
| ----------------------- | ------- | ---------------------------------------------------------------------- |
| `warnThresholdRatio` | 0.7 | The model is told to consolidate what it has while detail still exists |
| `compactThresholdRatio` | 0.8 | Older history is summarized automatically |
The ordering `warn < compact < 0.95` is enforced. The 0.95 ceiling is the trim ratio: trimming *discards* messages rather than summarizing them, so a compaction threshold at or above it would let trimming pre-empt compaction and turn the whole scheme into a sliding window. A value that breaks the ordering — or that isn't a number strictly between 0 and 1 — is ignored with a logged warning and the default is used; the run never fails on a bad ratio.
Raising `compactThresholdRatio` keeps more verbatim history but leaves less headroom, which bites hardest on local servers whose real window is smaller than advertised. Lowering it compacts earlier and more often, costing a summarizer call each time. The reserved-space figure in `/context` is derived from this setting, so the grid always reflects where compaction actually fires.
### `output`
Terminal display of reasoning, tools, and formatting. The interactive TUI reads these when a session starts.
```json
{
"output": {
"showReasoning": true,
"collapseReasoning": true,
"showToolExecution": true,
"mode": "hybrid"
}
}
```
| Key | Default | Effect |
| -------------------- | ------- | ----------------------------------------------------------------------------------------------- |
| `showReasoning` | `true` | Stream the model's reasoning while it thinks |
| `collapseReasoning` | `true` | After thinking finishes, collapse it to a one-line summary. **Ctrl+R** expands it in place |
| `showToolExecution` | `true` | Show tool calls as they run |
| `mode` | `hybrid` | `rendered` \| `hybrid` \| `raw` \| `quiet`. Overridable with `JAZZ_OUTPUT_MODE` / `--output` |
Set `collapseReasoning` to `false` to leave the full reasoning visible after it finishes. Ctrl+R is then unused — there is nothing collapsed to expand. Change it with `jazz config set output.collapseReasoning false`, or from **Output & Display** in `jazz config`.
### `scheduler`
Which scheduler runs workflows: your OS scheduler (launchd on macOS, cron on Linux), or Jazz's
own in-process ticker inside `jazz daemon`. Defaults to `"auto"`, which uses the OS scheduler.
```json
{
"scheduler": {
"mode": "in-process"
}
}
```
| Value | Effect |
| -------------- | ----------------------------------------------------------------------- |
| `"auto"` | Default. `jazz workflow schedule` installs a launchd/cron entry |
| `"in-process"` | `jazz daemon` polls due schedules itself once per minute; no OS entries |
Set it from **Scheduler** in `jazz config`, or directly:
```bash
jazz config set scheduler.mode in-process
```
`in-process` only matters for a host you leave running — `jazz daemon` must itself be running for
its ticker to fire. The `JAZZ_SCHEDULER=in-process` environment variable still works and overrides
this setting, which is useful for a one-off run without touching the saved config. See
[Scheduled runs](../use-cases/scheduled.md) for how each mode installs and fires.
### `webhooks`
Webhook doors onto specific agents. Each entry is served at `POST /webhooks/` by
`jazz daemon` and runs one fixed prompt, with the request body quoted into it as data.
```json
{
"webhooks": [
{
"name": "room",
"agentId": "default",
"conversation": "threaded",
"promptTemplate": "You are in a conversation. Reply to the latest message.\n\n{{payload}}"
}
]
}
```
| Field | Required | Effect |
| ---------------- | -------- | ----------------------------------------------------------------------------------- |
| `name` | yes | URL segment and token lookup key. Unique |
| `agentId` | yes | Which agent the webhook wakes |
| `promptTemplate` | yes | Prompt for the fire. `{{payload}}` is replaced with the quoted body |
| `description` | no | Note for yourself; never sent to the model |
| `conversation` | no | `"ephemeral"` (default) starts fresh each fire; `"threaded"` resumes per thread key |
Tokens never live in this file. Store one in the keyring with
`jazz config set webhooks..token`, or supply `JAZZ_WEBHOOK_TOKEN_` in the
environment. A threaded webhook takes its thread key from the `X-Jazz-Thread` request header.
See [Webhooks](../concepts/webhooks.md) for the full behaviour.
## Project Overrides: `./.jazz/config.json`
Use for project-specific settings such as MCP enable/disable flags or logging level. Do not put agent storage paths here — agents always load from `~/.jazz`.
## Environment Variables
Override settings or provide API keys via `.env` or the process environment.
### Paths and data
| Variable | Effect |
| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JAZZ_HOME` | Jazz home directory (default `~/.jazz`). Holds agents, history, logs, telemetry, and the model-catalog snapshot. Use it to isolate test data when developing Jazz |
| `JAZZ_CONFIG_PATH` | Global config file path (same as `--config`) |
| `JAZZ_LOG_DIR` | Log directory override |
### Network behavior
| Variable | Effect |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `JAZZ_OFFLINE` | `1`/`true`: make no outbound request of Jazz's own — skips the update check *and* the models.dev catalog fetch. See [Airgapped](../start/airgapped.md) |
| `JAZZ_DISABLE_UPDATE_CHECK` | `1`: skip only the npm version check |
| `JAZZ_MODELS_DEV_URL` | Point the model catalog at an internal mirror of `https://models.dev/api.json` |
| `HTTPS_PROXY` / `HTTP_PROXY` | Send every outbound request — provider APIs, web tools, remote MCP servers, the update check — through an HTTP proxy. Lowercase names work too, and `ALL_PROXY` covers both protocols |
| `NO_PROXY` | Comma-separated hosts and domain suffixes to reach directly, bypassing the proxy |
Only `http://` and `https://` proxies are supported; a SOCKS proxy is reported at
startup rather than silently ignored. If the proxy terminates TLS with a private
CA, point Node at that CA with `NODE_EXTRA_CA_CERTS=/path/to/ca.pem`.
### Providers
| Variable | Effect |
| ------------------------------ | ---------------------------------------------------------------------------------------- |
| `OPENAI_API_KEY` | OpenAI |
| `ANTHROPIC_API_KEY` | Anthropic |
| `ANTHROPIC_WORKSPACE_ID` | Only needed with an identity-linked Anthropic API key (see below) |
| `GOOGLE_GENERATIVE_AI_API_KEY` | Google Gemini |
| `OPENROUTER_API_KEY` | OpenRouter |
| `OLLAMA_BASE_URL` | Ollama endpoint (default `http://localhost:11434/api`; `/api` is appended automatically) |
| `LLAMACPP_BASE_URL` | llama.cpp endpoint (default `http://localhost:8080/v1`) |
Other providers follow the same `_API_KEY` convention — see
[Integrations → Providers](../integrations/providers.md).
Anthropic has two kinds of API keys: a workspace-scoped key (the common case) needs
nothing extra, but an **identity-linked** key — one tied to a Console user identity
rather than a single workspace, typically issued by an organization — needs a
workspace ID on every request. If a request fails with `anthropic-workspace-id is
required`, set `ANTHROPIC_WORKSPACE_ID` or `llm.anthropic.workspace_id` in
`config.json` (`jazz config` prompts for it right after the API key).
### Output and terminal
| Variable | Effect |
| ------------------ | ---------------------------------------------------------------------------- |
| `JAZZ_NO_TUI` | `1`: no terminal UI at all, plain output (same as `--no-tui`) |
| `JAZZ_FULLSCREEN` | `0`: keep an interactive interface but not the alternate screen. Print-and-exit commands never enter it, so their output stays in scrollback. |
| `JAZZ_OUTPUT_MODE` | `rendered` \| `hybrid` \| `raw` \| `quiet` (same as `--output`) |
| `JAZZ_THEME` | Colour theme |
| `JAZZ_UI_GLYPHS` | `unicode` \| `ascii` — override glyph detection for terminals that misreport |
| `JAZZ_TABLE_STYLE` | Table rendering style |
| `NO_COLOR` | Standard: disable colour output |
### Scheduling
| Variable | Effect |
| ----------------------- | ------------------------------------------------- |
| `JAZZ_DISABLE_CATCH_UP` | `1`: never offer missed scheduled runs on startup |
### Notifications
| Variable | Effect |
| ---------------------------------------------- | -------------------------------------------------------------- |
| `JAZZ_TERMINAL_NOTIFIER` / `TERMINAL_NOTIFIER` | Path to a `terminal-notifier` binary for desktop notifications |
| `JAZZ_TERMINAL` | Terminal identity used for notification attribution |
## `telemetry`
Jazz records what each run did — agent runs, LLM requests and token usage (agent loop vs
command-risk classifier), retries, tool invocations, Jazz process RSS/heap/CPU, and CLI
commands — as NDJSON under `~/.jazz/telemetry/events/YYYY-MM-DD.ndjson`,
pruned after `retentionDays`. Set `telemetry.otlp` to also push those events to an
OpenTelemetry collector. See [Observability](../start/observability.md) for a working
collector and Langfuse setup.
```json
{
"telemetry": {
"enabled": true,
"retentionDays": 90,
"otlp": {
"endpoint": "http://localhost:4318",
"headers": { "authorization": "Basic " },
"serviceName": "jazz",
"captureContent": false
}
}
}
```
| Field | Default | Effect |
| --------------------- | ------------------------------ | ------------------------------------------------------------ |
| `enabled` | `true` | Master switch. `false` records nothing, locally or remotely |
| `storagePath` | `~/.jazz/telemetry` | Where the local NDJSON files live |
| `bufferSize` | `100` | Events buffered in memory before a flush |
| `flushIntervalMs` | `30000` | Periodic flush interval |
| `retentionDays` | `90` | Local files older than this are deleted |
| `otlp.enabled` | `true` when an endpoint is set | Explicit opt-out that keeps the endpoint configured |
| `otlp.signals` | `["traces"]` | Signals to export: `traces`, `logs`, or both |
| `otlp.endpoint` | — | Collector base URL; `/v1/traces` and `/v1/logs` are appended |
| `otlp.tracesEndpoint` | — | Full traces URL including path, overriding `endpoint` |
| `otlp.logsEndpoint` | — | Full logs URL including path, overriding `endpoint` |
| `otlp.headers` | `{}` | Extra HTTP headers, typically auth |
| `otlp.serviceName` | `jazz` | `service.name` on exported records |
| `otlp.captureContent` | `false` | Include prompt, completion, and tool argument text |
| `otlp.timeoutMs` | `10000` | Per-request timeout |
### OTLP environment variables
Every `otlp` field falls back to the standard `OTEL_*` variable, so a Jazz process inherits an
already-configured collector without touching `config.json`. Precedence is config → environment
→ default.
| Variable | Effect |
| ------------------------------------ | ---------------------------------------------------------- |
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Collector base URL. Setting this alone turns export on |
| `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | Full traces URL, used verbatim; wins over the base URL |
| `OTEL_EXPORTER_OTLP_LOGS_ENDPOINT` | Full logs URL, used verbatim; wins over the base URL |
| `OTEL_EXPORTER_OTLP_HEADERS` | `key=value` pairs, comma-separated, values percent-encoded |
| `OTEL_SERVICE_NAME` | `service.name` on exported records |
There is no environment variable for `captureContent` — it can only be turned on in config, and
never follows from configuring an endpoint.
### What gets exported
Events are sent over OTLP/HTTP with JSON encoding. By default they are exported as **traces**:
one trace per agent run, with the run as the root span and each LLM request, retry, and tool
call as a child span. Traces are what LLM-observability backends accept — Langfuse ingests OTLP
traces and not logs — so this is the signal that works everywhere. Add `logs` to
`otlp.signals` to also emit the same events as OTLP log records.
Where the OpenTelemetry GenAI semantic conventions define an attribute, Jazz uses it
(`gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`,
`gen_ai.usage.output_tokens`); everything else is namespaced under `jazz.*`.
**`captureContent` is the one setting that turns observability into data egress.** With it off
(the default) Jazz drops content-bearing fields and truncates every remaining string attribute
to 256 characters. Turning it on sends user prompts, model output, and tool arguments to
whatever endpoint you configured. No event Jazz emits today carries content, so the flag is
currently inert — it exists so that adding a content-bearing field later cannot leak it by
default.
Export never blocks a run. A collector that is slow, down, or rejecting is logged as a warning
and the events are dropped once the buffer ceiling is reached; the local NDJSON file is
unaffected.
## `autoApprovedCommands`
A persisted allowlist for `execute_command`, set at the top level of `~/.jazz/config.json`:
```json
{ "autoApprovedCommands": ["himalaya", "khal", "git status"] }
```
Each entry lets one command through **without** raising the whole approval tier. This is the
right tool when a scheduled workflow needs a skill that shells out — email, calendar, and
Obsidian all run through `execute_command`, which is `unknown`, so a `low-risk` workflow
cannot use them otherwise.
Matching uses a parsed key (binary + first subcommand) with exact or word-boundary
comparison, never a raw string prefix — so `git status` does not also permit
`git status && rm -rf /`. See
[Tools & approval](../internals/tools-and-approval.md#two-sharper-controls).
## Agent Config: `customTools`
`customTools` is an optional field on an agent's `config`, letting a deployment declare new
tools directly in the agent's JSON config instead of shipping code — the closest analog is the
Claude Agent SDK's custom tools (name, description, input schema, handler, registered alongside
builtins). Declaring a custom tool is not enough to expose it: its `name` must also appear in
the agent's `tools` array, exactly like a builtin or MCP tool. A declared-but-unlisted custom
tool is simply skipped at registration.
Each entry has a `handler.type` of either `record` or `command`.
**`record`** — no side effect. The call is validated and appended to the run's `toolCalls` (the
same field every other tool call surfaces in), so the *caller* embedding Jazz can read the
arguments and act on them; the model itself only ever sees the fixed `response`. Note that
`toolCalls` reports calls as the model sent them — including calls whose arguments failed schema
validation — so callers must re-validate arguments before acting on them. This is the
pattern behind confirmation-card / propose-then-confirm flows:
```json
{
"name": "propose_action",
"description": "Propose an action for the user to confirm before it is carried out.",
"parameters": {
"type": "object",
"properties": {
"action": { "type": "string", "description": "Short description of the proposed action" },
"payload": { "type": "object", "description": "Structured data needed to carry out the action" }
},
"required": ["action"]
},
"handler": {
"type": "record",
"response": "Proposal recorded — the user will see a confirmation card."
}
}
```
**`command`** — spawns `handler.command` directly (an argv array, no shell) with the validated
tool arguments serialized as JSON on the child's stdin. Exit code `0` returns stdout (capped at
16 KB — tighter than `execute_command`'s 256 KB, because custom commands are small trusted argv
programs, not a general shell) as the tool result; a non-zero exit, spawn error, or timeout produces a failure result the
model sees the same way it sees a failing builtin tool. The command's environment is sanitized
the same way `execute_command` sanitizes its shell environment (see `envAllowlist` below), using
the `envAllowlist` of the agent that DECLARED the tool, not whichever agent happens to be calling
it. **Security note:** command tools execute with no interactive approval step — they are always
registered `high-risk` and run whatever the deployment configured the moment the model calls
them, so treat `handler.command` entries as deployment-authored, trusted commands, not
user-supplied ones.
```json
{
"name": "lint_project",
"description": "Run the project linter",
"parameters": {
"type": "object",
"properties": {}
},
"handler": {
"type": "command",
"command": ["./lint.sh"],
"timeoutMs": 30000
}
}
```
`timeoutMs` defaults to 30 000 ms and is capped at 300 000 ms (5 minutes) when set.
### Validation rules
- `name`: must match `^[a-z][a-z0-9_]{1,63}$`, must be unique within `customTools`, and must not
start with the `mcp_` prefix (reserved for MCP-sourced tools).
- At most 16 entries per agent.
- `description`: 1-1024 characters — this is what the model reads to decide when to call the tool.
- `parameters`: a JSON Schema object; must have `"type": "object"`. Only a subset of JSON Schema
is honored by the converter that turns this into the runtime validator: unsupported keywords
are silently dropped, and a property with a missing or unrecognized `type` degrades to an
empty object schema — which then rejects a scalar argument (string/number/boolean) passed for
that property. Stick to `string`, `number`, `integer`, `boolean`, `null`, `enum`, and plain
nested objects/arrays of those.
- `handler.response` (record): optional string, at most 1024 characters, defaults to `"Recorded."`.
- `handler.command` (command): a non-empty array of non-empty strings.
### Collisions and re-registration
A custom tool name that collides with an already-registered builtin or MCP tool name — or one of
its aliases (e.g. `glob`) — fails the run with a configuration error rather than silently
overriding the existing tool; rename the custom tool or remove the conflicting one. Because tool
registration runs on every agent run (not just once per process), re-registering the *exact
same* custom tool definition under a name that's already registered is a no-op, not a collision;
only a genuinely different definition sharing that name is rejected — and since the registry has
no way to update an existing registration in place, that rejection fails the run MID-SESSION
(not at process startup): restart the session, or revert the custom tool definition to match
what was registered earlier.
## Agent Config: `envAllowlist`
`envAllowlist` is an optional `string[]` field on an agent's `config` that exempts specific
environment variable names from the sensitive-name scrub applied to shell commands the agent
runs (`execute_command` and custom `command`-handler tools share this same env-sanitization
path; it does not affect `grep`/`find`/`git` tool spawns). By default, any variable whose name
matches `API|KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH` (case-insensitive) is stripped before a
child process is spawned. Listing a name in `envAllowlist` copies that variable from the
process environment into the child's environment even though it matches the scrub regex — it
never invents a value that isn't already set. `SSH_*`-prefixed names and the small set of base
env vars Jazz always sets itself (`PATH`, `HOME`, `USER`, `SHELL`, etc.) can never be
allowlisted — that block applies unconditionally, regardless of `envAllowlist` membership.
```json
{
"envAllowlist": ["MY_SERVICE_TOKEN"]
}
```
Validation: at most 32 names, each matching `^[A-Z][A-Z0-9_]{0,63}$` (uppercase letters,
digits, and underscores, starting with a letter, up to 64 characters).
**Security note:** allowlisting a secret-bearing variable hands it to every shell command the
agent runs — the deployment owns that trade-off.
---
## Agent Config: `temperature`
`temperature` is an optional `number` field on an agent's `config` that sets the model's
sampling temperature. The wizard never asks for it, so it is a file-only setting:
```json
{
"config": {
"llmProvider": "anthropic",
"llmModel": "claude-sonnet-4-5",
"temperature": 0.2
}
}
```
**Leaving it unset is not the same as setting a default.** When the field is absent Jazz omits
the parameter from the request entirely and the provider applies its own default. Set it only
when you want to override that.
Jazz does not range-check the value — the valid range differs by provider (commonly `0`–`1` or
`0`–`2`), and an out-of-range number surfaces as a provider error.
**Models that reject a custom temperature silently ignore this field.** Some models — notably
several reasoning models — accept no temperature at all. Jazz reads that capability from
[models.dev](https://models.dev) metadata (and, for OpenRouter, the model's
`supported_parameters`), and drops the parameter rather than sending a request the provider
would reject. The agent still runs; the setting simply has no effect. If a temperature change
appears to do nothing, that is the first thing to check.
---
## Tools Reference
Source: https://jazz-cli.vercel.app/docs/reference/tools.md
# Tools Reference
This page helps you find the exact name, risk tier, and behavior of a tool.
Every tool an agent can call, generated from the registry. Risk tiers determine what runs
unattended — see [Tools & approval](../internals/tools-and-approval.md) for the mechanism
and [Security](../../SECURITY.md) for the threat model.
> This page is verified by a test (`bun test packages/core/src/agent/tools/register-tools.docs.test.ts`)
> that fails if the registry and this table drift apart. If you add a tool, update this page.
---
## At a glance
| | Count |
| ----------------------------------------------------------------------- | ------ |
| **Agent-facing tools** | **46** |
| Hidden `execute_*` counterparts (the second half of each approval pair) | 9 |
| Total registered | 56 |
| `read-only` | 25 |
| `low-risk` | 12 |
| `high-risk` | 7 |
| `unknown` | 2 |
Plus, registered per agent rather than globally:
| Source | Tools | Notes |
| ---------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| **Skills** | `find_skills`, `load_skill`, `load_skill_section` | Present when the agent has skills available — see [Skills loading](../internals/skills-loading.md) |
| **MCP** | `mcp__` | Discovered from the agent's tool list, connected lazily — see [MCP](../integrations/mcp.md) |
| **Custom** | whatever you define | Agent-config `customTools` — see [Configuration](./configuration.md#agent-config-customtools) |
---
## How approval pairs work
Eight tools are **gated**: calling them does not act. They return a description of the
intended action (including a preview diff for edits), and only after approval — from a human
or from `--approval-policy` — does Jazz invoke the hidden `execute_*` counterpart.
```mermaid
flowchart LR
M["Model calls
write_file"] --> P["Propose:
resolve path, compute diff
no mutation"]
P --> G{"Approved?"}
G -->|yes| E["execute_write_file
actually writes"]
G -->|no| D["Refusal returned
to the agent"]
classDef gate fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef act fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class G gate
class E act
```
You never call `execute_*` names yourself; they are hidden from the model's tool list.
---
## What each tool reveals
Risk is not the same question as disclosure. Risk asks what a tool can do **to** the
machine; disclosure asks how freely its answer can be shared. The two do not correlate:
`read_file` is read-only and can reveal anything, `get_time` is read-only and reveals
nothing, `write_file` changes the machine and reveals nothing at all.
Every tool declares both. The field is required, with no default anywhere, so a new tool
cannot be added without someone deciding.
| Level | Safe to tell | Tools |
| ---------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `public` | safe to tell anyone | `add_reminder`, `cp`, `mkdir`, `mv`, `rm`, `web_fetch`, `web_search`, `write_file` |
| `internal` | the shape of this machine — paths, names, what is installed | `analyze_media`, `cancel_batch`, `cancel_trigger`, `cd`, `context_info`, `create_pdf`, `create_web_app`, `find`, `get_time`, `list_jobs`, `list_triggers`, `ls`, `pdf_page_count`, `pwd`, `register_trigger`, `search_tools`, `stat` |
| `private` | your own material — file contents, memory, schedule, transcript | `ask_file_picker`, `ask_user_question`, `cancel_reminder`, `edit_file`, `enqueue_batch`, `execute_command`, `grep`, `http_request`, `list_reminders`, `list_todos`, `manage_memory`, `manage_todos`, `manage_workspace`, `read_file`, `read_pdf`, `retrieve_tool_result`, `spawn_subagent`, `summarize_context`, `update_work_state`, `view_memory`, `view_workspace` |
A tool spanning two levels takes the more sensitive one — `edit_file` writes, but its approval
message carries a diff of your file, so it is `private`. `http_request` reaches private
networks including services on localhost, so it is too.
Skill tools (`find_skills`, `load_skill`, `load_skill_section`) are `internal` too, and are
absent from the table for the same reason they are absent from the one below: they are
registered per agent rather than globally.
There is no `unknown` level. **MCP and custom tools are `private`**, because a tool defined
outside this codebase returns something this codebase cannot classify, and the safe reading
of "unknown" is the most restrictive one.
---
## The tools
### File Management
| Tool | Risk | Approval pair | What it does |
| ---------------- | ----------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `cd` | `read-only` | — | Change the working directory for this session. Persists across subsequent tool calls. |
| `cp` | `high-risk` | `execute_cp` | Copy a file or directory. Equivalent to shell cp/cp -r. Directories are copied recursively. |
| `edit_file` | `high-risk` | `execute_edit_file` | Edit file via replace_lines, replace_pattern, insert, or delete_lines. Applied in order. IMPORTANT: Use rep… |
| `find` | `read-only` | — | Find files/directories by name, glob, or regex. Also advertised as `glob`. Searches names/paths, NOT contents (use grep). |
| `grep` | `read-only` | — | Search file contents for text patterns (ripgrep with grep fallback). Supports regex, file filters, context… |
| `ls` | `read-only` | — | List directory contents. Supports recursive traversal, name filtering, hidden files. Default 200 results, c… |
| `mkdir` | `high-risk` | `execute_mkdir` | Create a directory. Parents created automatically by default. |
| `mv` | `high-risk` | `execute_mv` | Move or rename a file or directory. Equivalent to shell mv. |
| `pdf_page_count` | `read-only` | — | Get total page count of a PDF without reading content. |
| `pwd` | `read-only` | — | Print the current working directory. |
| `read_file` | `read-only` | — | Read a UTF-8 text file with numbered lines. startLine/endLine; negative startLine reads from the end. |
| `read_pdf` | `read-only` | — | Extract text and tables from a PDF. Use pdf_page_count first for large files. Supports page ranges. |
| `rm` | `high-risk` | `execute_rm` | Remove a file or directory. May be irreversible. |
| `stat` | `read-only` | — | Check file/directory existence and get metadata (type, size, times). |
| `write_file` | `high-risk` | `execute_write_file` | Write content to a file, creating it if needed. Replaces entire file content. |
### Shell Commands
| Tool | Risk | Approval pair | What it does |
| ----------------- | --------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `execute_command` | `unknown` | `execute_execute_command` | Run a shell command when no dedicated tool exists. Each command is classified `read-only`, `low-risk`, or `high-risk`, and the active tier then applies to that verdict. Stdout/stderr capped at 256 KB each. |
In the interactive terminal, an operator can also type `! `. That explicit shell escape
uses the same cwd resolution, environment sanitization, denylist, timeout, interruption, and
output caps as `execute_command`, then gives the result to the agent as context. It is not
available through `jazz run` or remote chat surfaces.
### Web Search
| Tool | Risk | Approval pair | What it does |
| ------------ | ----------- | ------------- | ----------------------------------------- |
| `web_search` | `read-only` | — | Search the web for real-time information. |
### Web Fetch
| Tool | Risk | Approval pair | What it does |
| ----------- | ----------- | ------------- | ------------------------------------------ |
| `web_fetch` | `read-only` | — | Fetch and extract text content from a URL. |
### HTTP
| Tool | Risk | Approval pair | What it does |
| -------------- | ----------- | ------------- | ---------------------------------------------------------------------------------- |
| `http_request` | `read-only` | — | Send HTTP requests. Supports all methods, headers, query params, and body formats. |
### Todo
| Tool | Risk | Approval pair | What it does |
| ------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------ |
| `list_todos` | `read-only` | — | Read the current todo list. Returns all items with their status and priority. |
| `manage_todos` | `low-risk` | — | Create or update the todo list. Send the FULL list of items each time (replaces the previous list). Use thi… |
| `update_work_state` | `low-risk` | — | Record where you are in the current task so it survives compaction and resuming later. Patches o… |
### Memory
Opt-in per agent (like File Management) rather than always-on — see [Memory](../internals/memory.md).
| Tool | Risk | Approval pair | What it does |
| --------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------- |
| `view_memory` | `read-only` | — | Call first, before answering, at the start of every conversation. |
| `manage_memory` | `low-risk` | — | Save facts about this person that will still matter later — preferences, location, age, how they… |
`update_work_state` lives with the todo tools (always-on). It is scoped to one conversation and discarded when the task ends, unlike memory which persists across conversations — see [Context management](../internals/context-management.md).
### Workspace
Opt-in per agent (like Memory) rather than always-on. Deliberately separate from memory: memory
is small, curated, one-file-per-topic notes; workspace is where large working drafts, research
dumps, and intermediate artifacts live, referenced from memory rather than duplicated into it.
| Tool | Risk | Approval pair | What it does |
| ------------------- | ----------- | ------------- | --------------------------------------------------------------------------------------------------- |
| `view_workspace` | `read-only` | — | View your durable scratch space: working drafts, research dumps, and intermediate artifacts too… |
| `manage_workspace` | `low-risk` | — | Save durable working drafts, research dumps, or intermediate artifacts too large or provisional… |
### Reminders
Opt-in per agent. Reminders persist on disk and fire later on the same surface that scheduled them — see [Reminders](../internals/reminders.md).
For CLI-hosted agents, `add_reminder` installs the same real one-shot host-scheduler job
(`launchd` on macOS, an `at` job on Linux) used for wake triggers, so a reminder fires even if
`jazz daemon` isn't running; firing sends a native OS desktop notification instead of resuming a
conversation — a reminder is "notify a person," never "resume the agent." `jazz daemon`'s
in-process ticker remains a fallback for hosts with neither `launchd` nor `at`. Telegram and
Discord reminders are unaffected by any of this: their bots already sweep and deliver reminders
as chat messages from their own in-process interval, unchanged.
| Tool | Risk | Approval pair | What it does |
| ----------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `add_reminder` | `low-risk` | — | Schedule a reminder from a duration (`30m`), clock time (`18:00`), `tomorrow HH:MM`, a weekday (`tue 20:00`), or an absolute `2026-08-25 20:00`. |
| `list_reminders` | `read-only` | — | List this person's pending reminders, including their id, fire time, and text. |
| `cancel_reminder` | `low-risk` | — | Cancel a pending reminder by id (get the id from list_reminders first). |
### Wake Triggers
Opt-in per agent. A trigger causes the agent to actually run again with a given prompt, resuming
the exact conversation it was scheduled from — unlike a reminder, which just delivers a note to a
person. See [Reminders](../internals/reminders.md) for how the two compare.
`register_trigger` does not depend on `jazz daemon` running to actually fire. Registering a
trigger installs a real one-shot job with the host's own scheduler — a `launchd` job on macOS, an
`at` job on Linux — that fires the trigger by invoking `jazz` directly at the scheduled time, even
if nothing else is running. `jazz daemon`'s in-process ticker remains a fallback for platforms or
environments with neither `launchd` nor the `at` binary available (most containers, some CI), and
scheduling with the host is always best-effort: if it fails for any reason, registration still
succeeds and the ticker is the safety net.
| Tool | Risk | Approval pair | What it does |
| -------------------- | ----------- | ------------- | ----------------------------------------------------------------------------------------------------- |
| `register_trigger` | `low-risk` | — | Schedule yourself to wake up later and resume this exact conversation — use this when you need to… |
| `list_triggers` | `read-only` | — | List this agent's pending self-scheduled wake triggers. |
| `cancel_trigger` | `low-risk` | — | Cancel a pending wake trigger by id (get the id from list_triggers first). |
### Background Jobs
Opt-in per agent. Runs several independent shell commands in the background with a concurrency
cap and per-job retry/backoff, without blocking the agent's turn. Completion (fan-in) resumes the
conversation the same way a wake trigger fires, once every job in the batch reaches a final
state, and the agent is told each job's status **and what it printed** — a batch exists to find
something out, so an exit code on its own would tell it nothing.
If that resumed turn needs an approval nobody is there to give, the run parks instead of dying:
you get a desktop notification naming it, and `jazz runs resume ` finishes it. See
[tools and approval](../internals/tools-and-approval.md).
| Tool | Risk | Approval pair | What it does |
| --------------- | ----------- | ------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `enqueue_batch` | `unknown` | `execute_enqueue_batch` | Run several independent shell commands in the background with a concurrency cap and per-job retry/backoff. |
| `list_jobs` | `read-only` | — | List this agent's background job batches, every job's status, and what each one printed. |
| `cancel_batch` | `low-risk` | — | Cancel a job batch's pending jobs by id (jobs already running finish naturally). |
### Context
| Tool | Risk | Approval pair | What it does |
| -------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------- |
| `context_info` | `read-only` | — | Get current context window token usage statistics. |
| `get_time` | `read-only` | — | Get current date and time. Use for scheduling, relative times (yesterday, next Monday), and timestamps. |
| `retrieve_tool_result` | `read-only` | — | Read a tool body that was offloaded from context. Pass the `tool_call_id` from the placeholder. |
### Tool Search
| Tool | Risk | Approval pair | What it does |
| -------------- | ----------- | ------------- | --------------------------------------------------------------------------------------------------- |
| `search_tools` | `read-only` | — | Fetch full parameter schemas for deferred tools (MCP servers, background jobs, etc.) you can see by name in your tool list but haven't fetched yet. |
### Sub Agents
| Tool | Risk | Approval pair | What it does |
| ------------------- | ----------- | ------------- | ------------------------------------------------------------------------------------------------------------ |
| `spawn_subagent` | `low-risk` | — | Spawn a sub-agent with fresh context for a specific task. Personas: coder, researcher, default. Optionally validate a bounded JSON handoff with `resultSchema`; see Sub-agents internals. |
| `summarize_context` | `read-only` | — | Compact conversation by summarizing older messages to free token budget. Always performs summarization when… |
### Perception Delegation
Always-on. Lets a text-only agent borrow eyes, ears, or a watch from a model that has them.
| Tool | Risk | Approval pair | What it does |
| --------------------------- | ----------- | ---------------------- | ------------------------------------------------------------------------------------------------------------ |
| `analyze_media` | `high-risk` | `execute_analyze_media` | Delegate image/audio/video analysis to a capable model companion and get the textual answer back. The person at the keyboard picks which model does the looking (picker-style approval, never auto-approved); an agent with a pre-bound `companions` entry for the role (`analyze:image`, `analyze:audio`, `analyze:video`) routes there silently instead. |
### User Interaction
| Tool | Risk | Approval pair | What it does |
| ------------------- | ----------- | ------------- | --------------------------------------------------------------------------------------- |
| `ask_file_picker` | `read-only` | — | Show an interactive file picker for the user to select a file. |
| `ask_user_question` | `read-only` | — | Ask the user a question with interactive selectable suggestions. One question per call. |
### Web App
Opt-in per agent via `tools`. Used by chat bridges that can render a Mini App or a static image.
| Tool | Risk | Approval pair | What it does |
| ---------------- | ---------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `create_web_app` | `low-risk` | — | Create an interactive UI — a chart, form, dashboard, small game, or any other webpage — for delivery as a static image or a live page. |
| `create_pdf` | `low-risk` | — | Render a PDF from HTML the agent writes, saved to the working directory or an explicit path. Text and numbers are exact — a renderer, not an image generator. |
---
## What is _not_ a built-in tool
A common and consequential misreading. These capabilities exist, but **not as built-in
tools** — they are [skills](../concepts/skills.md) that shell out through
`execute_command`, which is `unknown`:
| Capability | How it actually works | Effective risk tier |
| --------------------------- | ------------------------------------------------------------------------------------------ | ----------------------- |
| Email (read, archive, send) | `email` skill → [Himalaya](https://github.com/pimalaya/himalaya) CLI via `execute_command` | `unknown` |
| Calendar (list, create) | `calendar` skill → [khal](https://github.com/pimutils/khal) via `execute_command` | `unknown` |
| Obsidian vault writes | `obsidian` skill → CLI via `execute_command`, or `write_file` | `unknown` / `high-risk` |
So a scheduled workflow set to `autoApprove: low-risk` **cannot archive an email** — every
himalaya invocation is declined. The fix is usually _not_ to raise the whole tier to
`high-risk` (which also unlocks `rm` and `git push`), but to allowlist the specific binary:
```json
// ~/.jazz/config.json
{ "autoApprovedCommands": ["himalaya", "khal"] }
```
That keeps the tier low while letting the one command through. Matching is on a parsed key
(binary + first subcommand), never a raw prefix — see
[Tools & approval](../internals/tools-and-approval.md#two-sharper-controls).
---
## Notes
- **`find` vs `grep`** — `find` locates files by name, glob, or path pattern. `grep` searches _inside_ file contents. Non-overlapping on purpose.
- **`execute_command` classifier**. The tool is `unknown`, so a harness-model classifier labels each command `read-only`, `low-risk`, or `high-risk` and the active tier judges that verdict: `--approval-policy read-only` auto-approves an inspect-only command, an interactive session skips its prompt, yolo skips the classifier entirely. The live zone shows `classifying` while it runs, and the verdict is printed on the settled receipt. It sees the last five _user_ requests (800 characters) on an interactive session and the command alone everywhere else — never the assistant's own turns. Timeouts and ambiguous replies stay `high-risk`. See [Tools & approval](../internals/tools-and-approval.md#command-classifier).
- **`http_request` is `read-only`** by risk classification even though it can issue POSTs. It reaches whatever URL the agent targets; network policy belongs at the firewall, not the tier. Treat it accordingly on surfaces that accept untrusted input.
- **Timeouts** — 3 minutes by default per tool. `ask_user_question` and `ask_file_picker` are `longRunning` and never time out, because waiting for a human is not a hang.
- **Concurrency** — up to 10 tools execute in parallel per iteration.
- **`create_pdf` needs a browser too** — same `puppeteer-core` path as `create_web_app`'s static mode, rendering through `page.pdf()`. It writes to the agent's working directory by default (an explicit `path` overrides), unlike `create_web_app`, whose output lands in Jazz's own data directory because only a bridge ever reads it.
- **`create_web_app` needs a browser for `mode: "static"`** — it screenshots the page through `puppeteer-core`, which deliberately ships no bundled Chrome so that installing Jazz never downloads one. It uses `PUPPETEER_EXECUTABLE_PATH` if set, otherwise an installed Google Chrome; with neither it fails and says so. `mode: "interactive"` needs no browser.
---
## Related
- [Tools & approval](../internals/tools-and-approval.md) — the execution and gating machinery
- [Concepts: tools](../concepts/tools.md) — what a tool is and how to add one
- [CLI Reference](./cli.md) — `--approval-policy` and friends
- [Configuration](./configuration.md) — `customTools`, `envAllowlist`, `autoApprovedCommands`
---
## Workflow frontmatter
Source: https://jazz-cli.vercel.app/docs/reference/workflow-frontmatter.md
# Workflow frontmatter
How to get the `WORKFLOW.md` YAML fields exactly right.
Verified against
[`packages/core/src/workflows/workflow-service.ts`](../../packages/core/src/workflows/workflow-service.ts).
For what a workflow *is*, see [Workflows](../concepts/workflows.md); for ready-made ones, the
[Playbooks](../playbooks/index.md).
---
## Fields
```yaml
---
name: daily-standup-prep
description: Prepare my daily standup notes
schedule: "0 9 * * 1-5"
agent: my-dev-agent
autoApprove: read-only
skills:
- github-action
catchUpOnRestart: true
maxCatchUpAge: 7200
maxIterations: 40
maxCostUSD: 0.20
maxTokens: 200000
maxDurationMs: 1800000
---
```
| Field | Type | Required | Purpose |
| ------------------ | ----------- | -------- | --------------------------------------------------------------------------------- |
| `name` | string | ✅ | Workflow identifier used by every `jazz workflow` command |
| `description` | string | ✅ | One-line summary shown in `jazz workflow list` |
| `agent` | string | — | Agent id or name to run this workflow with. Overridable at runtime with `--agent` |
| `schedule` | cron string | — | When to run. Required only if you intend to `jazz workflow schedule` it |
| `autoApprove` | see below | — | Autonomy tier for unattended runs |
| `skills` | string[] | — | Skills to make available to the agent for this workflow |
| `catchUpOnRestart` | boolean | — | Whether a recent missed run may be replayed after daemon restart |
| `maxCatchUpAge` | seconds | — | Past this age a missed run is skipped. Default 86400 (24 h) |
| `maxIterations` | number | — | Iteration cap for this workflow. Default 100. Overridable with `--max-iterations` |
| `maxCostUSD` | number | — | Spend cap in USD, checked between iterations. Unset = uncapped. Overridable with `--max-cost-usd` |
| `maxTokens` | number | — | Cap on cumulative prompt + completion tokens for this run (not sub-agents), checked between iterations. Unset = uncapped. Overridable with `--max-tokens` |
| `maxDurationMs` | ms | — | Wall-clock budget with 50/80/90% agent pressure nudges. Unset = uncapped. Overridable with `--max-duration-ms` |
`maxCostUSD`, `maxTokens`, and `maxDurationMs` are soft checkpoints, evaluated between
iterations — not preemptive interrupts. See
[Configuration → `maxCostUSD`, `maxTokens`, and `maxDurationMs`](./configuration.md#maxcostusd-maxtokens-and-maxdurationms)
for the full enforcement model and how `maxDurationMs` differs from `--timeout`.
There is **no** `autoApprovedCommands` field in frontmatter — that is a global config setting.
See [the note below](#the-low-risk-trap).
---
## `autoApprove`
Accepts a boolean or a tier string.
| Value | Auto-approves |
| ----------- | ------------------------------------------------------------------------------------------- |
| `false` | Nothing. Not useful for a scheduled run — it will stall on the first gated tool |
| `read-only` | Reads, search, web requests, `git status`/`log`/`diff`/`blame`/`branch` |
| `low-risk` | + `manage_todos`, `spawn_subagent` |
| `high-risk` | + every gated tool: `write_file`, `edit_file`, `rm`, `mv`, `cp`, `mkdir`, `execute_command` |
| `true` | Same as `high-risk` |
Exact per-tool tiers: [Tools reference](./tools.md).
---
## The `low-risk` trap
`low-risk` is narrower than it sounds. In the built-in toolset it adds **three** tools
(`manage_todos`, `update_work_state`, and `spawn_subagent`). It does **not** cover email, calendar, or file writes.
This matters because the capabilities people most want on a schedule are skills that shell
out through `execute_command`, which is `unknown`:
```mermaid
flowchart LR
W["WORKFLOW.md
autoApprove: low-risk"] --> S["email skill"]
S --> C["execute_command
himalaya message move …"]
C --> G{"low-risk covers
unknown?"}
G -->|"no"| D["Declined.
Nothing gets archived."]
classDef bad fill:#c1443c,stroke:#7d2b26,color:#ffffff
class D bad
```
Two ways out, and the second is usually right:
**1. Raise the tier to `high-risk`** — also unlocks `rm`, `git push`, and arbitrary shell.
Rarely what you want on a schedule.
**2. Allowlist the specific binary** and keep the tier low:
```json
// ~/.jazz/config.json
{ "autoApprovedCommands": ["himalaya", "khal"] }
```
Matching is on a parsed key (binary + first subcommand), never a raw prefix — so `himalaya`
is allowed while `himalaya && rm -rf /` is not. See
[Tools & approval](../internals/tools-and-approval.md#two-sharper-controls).
---
## `schedule`
Standard 5-field cron. **macOS caveat:** launchd's `StartCalendarInterval` supports only plain
integers and wildcards — no step values (`*/15`), ranges (`1-5`), or lists (`1,3,5`). Jazz
expands what it can into multiple entries and rejects what it can't with an explicit error
rather than silently scheduling something else.
Neither launchd nor cron fires a job whose slot passed while the machine was asleep — see
[Scheduling](../concepts/scheduling.md) and [Surfaces → Scheduled](../use-cases/scheduled.md).
---
## Where workflows live
Discovered in this order; later overrides earlier on name collision:
1. **Built-in** — shipped with the `jazz-ai` package
2. **Global** — `~/.jazz/workflows//WORKFLOW.md`
3. **Local** — `./workflows//WORKFLOW.md`, scanned up to depth 4 from the cwd
---
## Related
- [Workflows](../concepts/workflows.md) — the concept and the body of the file
- [Playbooks](../playbooks/index.md) — seven complete recipes
- [Surfaces → Scheduled](../use-cases/scheduled.md) — running them unattended
- [CLI Reference](./cli.md#jazz-workflow) — the commands
---
## Internals — how Jazz works
Source: https://jazz-cli.vercel.app/docs/internals.md
# Internals — how Jazz works
This page explains the machine well enough to trust it, debug it, or extend it.
Two different people need this section:
- **Evaluating Jazz?** You want to know whether it survives a forty-minute autonomous run or spirals into a loop and burns $40. Read [Agent loop](./agent-loop.md) and [Context management](./context-management.md).
- **Contributing to Jazz?** You want to know where your code goes. Read [Code map](./code-map.md).
Everyone should read [Design decisions](./design-decisions.md) — the harness choices and
what each one trades away. If you're changing the harness, [Evals](./evals.md) is how you find
out whether it helped.
---
## The 10,000-foot view
```mermaid
flowchart TB
subgraph surfaces["Surfaces"]
direction LR
S1["Terminal"]
S2["jazz run"]
S3["Workflows"]
end
RUNNER["AgentRunner
resolve agent, provider, model,
toolset, conversation"]
subgraph loop["Agent loop — up to 100 iterations"]
direction TB
COMPACT["1 · Compact context if > 80%"]
LLM["2 · Ask the model"]
BRANCH{"3 · Tool calls?"}
TOOLS["4 · Execute tools
(≤10 concurrent, approval-gated)"]
GUARD["5 · Meltdown check
+ budget pressure"]
DONE["Final answer"]
end
FINAL["Finalize
tokens, cost, telemetry,
save transcript"]
S1 --> RUNNER
S2 --> RUNNER
S3 --> RUNNER
RUNNER --> COMPACT
COMPACT --> LLM
LLM --> BRANCH
BRANCH -->|yes| TOOLS
TOOLS --> GUARD
GUARD --> COMPACT
BRANCH -->|no| DONE
DONE --> FINAL
classDef hot fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef core fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class LLM,TOOLS hot
class RUNNER,FINAL core
```
Everything interesting happens in that loop, and the interesting parts are the guards
around it — compaction, meltdown detection, and budget pressure — not the request itself.
---
## The layers
Jazz follows Clean / Hexagonal architecture. The dependency rule is enforced: `core/`
imports nothing from `services/`.
```mermaid
flowchart TB
CLI["cli/
commands · Ink TUI · presentation
user-facing"]
CORE["core/
agent loop · tools · context · types
service contracts (ports)
zero external dependencies"]
SERVICES["services/
LLM adapters · storage · MCP
logger · history · telemetry
implements the ports"]
CLI --> CORE
SERVICES -->|"implements"| CORE
CLI -.->|"wires Layers"| SERVICES
classDef core fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class CORE core
```
`core/` defines an interface plus an Effect `Context.Tag`; `services/` provides a `Layer`
that satisfies it; `cli/` merges the Layers at startup. That's why swapping a storage
backend or adding an LLM provider touches one directory.
Details and conventions: [Code map](./code-map.md).
---
## Pages
| Page | What it covers |
| --- | --- |
| [Agent loop](./agent-loop.md) | Iterations, budget pressure, meltdown detection, tool phase, streaming vs batch, finalization |
| [Context management](./context-management.md) | Two-tier token counting, calibration, turn-aware trimming, 80% compaction, tool-result formatting |
| [Tools & approval](./tools-and-approval.md) | Registry, risk tiers, two-phase execution, concurrency, timeouts, command allowlisting |
| [Sub-agents](./subagents.md) | Context isolation, personas, cost roll-up, when the model reaches for one |
| [Memory](./memory.md) | Per-agent file-backed memory, path-safety choke point, locking, why it's opt-in |
| [Reminders](./reminders.md) | Per-agent scheduled reminders, timezone-aware `when` specs, disk persistence |
| [Agent-to-agent](../concepts/agent-to-agent.md) | Bootstrapping a peer relationship without a shared secret typed by a human: invite records, redemption, the config-upsert semantics |
| [Skills loading](./skills-loading.md) | Progressive disclosure: `find_skills` → `load_skill` → `load_skill_section` |
| [Providers & models](./providers-and-models.md) | The AI SDK port, model catalog, reasoning normalization, retries, cost |
| [Evals](./evals.md) | Measuring whether a harness change helped: Pass^k, A/B, grounding checks, judge calibration |
| [Design decisions](./design-decisions.md) | Every harness choice, and what it trades away |
| [Code map](./code-map.md) | Directory structure, Effect patterns, how to add an adapter, testing |
| [Service template](./service-template.md) | Worked example of adding a service: contract, adapter, wiring, tests |
---
## Key numbers
Useful when reading logs or sizing a deployment. All from
[`packages/core/src/constants/agent.ts`](../../packages/core/src/constants/agent.ts).
| Constant | Value | Meaning |
| --- | --- | --- |
| `DEFAULT_MAX_ITERATIONS` | 80 | Reason→act cycles per run before the loop stops |
| Budget pressure thresholds | 70% / 90% | Where Jazz tells itself to consolidate / finish |
| Compaction threshold | 80% | Of the model's context window, before summarizing |
| `MELTDOWN_WINDOW_SIZE` | 10 | Recent tool calls examined for repetition |
| Meltdown uniqueness floor | 40% | Below this, the run is judged stuck |
| `MAX_CONCURRENT_TOOLS` | 10 | Parallel tool executions per iteration |
| `TOOL_TIMEOUT_MS` | 3 min | Default per-tool timeout |
| Sub-agent timeout | 30 min | Ceiling on one delegated task |
| `DEFAULT_MAX_LLM_RETRIES` | 10 | Retries on transient provider failures |
| `LLM_TIMEOUT_SECONDS` | 900 | Whole completion call, including backoff |
| `MAX_CONVERSATION_HISTORY_PER_AGENT` | 100 | LRU-bounded stored conversations per agent |
| `DEFAULT_MAX_CATCH_UP_AGE_SECONDS` | 24 h | Past this, a missed scheduled run is skipped |
---
## The agent loop
Source: https://jazz-cli.vercel.app/docs/internals/agent-loop.md
# The agent loop
This page explains what happens between "you press enter" and "you get an answer" —
especially on runs that take a long time.
Source: [`packages/core/src/agent/execution/agent-loop.ts`](../../packages/core/src/agent/execution/agent-loop.ts)
An agent run is a bounded loop: ask the model, execute whatever tools it asked for, feed
the results back, repeat until it stops asking for tools. The loop itself is twenty lines.
Everything that makes it survive a long autonomous run is in the guards around it.
---
## One full iteration
```mermaid
flowchart TD
START(["Iteration i of 100"]) --> COMPACT
COMPACT["Compact if needed
tokens > 80% of window?
→ summarize, then resume"]
COMPACT --> PRESSURE["Build budget pressure
just compacted → 'continue the task'
else i/80 ≥ 70% → 'consolidate'
i/80 ≥ 90% → 'finish now'
ephemeral — not stored"]
PRESSURE --> ASK["Ask the model
messages + tools + reasoning effort"]
ASK --> INT{"Interrupted?
(double-Esc)"}
INT -->|yes| STOP(["Break — keep partial output"])
INT -->|no| RECORD["Record usage
tokens, cost,
calibrate token counter"]
RECORD --> APPEND["Append assistant message
content + reasoning + tool_calls"]
APPEND --> TRIM["Trim to the token budget
(turn-aware — never splits
a tool call from its result)"]
TRIM --> BRANCH{"Did the model
request tools?"}
BRANCH -->|"no"| FINAL["Final response
present, notify, exit loop"]
BRANCH -->|"yes"| TOOLPHASE
TOOLPHASE["Tool phase
see below"]
TOOLPHASE --> NEXT(["Iteration i+1"])
classDef guard fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef work fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class COMPACT,PRESSURE,TRIM guard
class ASK,TOOLPHASE work
```
The loop runs at most `DEFAULT_MAX_ITERATIONS` (100) times. It exits early on a final
response (no tool calls) or a user interrupt.
---
## The tool phase
```mermaid
flowchart TD
IN(["Model requested N tool calls"]) --> TRACK["Track each call as
name:arguments
keep a rolling window of 10"]
TRACK --> MELT{"Meltdown?
unique keys / 10 < 40%"}
MELT -->|yes| INJECT["Inject recovery message:
'stop, summarize, try a
different strategy'
reset the window"]
MELT -->|no| EXEC
INJECT --> EXEC
EXEC["Execute
≤10 concurrent, each forked
3-min default timeout
approval-gated per tool"]
EXEC --> VALIDATE{"Every call
got a result?"}
VALIDATE -->|no| FAIL(["Hard fail — this is a bug,
not a recoverable state"])
VALIDATE -->|yes| FORMAT["Format results for context
compress before storing"]
FORMAT --> QUEUE{"User typed
while we worked?"}
QUEUE -->|yes| PUSH["Append their message —
steer mid-run"]
QUEUE -->|no| OUT
PUSH --> OUT(["Next iteration"])
classDef guard fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef bad fill:#c1443c,stroke:#7d2b26,color:#ffffff
class MELT,INJECT,FORMAT guard
class FAIL bad
```
### Missing results are a hard failure
If any requested tool call comes back without a result, the run fails loudly. It would be
easy to paste in a placeholder and continue — but a message history where a `tool_calls`
entry has no matching `tool` message is invalid to most providers, and the resulting error
appears three iterations later somewhere unrelated. Failing at the source keeps the bug
findable.
### Mid-run steering
Tool execution is slow, and users type while it happens. Anything queued during the tool
phase is appended as a user message before the next iteration, so you can redirect a long
run without killing it.
---
## Guard 1 — budget pressure
An agent with 100 iterations and no sense of time will happily spend all 100 on research and
produce nothing. So Jazz tells it where it stands:
```mermaid
timeline
title Iteration budget (100 iterations)
section 1–69 · Free rein
No pressure : explore, research, spawn sub-agents
section 70–89 · 70% warning
"Begin consolidating results. Stop spawning new research subagents." : agent shifts to synthesis
section 90–100 · 90% critical
"Write your final output NOW. Use what you have collected so far." : agent lands the plane
```
The important detail: **the pressure message is ephemeral.** It's appended to the array
sent to the model for that one call and never pushed into the stored conversation:
```ts
const budgetMsg = buildBudgetPressureMessage(iterationIndex + 1, maxIterations);
const messagesForLLM = budgetMsg ? [...state.currentMessages, budgetMsg] : state.currentMessages;
```
If it were stored, iteration 98 would carry eight escalating "FINISH NOW" messages, each
one costing tokens and confusing the transcript that later gets summarized. This way the
nudge steers the run without polluting its history.
### Sibling guards — cost, tokens, and wall-clock time
Three more budgets check in the same place, right after each iteration's tool phase
finishes, and are configured via `maxCostUSD`, `maxTokens`, and `maxDurationMs` (see
[Configuration reference](../reference/configuration.md#maxcostusd-maxtokens-and-maxdurationms)):
- **`maxCostUSD`** re-prices the run's accumulated tokens (plus any sub-agent spend rolled up
via `childCostUSD`) against the model's models.dev pricing. Skipped entirely when pricing is
unknown — it never guess-aborts a run it cannot verify the spend of.
- **`maxTokens`** just sums `totalPromptTokens + totalCompletionTokens`. No pricing lookup, so
it still enforces on a local/unpriced model where `maxCostUSD` cannot.
- **`maxDurationMs`** compares wall-clock elapsed time (`Date.now() - runMetrics.startedAt`)
against the budget. Unlike the other two, it also gets an ephemeral pressure message inside
`runIteration` itself — `buildTimeBudgetPressureMessage` — at 50%, 80%, and 90% elapsed,
mirroring the iteration-budget nudge above.
All three share the same timing as the iteration budget: checked *between* iterations, not a
preemptive interrupt. A single expensive iteration — a costly tool call, or a sub-agent
delegation that itself runs for a while — can push the total past the cap before the next
check trips. A run stopped this way reports which cap fired on the response:
`costCapped` / `tokenCapped` / `durationCapped`.
This is deliberately different from `--timeout`, which lives outside the loop entirely — the
CLI races the whole run against a deadline (`packages/core/src/utils/run-deadline.ts`) and
kills it with no warning to the agent. `--timeout` is the hard outer safety net;
`maxDurationMs` is the warned, graceful budget.
---
## Guard 2 — meltdown detection
The classic agent failure isn't a crash, it's a groove: the same search, the same file
read, forever, until the budget is gone.
```ts
const keys = window.map((tc) => `${tc.name}:${tc.arguments}`);
const uniqueness = new Set(keys).size / windowSize;
return uniqueness < 0.4;
```
The key is composite — **tool name *plus* arguments**. This distinction is the whole
design:
| Recent calls | Unique keys | Verdict |
| --------------------------------------------------------- | ------------ | ---------------------------------------- |
| `web_search("effect-ts")` × 10 | 1/10 = 10% | 🔴 Stuck — same query over and over |
| `web_search(q1)` → `web_fetch(u1)` → `web_search(q2)` → … | 10/10 = 100% | 🟢 Productive — that's research |
| `read_file(a)` `read_file(b)` … 10 distinct files | 10/10 = 100% | 🟢 Productive — that's reading a codebase |
| `execute_command()` × 6, `read_file(x)` × 4 | 2/10 = 20% | 🔴 Stuck |
Keying on tool *name* alone would flag the second and third rows as meltdowns, which are
exactly the behaviors you want. When a meltdown does trip, Jazz injects a message telling
the agent to stop, summarize what it has, and either output or try a fundamentally
different approach — then clears the window so it gets a fair chance.
Unlike budget pressure, this message **is** stored. It's a real event in the run's history
and the agent should keep remembering that its last approach didn't work.
---
## Streaming vs batch
The loop is shared. Only how it talks to the model and renders output differs, expressed
as a `CompletionStrategy`:
| | Streaming | Batch |
| ------------------ | ------------------------------------- | ---------------------------- |
| Model call | token-by-token stream | single response |
| Rendering | live, as tokens arrive | markdown rendered at the end |
| Thinking indicator | yes | no |
| Interruptible | yes — double-Esc races tool execution | no |
| Used by | terminal TUI, `--events` bridges | `jazz run` piped output, CI |
Adding a third mode means implementing one interface, not forking the loop. That's the
reason for the indirection.
---
## Finalization
After the loop, win or lose:
```mermaid
flowchart LR
EXIT(["Loop exits"]) --> WHY{"How?"}
WHY -->|"final answer"| OK["Normal"]
WHY -->|"hit 100 iterations"| LIMIT["Warn: iteration limit"]
WHY -->|"empty response"| EMPTY["Warn: empty response"]
WHY -->|"interrupted"| INT["Keep partial output"]
OK --> METRICS
LIMIT --> METRICS
EMPTY --> METRICS
INT --> METRICS
METRICS["Write metrics
forked fiber, awaited on release —
never blocks your answer,
never lost on exit"]
METRICS --> COST["Cost
own tokens × model price
+ sub-agent cost"]
COST --> RESP(["AgentResponse
content · usage · costUSD · messages"])
classDef core fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class METRICS,COST core
```
Two details worth noting:
- **Metrics are written on a forked fiber** that the loop's `release` step awaits. You get your answer immediately, and the telemetry still lands even if the process is shutting down.
- **Cost includes sub-agent spend.** A run whose own tokens are unpriced (a local model) but which spawned a priced sub-agent still reports a figure — otherwise the number would silently understate what you spent.
---
## Reading a run in the logs
| Log line | Means |
| ----------------------------------------------- | -------------------------------------------------------------------------- |
| `Sending LLM request` | Top of an iteration — includes iteration number, message count, tool count |
| `Agent decided to use tools` | Tool phase starting, with the chosen tool names |
| `Meltdown detected — injecting recovery signal` | Guard 2 fired; the agent was looping |
| `Compacting context` | Crossed 80% of the window; a summary is being produced |
| `Tool timeout: ` | A tool exceeded its timeout; returned as a failed result, not a crash |
| `Agent provided final response` | Loop exiting normally |
| `Missing tool results for some tool calls` | Bug — please open an issue with the log |
---
## Related
- [Context management](./context-management.md) — what "compact" and "trim" actually do
- [Tools & approval](./tools-and-approval.md) — the execution and gating detail
- [Sub-agents](./subagents.md) — what the loop does when the agent delegates
- [Design decisions](./design-decisions.md) — why these guards and not others
---
## Code map
Source: https://jazz-cli.vercel.app/docs/internals/code-map.md
# Code map
This page helps you find where your change goes, and follow the conventions already there.
This is the contributor-facing counterpart to the rest of [Internals](./index.md): where
the code lives and how it's wired, rather than what the harness does at runtime.
---
## Core Principles
Jazz is a Bun workspace under `packages/*`. The dependency rule below is not just documented
convention — it's structurally enforced by TypeScript project references, so `tsc -b` rejects a
package importing from one it doesn't declare a reference to.
- **`packages/core/`** contains the domain, contracts (interfaces and types), and business logic.
- No imports from `packages/adapters/` or `packages/cli/` allowed in core, except in tests.
- Contracts are expressed as interfaces + Context tags (e.g., `AgentConfigServiceTag`).
- Publishable standalone as `@jazz/core` — no workspace dependencies of its own.
- **`packages/adapters/`** implements adapters (database, LLM providers, Gmail, file system, logger, etc.).
- Adapters provide Layers that satisfy the tags declared in `core/interfaces`.
- Depends on `core` only.
- **`packages/cli/`** contains user-facing command implementations, Ink/OpenTUI presentation,
and the terminal-rendering `TerminalService` implementation.
- Depends on `core` only.
- **`packages/runtime/`** is the composition root — wires core, adapters, and cli into the
Effect Layer graph that becomes the `jazz` binary.
- Depends on `core`, `adapters`, and `cli`.
- **`packages/bot-shared/`**, **`packages/telegram-bot/`**, **`packages/discord-bot/`** are the
chat-bridge integrations, each depending on `core`, `adapters`, and `bot-shared`.
## The dependency rule
```mermaid
flowchart TB
CLI["packages/cli/
commands · Ink TUI · presentation · TerminalService"]
CORE["packages/core/
agent loop · tools · context · types
interfaces = ports
imports nothing outward"]
ADP["packages/adapters/
llm · storage · mcp · history
logger · telemetry · notification"]
RT["packages/runtime/
composition root · jazz binary"]
CLI -->|"calls"| CORE
ADP -->|"implements ports"| CORE
RT -.->|"merges Layers at startup"| CLI
RT -.->|"merges Layers at startup"| ADP
RT -.->|"merges Layers at startup"| CORE
NO["core/ → adapters/ or cli/
never (except in tests)"]
classDef core fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef forbidden fill:#c1443c,stroke:#7d2b26,color:#ffffff
class CORE core
class NO forbidden
```
Arrows point inward, always. That's what lets you swap a storage backend or add an LLM
provider by touching one package, and test the agent loop with plain mocks.
Adding a capability follows the arrows in reverse:
```mermaid
flowchart LR
A["1 · Define the port
packages/core/src/interfaces/foo.ts
interface + Context.GenericTag"]
B["2 · Implement the adapter
packages/adapters/src/foo.ts
+ a Layer"]
C["3 · Register the Layer
packages/runtime/src/app-layer.ts"]
D["4 · Test with a mock Layer
Layer.succeed(FooTag, fake)"]
A --> B --> C --> D
classDef step fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class A,B,C,D step
```
---
## Directory Structure
```text
packages/
├── cli/src/ # @jazz/cli — user-facing CLI
│ ├── commands/ # Command implementations (chat, agent, config)
│ ├── presentation/ # Output formatting (markdown, CLI renderer)
│ ├── chat-service.ts # Chat orchestrator (UI-touching; lives here, not adapters)
│ ├── chat/ # Chat service modules
│ │ ├── commands/ # Slash command handling
│ │ │ ├── parser.ts # Parse /help, /new, etc.
│ │ │ └── handler.ts # Execute commands
│ │ └── session/ # Session management
│ │ ├── manager.ts # ID generation, logging
│ │ └── agent-setup.ts # MCP connection setup
│ ├── terminal.ts # TerminalService implementation (Ink/OpenTUI rendering)
│ └── ui/ # Ink React components
│ ├── App.tsx # Main app with store pattern
│ ├── ErrorBoundary.tsx # Error boundary for graceful failures
│ ├── LineInput.tsx # Readline-style input component
│ └── text-utils.ts # Word boundary utilities
│
├── core/src/ # @jazz/core — domain and contracts
│ ├── agent/ # Agent execution engine
│ │ ├── agent-runner.ts # Orchestrator (delegates to executors)
│ │ ├── types.ts # Shared types (AgentRunnerOptions, etc.)
│ │ ├── context/ # Context management
│ │ │ └── summarizer.ts # Auto-summarization for context window
│ │ ├── execution/ # LLM execution strategies
│ │ │ ├── streaming-executor.ts # Real-time streaming
│ │ │ └── batch-executor.ts # Non-streaming execution
│ │ ├── prompts/ # System prompts by agent type
│ │ └── tools/ # Tool implementations
│ │ ├── fs/ # Filesystem tools (read, write, grep, etc.)
│ │ ├── command-risk.ts # execute_command risk classifier (LLM)
│ │ ├── tool-categories.ts # Builtin category ids + mappings
│ │ ├── register-tools.ts # Builtin tool registration
│ │ └── register-mcp-tools.ts # Per-agent MCP connect + register
│ ├── interfaces/ # Service contracts (Tag + Interface)
│ ├── types/ # Domain types
│ └── utils/ # Shared utilities
│
├── adapters/src/ # @jazz/adapters — adapter implementations
│ ├── llm/ # LLM provider adapters
│ ├── mcp/ # MCP client + OAuth
│ ├── peers/ # ask_peer ledger/token adapters
│ └── storage/ # Persistence (JSON file storage)
│
├── runtime/src/ # @jazz/runtime — composition root
│ ├── entry.ts # Binary entrypoint
│ ├── cli-app.ts # Commander.js program, command registration
│ └── app-layer.ts # Effect Layer composition
│
├── bot-shared/src/ # @jazz/bot-shared — shared bridge helpers
├── telegram-bot/src/ # Telegram bridge
└── discord-bot/src/ # Discord bridge
```
---
## Key modules
### Agent Runner (`packages/core/src/agent/`)
The agent runner is split into focused modules; runtime behavior is documented in
[Agent loop](./agent-loop.md) and [Context management](./context-management.md).
| Module | Purpose |
| --------------------------------- | ---------------------------------------------------------------------- |
| `agent-runner.ts` | Orchestrator - delegates to executors |
| `types.ts` | Shared types: `AgentRunnerOptions`, `AgentResponse`, `AgentRunContext` |
| `context/summarizer.ts` | Auto-compaction when context approaches token limit |
| `execution/streaming-executor.ts` | Real-time LLM streaming with tool calls |
| `execution/batch-executor.ts` | Non-streaming execution with retry logic |
**Dependency Injection Pattern**: To avoid circular dependencies, the `summarizer.ts` accepts a `RecursiveRunner` function parameter instead of importing `AgentRunner` directly.
### Chat Service (`packages/cli/src/chat/`)
The chat service is split into focused modules:
| Module | Purpose |
| ------------------------ | --------------------------------------- |
| `chat-service.ts` | Session orchestrator |
| `commands/parser.ts` | Parse slash commands from user input |
| `commands/handler.ts` | Execute individual commands |
| `commands/types.ts` | `SpecialCommand`, `CommandResult` types |
| `session/manager.ts` | Session ID generation, logging |
| `session/agent-setup.ts` | MCP server connections before chat |
---
## Common Conventions
### Service Contracts
A service contract is an interface + a Context tag, defined under `packages/core/src/interfaces/`.
```typescript
// packages/core/src/interfaces/agent-config.ts
export interface AgentConfigService {
getConfig(): Effect.Effect;
}
export const AgentConfigServiceTag = Context.GenericTag("AgentConfigService");
```
### Using Services in Effect
```typescript
const config = yield * AgentConfigServiceTag;
const value = yield * config.getConfig();
```
### Providing Layers
```typescript
Layer.effect(AgentConfigServiceTag, Effect.succeed(new ConfigServiceImpl(...)))
```
---
## How to Add a New Adapter/Service
1. Add the contract to `packages/core/src/interfaces/` (interface + Tag).
2. Implement the adapter in `packages/adapters/src/` and create a Layer.
3. Add registration in [`packages/runtime/src/app-layer.ts`](../../packages/runtime/src/app-layer.ts) by merging the new Layer.
4. Add tests with a mock Layer.
---
## Testing Patterns
### Pure Function Tests
For utilities like `parseSpecialCommand` or `generateSessionId`:
```typescript
import { describe, expect, it } from "bun:test";
import { parseSpecialCommand } from "./parser";
describe("parseSpecialCommand", () => {
it("should parse /help command", () => {
const result = parseSpecialCommand("/help");
expect(result.type).toBe("help");
});
});
```
### Effect Tests with Mocked Layers
```typescript
const mockLogger: LoggerService = {
debug: () => Effect.void,
info: () => Effect.void,
// ...
};
const testLayer = Layer.succeed(LoggerServiceTag, mockLogger);
const result = await Effect.runPromise(myEffect.pipe(Effect.provide(testLayer)));
```
---
## UI Architecture
`@jazz/cli` uses [Ink](https://github.com/vadimdemedes/ink) (React for terminals) with a dual-pattern state management:
1. **External Store (`store` object)**: Imperative access for Effect-based services
2. **React Context (`AppContext`)**: Reactive state for components
The `ErrorBoundary` component wraps the app to catch rendering errors gracefully.
---
## Why This Structure
- **Separates policy (core) from mechanics (adapters)** — makes it easy to:
- Swap LLM providers
- Substitute storage backends
- Test core logic with deterministic mocks
- **Good for open-source**: Contributors can implement providers/adapters without changing core logic.
---
## Troubleshooting
- **Missing tag at runtime**: Ensure the Layer providing that tag is included in `createAppLayer`.
- **Circular dependency**: Use dependency injection (pass functions as parameters) instead of direct imports.
- **Context overflow**: The `Summarizer` automatically compacts context when tokens approach 80% of the limit.
---
## Context management
Source: https://jazz-cli.vercel.app/docs/internals/context-management.md
# Context management
This page explains why a long Jazz run doesn't fall off the end of its context
window.
Source:
[`context/summarizer.ts`](../../packages/core/src/agent/context/summarizer.ts) ·
[`context/context-window-manager.ts`](../../packages/core/src/agent/context/context-window-manager.ts) ·
[`context/token-counter.ts`](../../packages/core/src/agent/context/token-counter.ts)
Context is the scarce resource in an agent run. Tool results are large, they accumulate
every iteration, and running out means either a provider error or silently forgetting the
task. Jazz manages it with three mechanisms that do different jobs and are easy to
confuse.
---
## Three mechanisms, three jobs
```mermaid
flowchart TB
subgraph counting["1 · Counting — how full are we?"]
TC["Token counter
estimate before the call,
calibrate after it"]
end
subgraph trimming["2 · Trimming — cheap, every iteration"]
TR["Drop the oldest messages
that fit no budget.
No LLM call. Lossy."]
end
subgraph compaction["3 · Compaction — expensive, at 80%"]
CO["Summarize the middle,
keep system + recent.
One LLM call. Lossy but coherent."]
end
TC --> TR
TC --> CO
classDef cheap fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef pricey fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
class TC,TR cheap
class CO pricey
```
| | Trimming | Compaction |
| ----------- | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| Runs | after appending the assistant message, once tokens exceed **95%** of the context budget | when tokens exceed 80% of the context budget (the model's window, or the agent's `maxContextTokens` ceiling when it is lower) |
| Costs | nothing | one LLM call |
| Budget | a fixed working-set target (50k tokens by default) | the context window the provider will actually honour |
| What's lost | old messages, entirely | detail — the gist survives as a summary |
| Preserves | system message + last N complete turns | system message + a summary + recent messages |
**Trimming sits above compaction, deliberately.** Its budget is 95% of the context budget,
compaction's is 80%, so compaction always gets first refusal and trimming only fires when
summarizing could not bring the run under budget — a single tool result too large to
summarize around, for example. When it does fire you are told, because messages are being
discarded without being summarized.
This ordering used to be inverted. The trim budget was a flat 50,000 tokens regardless of
the model, so on any window larger than ~62k (50k ÷ 0.8) trimming pre-empted compaction
entirely: history was held at 50k by discarding the oldest turns, the 80% threshold was
never reached, and the summarizer never ran. The run degraded into exactly the sliding
window this design exists to avoid — and, because trimming rewrites the start of the
message list, it also invalidated the provider's cacheable prefix on every single turn.
Trimming keeps the working set tidy. Compaction is what saves a run that genuinely has more
history than fits.
---
## 1 · Counting tokens
You can't decide whether to compact without knowing how full you are, and every provider
tokenizes differently. Jazz uses two tiers.
```mermaid
sequenceDiagram
autonumber
participant L as Agent loop
participant C as Token counter
participant M as Model
Note over C: Tier 2 — estimate
L->>C: countMessages(messages, {provider, modelId})
alt OpenAI family
C-->>L: exact count (gpt-tokenizer, cl100k / o200k)
else everything else
C-->>L: chars ÷ calibrated ratio
(seed: Claude 3.5, Gemini 4.0, Llama 3.6 …)
end
L->>M: completion request
M-->>L: response + usage.promptTokens
Note over C: Tier 1 — ground truth
L->>C: calibrate(authoritative = usage.promptTokens)
Note over C: learn this model's real chars/token
smoothing 0.7, clamped to [2, 6]
```
**Tier 1 — authoritative calibration.** After every call the provider reports
`usage.promptTokens`: its own count of exactly what we sent. Jazz feeds that back and the
counter learns a per-model chars-per-token ratio. Ground truth, free, one round trip.
**Tier 2 — pre-call estimate.** Before the *next* call, we need a number to compare against
the threshold. OpenAI-family encodings use `gpt-tokenizer` for an exact count. Everything
else uses the calibrated ratio if we have one, or a family seed if we don't.
Why no Anthropic tokenizer: `@anthropic-ai/tokenizer` is stale (Claude-2 era) and the
official `count_tokens` endpoint is a network call on the hot path. Calibration converges
after one exchange and costs nothing.
Per-message overheads are counted too — 4 tokens for role tags and separators, plus 10 more
for tool-result messages, which are numerous enough that ignoring their framing drifts the
estimate.
---
## 2 · Trimming: turn-aware, never mid-tool-call
Trimming runs every iteration against a fixed token budget. The subtlety isn't *what* to
drop, it's what must never be split.
```mermaid
flowchart TB
subgraph before["Before trim"]
direction TB
B0["0 · system"]
B1["1 · user: 'audit the repo'"]
B2["2 · assistant + tool_calls"]
B3["3 · tool result (large)"]
B4["4 · user: 'also check tests'"]
B5["5 · assistant + tool_calls"]
B6["6 · tool result"]
B7["7 · user: 'summarize'"]
B8["8 · assistant"]
end
subgraph after["After trim"]
direction TB
A0["0 · system — always kept"]
A4["4–8 · last N complete turns
protected zone"]
A2["2–3 kept only if they fit
— and only together"]
end
before --> after
classDef keep fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef maybe fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
class A0,A4 keep
class A2 maybe
```
The algorithm:
1. **System message is index 0 and always survives.** It carries the agent's identity and rules.
2. **Identify the protected zone** — the last N complete *turns*, scanning backwards for user messages (default 3). A "turn" is a user message plus every assistant and tool message after it until the next user message. Complete interaction cycles, not a raw message count.
3. **Walk backwards** from just before the protected zone, keeping messages while they fit the budget.
4. **Validate tool integrity.** An assistant message with `tool_calls` and its corresponding `tool` result messages are kept or dropped as a unit.
Step 4 is the one that matters. A history containing `tool_calls` with no matching `tool`
message is *invalid* to most providers — you get a 400 several iterations later, far from
the cause. Turn-awareness makes that structurally impossible rather than something to
remember.
---
## 3 · Compaction: summarize, don't truncate
At 80% of the model's real context window, Jazz stops discarding and starts summarizing.
```mermaid
flowchart LR
subgraph in["Before — 92% full"]
direction TB
S["system"]
MID["… 60 messages of
research, tool results,
dead ends …"]
REC["recent messages"]
end
SUM["Summarizer sub-agent
own model (configurable)
own context"]
subgraph out["After — 30% full"]
direction TB
S2["system"]
SUMMSG["summary
one assistant message:
what was found, what's left"]
REC2["recent messages
(sanitized)"]
end
MID --> SUM
SUM --> SUMMSG
S --> S2
REC --> REC2
classDef hot fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
class SUM,SUMMSG hot
```
The rebuild is literally `[system, summary, ...recentMessages]`. The middle — where the
bulk of the tokens live — becomes one message describing what was learned.
**Why summarize rather than slide a window?** A sliding window drops the *plan*. Forty
minutes into a research run, the early messages contain the task definition and the
strategy; the recent ones contain a tool result about page 14 of a PDF. Truncating keeps
the trivia and throws away the point. Summarizing keeps the point.
**The cost, honestly stated:** compaction is an extra LLM call, it adds latency mid-run, and
a summary is lossy — a detail the agent needed might not survive. Mitigations:
- **`summarizerModel` is configurable per agent.** Point compaction at a cheap fast model while the main agent runs an expensive one. Falls back to the agent's own model, with a warning if the configured value is unparseable.
- **It's visible.** You get a `Context window ~80% full — auto-compacting…` warning, then `Compacted 64 → 12 messages (saved ~48000 tokens)`. Never silent.
- **You can force it.** `/compact` in chat, or the `summarize_context` tool, which the agent can call itself when it knows it's about to go deep.
- **It's skipped when pointless.** If there's nothing in the middle worth summarizing, the messages come back untouched.
Window size comes from the model catalog (models.dev), falling back to 128k when unknown —
so the threshold tracks the actual model rather than a guess.
**Local providers are the exception, and getting this wrong is the worst failure mode there
is.** A cloud provider honours the window its catalog advertises. Ollama does not: it loads
the model with whatever `num_ctx` the request carries, or with the server's
`OLLAMA_CONTEXT_LENGTH` default — `qwen3.6:27b` advertises 262144 tokens and is routinely
served at 131072 or less. Accounting against the advertised number means Jazz compacts long
after the server has started dropping the middle of the conversation, and the agent keeps
answering from a context it no longer has.
So for `ollama` and `llamacpp` the threshold is taken from the agent's pinned `numCtx` when
it has one (that value overrides the server default for the request, so it *is* the runtime
window), and from the window the local server reported otherwise — llama-server's `/props`
gives its `-c` value directly. An unpinned Ollama agent gets a warning at run start rather
than a silent assumption, because Ollama exposes a loaded model's window on `/api/ps` but
has no endpoint for the server default before anything is loaded.
The catalog is no help here at all: models.dev carries no `ollama` or `llamacpp` provider,
so a local model resolves to the 128k unknown-model placeholder rather than to a real
maximum. That placeholder is never treated as a ceiling — a pinned window above it is
honoured, because the user pinned it and configured the server to serve it. Only a
*genuinely known* maximum caps a runtime window.
### The per-agent ceiling
`config.maxContextTokens` caps the window for *any* provider. It is the answer to "this
agent should never carry more than 60k tokens of history, even though the model would hold
200k" — useful for keeping cost and latency predictable, for models whose quality sags long
before their advertised limit, and for staying under a provider tier's real limit.
The ceiling only ever lowers the window: `min(runtime window, maxContextTokens)`. Asking for
more than the server will honour is ignored, because that is exactly the silent-truncation
failure above. Everything downstream then follows the capped number — the warning, the
compaction threshold, the summarizer's recent-message budget, and `/context`.
Set it with `jazz agent edit` → **Max Context Tokens**; leave the prompt blank to remove the
ceiling and go back to the model's own window.
### The ladder, cheapest rung first
Four mechanisms share one budget, escalating by cost. Clearing no longer waits
on a window-fill percentage: it runs **every iteration**.
| Rung | Fires at | Costs | Effect |
| ------------------ | -------------------------------- | ------------------ | ----------------------------------------------------------------------------------------------- |
| Clear tool results | every iteration | nothing | Live tool cycle stays verbatim. Older large results become a pointer (or a re-run stub). |
| Warn | 70% | nothing | User *and* agent are told; the agent is nudged to consolidate |
| Compact | 80% | one LLM call | Older history summarized into the running summary |
| Trim | 95% | nothing, but lossy | Messages dropped unsummarized — the floor, not the path |
Clearing is free, so it runs first, every turn. Each result is rewritten at most
once (`cleared` sticks), so the prompt-cache prefix only jumps when a result
actually ages out of the live cycle.
Before stubbing, Jazz tries to write the original body under
`~/.jazz/work///tool-results/.txt`. The
placeholder then names `retrieve_tool_result`. If the write fails — read-only
CI images, locked-down containers, a Telegram host that can read but not write —
the run continues and the placeholder says to re-run the original tool. Missing
retrieves fail the same way. The conversation never depends on a writable disk.
The live cycle is the last assistant message that still has `tool_calls`,
through the end of the list: those results have not been shown to the model yet
(or are the ones it is about to use). A later assistant message without tool
calls means that cycle was already consumed, and the bodies can go.
Tool results are cleared by replacing content and keeping the message, so the
assistant/tool pairing survives. Deleting the message would orphan the `tool_calls` that
referenced it and provoke a provider error.
### What the budget counts
Tokens are messages **plus per-request overhead** — tool schemas and provider scaffolding.
MCP server schemas no longer count toward this by default: they're a `deferred`-tier category
(see [Design decisions](./design-decisions.md#deferred-tool-schemas)), so only the always-on
tool set's schemas are in the request until `search_tools` fetches one. Overhead is measured,
not estimated: after each call, `promptTokens − estimatedMessageTokens` is the gap, smoothed
per model. Counting messages alone meant an agent believed it was at 79% when it was at 102%.
### Warn first, compact second
Two thresholds share one budget, both defined in `context-window-manager.ts`:
| | Warning | Compaction |
| -------- | -------------------------------------------------------------------------- | --------------------------------------- |
| Fires at | 70% of the budget (`CONTEXT_WARN_THRESHOLD_RATIO`) | 80% (`CONTEXT_COMPACT_THRESHOLD_RATIO`) |
| Costs | nothing | an extra LLM call |
| Effect | `context 74% full of 60,000 tokens — will auto-compact soon`, once per run | history is summarized |
Both the user *and the agent* are told. Past 70% the request carries an ephemeral
`[CONTEXT WARNING: …]` line telling the model to record what it needs and consolidate
rather than gather more; past 90% a `[CONTEXT CRITICAL: …]` line telling it to write its
output now. This mirrors the iteration-budget nudge in `buildBudgetPressureMessage`, and the
two are merged into one appended message when both fire.
**After a successful compaction the wrap-up nudges are replaced.** The history has just
been rewritten and space was freed so the original task can continue — telling the model
to "write your final output NOW" at that moment is the opposite of what happened. The
request instead carries `[CONTEXT COMPACTED: …]` asking it to resume from the summary
until the user's request is fully complete. If usage is still above 90% after the
summary, the same message notes that context is still tight, but it does not tell the
model to stop.
The nudge is appended to the outgoing request only — never pushed into `currentMessages`.
A persisted warning would cost tokens exactly when they are scarce, be re-sent every turn,
and eventually be summarized into the very compaction it was warning about.
The warning exists so that compaction is never a surprise: there is a window where you can
still `/compact` on your own terms, narrow the task, or raise the ceiling before the
summarizer decides what to keep. `ContextWindowManager` owns both decisions — `usage()`
returns the current tokens, the budget, and both flags from a single count.
---
## Working state outlives the context window
Compaction is lossy by design, so the things that must not be lost are written outside the
conversation, under `~/.jazz/work///`:
- **`journal.jsonl`** — every compaction appends its summary here *before* it enters
context. No extra LLM call and no extra tokens: it persists something already paid for.
Append-only, one JSON object per line, so a crash damages at most the final record.
- **`state.json`** — the agent's own record of where the work stands, written through
`update_work_state`. Patched field by field, so a small correction cannot drop the rest.
This is deliberately **not** memory. Memory holds what stays true about a person or project
for weeks, and its own instructions tell the agent not to store one-off task details —
which is exactly what compaction destroys. "They prefer Bun over npm" is memory; "3 of 5
routes migrated, auth fails on token refresh" is work state, and it is discarded when the
work ends.
Two things read it back:
- **Resuming a conversation** loads post-compaction messages, so whatever compaction
dropped is simply absent. The journal is folded back in as a bounded (~2k token)
preamble, framed as claims to verify rather than fact — progress records are written
mid-task and are habitually optimistic about what was finished.
- **Compaction itself** is told what work state already holds, so the summary covers what
the transcript adds instead of restating the plan.
Todos carry a `verifiedBy` field alongside their status, and the prompt asks for it
whenever something is marked completed. An agent that marks its own work complete on the
strength of having written it turns the record into a confident lie for whoever picks the
work up next; a completed todo with nothing in `verifiedBy` says plainly that nobody
checked. Work state used to keep a second, parallel list of the same work under a
different vocabulary — the idea survived, the duplicate list did not.
Inspect or discard it with `/work` and `/work clear`. Journals are capped per conversation
and pruned oldest-first, since the newest record is the one describing where the task is.
---
## Tool results are reformatted before storage
The largest single lever on context in a long run isn't the conversation — it's tool
output. Every tool result passes through `formatToolResultForContext` before it's appended,
which shapes it for a model reader rather than dumping a raw payload.
Result sizes are also recorded per tool name in the run metrics, so `/context` can show you
which tool is actually eating your window. Usually it's one, and usually it's a surprise.
---
## Inspecting it live
| Command | Shows |
| ---------- | ------------------------------------------------------ |
| `/context` | Current tokens, window size, and the biggest consumers |
| `/compact` | Force compaction now |
| `/cost` | Tokens and USD for this session, including sub-agents |
And in the logs: `Conversation context approaching limit`, `Context compacted successfully`
(with tokens saved), and trim decisions at debug level.
---
## Related
- [Agent loop](./agent-loop.md) — where compaction and trimming sit in an iteration
- [Sub-agents](./subagents.md) — the other way to keep the parent's context small
- [Design decisions](./design-decisions.md) — the trade-offs behind these choices
---
## Design decisions
Source: https://jazz-cli.vercel.app/docs/internals/design-decisions.md
# Design decisions
This page explains _why_ the harness is built this way — and what each choice
gives up.
Every decision below is a real trade-off, not a free win. This page states the alternative
that was rejected and the cost that was accepted, because a harness is only trustworthy if
you can see where its edges are.
---
## Map
```mermaid
mindmap
root(("Jazz
harness"))
("Keeping runs alive")
("Ephemeral budget pressure")
("Meltdown on tool diversity")
("Compaction, not truncation")
("Turn-aware trimming")
("Staying honest about cost")
("Two-tier token counting")
("Tool results reformatted")
("Tool results offloaded")
("Conversation prefix cached")
("Sub-agent cost roll-up")
("Running anywhere")
("stdout = payload")
("Risk tiers, one dial")
("Two-phase approval")
("Not locking you in")
("AI SDK as provider port")
("Catalog + on-disk snapshot")
("Effect-TS core")
```
---
## Keeping long runs alive
### Iteration budget with ephemeral pressure injection
**Decision.** 100 iterations per run. At 70% Jazz injects "begin consolidating"; at 90%,
"write your final output now". The message is appended to the request array for that one
call and never stored.
**Alternatives rejected.** A hard cap with no signalling — the agent gets cut off
mid-thought and you get nothing. Storing the warnings — by iteration 78 the history carries
eight escalating FINISH NOW messages that cost tokens, contradict each other, and poison the
next summarization.
**Cost accepted.** The agent can't see the pressure history, so it can't reason about "I've
been told this twice". In practice escalating tiers cover that.
📄 [`agent-loop.ts:40`](../../packages/core/src/agent/execution/agent-loop.ts#L40) · [Agent loop](./agent-loop.md#guard-1--budget-pressure)
### Meltdown detection keyed on name + arguments
**Decision.** Over the last 10 tool calls, if unique `name:arguments` keys fall below 40%,
inject a recovery message and reset the window.
**Alternatives rejected.** Counting repeats of the tool _name_ — that flags
`web_search(q1) → web_fetch(u1) → web_search(q2)` as a meltdown, which is what research
looks like, and flags reading ten files in a row, which is what understanding a codebase
looks like. Both are the behaviors you're trying to encourage.
**Cost accepted.** An agent that loops with _slightly_ varied arguments — appending a
counter to the same query — slips through. Catching that needs semantic similarity, which
costs a model call per check.
📄 [`agent-loop.ts:101`](../../packages/core/src/agent/execution/agent-loop.ts#L101) · [Agent loop](./agent-loop.md#guard-2--meltdown-detection)
### Compaction at 80%, not truncation
**Decision.** When tokens pass 80% of the model's window, summarize the middle of the
conversation into one message and rebuild as `[system, summary, ...recent]`.
**Alternatives rejected.** A sliding window — it drops the oldest messages, which is where
the task definition and the plan live. Forty minutes in you keep a tool result about page 14
of a PDF and lose the reason you were reading it.
**Cost accepted.** An extra LLM call, added mid-run latency, and genuine information loss.
Mitigated by a configurable `summarizerModel` (point it at something cheap), by making it
visible rather than silent, and by letting the agent trigger it deliberately via
`summarize_context`.
📄 [`summarizer.ts:222`](../../packages/core/src/agent/context/summarizer.ts#L222) · [Context management](./context-management.md#3--compaction-summarize-dont-truncate)
### Turn-aware trimming
**Decision.** Trimming protects the system message plus the last N _complete turns_, and
keeps an assistant message's `tool_calls` together with its `tool` results as a unit.
**Alternatives rejected.** Dropping the oldest K messages. It eventually splits a tool call
from its result, which is invalid to most providers — and the 400 shows up several
iterations later, far from the cause.
**Cost accepted.** Trimming is coarser: sometimes a whole turn is dropped where a couple of
messages would have sufficed.
📄 [`context-window-manager.ts:94`](../../packages/core/src/agent/context/context-window-manager.ts#L94) · [Context management](./context-management.md#2--trimming-turn-aware-never-mid-tool-call)
---
## Staying honest about cost
### Two-tier token counting with per-model calibration
**Decision.** Exact counts via `gpt-tokenizer` for OpenAI families; for everyone else a
family-seeded chars-per-token ratio that calibrates against the provider's own reported
`usage.promptTokens` after each call, smoothed and clamped to `[2, 6]`.
**Alternatives rejected.** One universal ratio (≈4 chars/token) — under-counts Claude by
~15%, so you overrun the window you thought you were under. Anthropic's tokenizer package —
stale, Claude-2 era. Their `count_tokens` API — a network round trip on the hot path.
**Cost accepted.** The first call against an unfamiliar model uses a seed estimate and can
be off. It self-corrects after one exchange.
📄 [`token-counter.ts`](../../packages/core/src/agent/context/token-counter.ts) · [Context management](./context-management.md#1--counting-tokens)
### Tool results reformatted before entering context
**Decision.** Every tool result passes through `formatToolResultForContext` before being
appended, and its size is recorded per tool name.
**Alternatives rejected.** Storing raw payloads. Tool output — not conversation — is the
dominant context cost in long runs, and raw JSON is the least token-efficient way to say
anything.
**Cost accepted.** Formatting is lossy; a tool whose output genuinely needs full fidelity
must say so in its own formatter.
📄 [`tool-result-formatter.ts`](../../packages/core/src/utils/tool-result-formatter.ts)
### Offload old tool results, don't wait for 65%
**Decision.** Every iteration, persist large tool bodies to the conversation's work
directory, then replace every cycle except the live one with a pointer to
`retrieve_tool_result`. A failed write (read-only CI, container, Telegram host)
does not fail the run: the placeholder tells the model to re-run the original
tool. No window-fill gate.
**Alternatives rejected.** Waiting until 65% of the window, once per crossing —
a 200k model carried ~130k of already-read grep/file output on every round trip.
Clearing every turn without persist — the model cannot get the bytes back.
Requiring a writable disk — Jazz runs in CI, Docker, and chat bridges that can
read but not write.
**Cost accepted.** One cache miss per aged-out result (the prefix rewrites once,
then sticks). Retrieve is an extra round trip when the model still needs an old
body. Hosts that cannot write pay the same stub they had before, minus the wait.
📄 [`tool-result-offload.ts`](../../packages/core/src/agent/context/tool-result-offload.ts) · [`tool-result-clearing.ts`](../../packages/core/src/agent/context/tool-result-clearing.ts)
### Cache the conversation prefix, not just the system prompt
**Decision.** Anthropic-style providers get a cache breakpoint on the last
content part of the request as well as the system message. OpenAI requests
always carry `promptCacheKey: "conversation"`, including when reasoning is off.
**Alternatives rejected.** Caching only the system prompt — the dominant tokens
in a long run are history, and they were re-billed at full input price every
turn. Rewriting the prefix every iteration to shrink it — that busts the cache
and makes every remaining token expensive.
**Cost accepted.** A compaction or offload that rewrites an earlier message is
one cache miss. That is cheaper than never hitting the cache at all.
📄 [`ai-sdk-service.ts`](../../packages/adapters/src/llm/ai-sdk-service.ts)
### Sub-agent cost rolls up into the parent
**Decision.** A parent run reports its own cost plus all child cost, and emits a figure
whenever _either_ side is known.
**Alternatives rejected.** Reporting only the parent's own tokens — a local-model parent
that spawned three cloud sub-agents would report `$0.00` while your bill said otherwise.
**Cost accepted.** You can't read per-child spend off the top-level number; that lives in
the telemetry records.
📄 [`agent-loop.ts:221`](../../packages/core/src/agent/execution/agent-loop.ts#L221)
---
## Running anywhere
### stdout is the payload, stderr is everything else
**Decision.** `jazz run` writes only the answer (or exactly one JSON object) to stdout.
Status notices, tool chatter, headers, footers, and `--events` NDJSON all go to stderr.
`JAZZ_NO_TUI=1` is forced so Ink never touches stdout.
**Alternatives rejected.** A `--quiet` flag over the normal output path — you're still
filtering, and one new log line breaks every downstream parser. A dedicated `--format json`
that only _mostly_ suppresses chatter — same problem, later.
**Cost accepted.** Two streams to wire up in a bridge instead of one. That's the entire
cost, and it's what makes every non-terminal surface possible.
📄 [`execute.ts:30`](../../packages/cli/src/commands/run/execute.ts#L30) · [Headless](../use-cases/headless.md)
### Risk tiers instead of a tool allowlist
**Decision.** Every tool declares a risk level (`read-only` / `low-risk` / `high-risk`), and
one policy dial decides what runs unattended.
**Alternatives rejected.** A per-tool allowlist as the primary mechanism. It doesn't
generalize across surfaces — you'd maintain a different list for CI, cron, and each bridge,
and a new tool defaults to invisible rather than gated.
**Cost accepted.** Tiers are coarse: `high-risk` covers both `git push` and `rm -rf`.
Sharper control comes from the two escape hatches — a per-tool session allowlist and a
per-command allowlist for `execute_command` — and from trimming the agent's toolset, which
is the strongest control available.
📄 [`types/tools.ts:19`](../../packages/core/src/types/tools.ts#L19) · [Tools & approval](./tools-and-approval.md)
### Two-phase execution (propose → approve → execute)
**Decision.** A gated tool doesn't act. It returns an approval request describing what it
_would_ do — including a preview diff for edits. Approval (human or policy) then invokes the
real execution tool.
**Alternatives rejected.** A boolean `dangerous` flag checked before calling. You can't show
a meaningful preview without doing the work, and "would this be destructive" gets evaluated
before the arguments are resolved.
**Cost accepted.** Every gated tool is two registry entries instead of one, and the registry
carries the propose→execute mapping.
**What it buys:** interactive and unattended runs use _the same code path_. There is no
separate headless mode that can drift from the interactive one — the only difference is who
answers.
📄 [`tool-executor.ts:192`](../../packages/core/src/agent/execution/tool-executor.ts#L192)
### Command approval matches on a parsed key, never a raw prefix
**Decision.** "Always approve `git status`" stores a key extracted from the command (binary
- first subcommand) and matches exactly or on a word boundary.
**Alternatives rejected.** Prefix-matching the raw command string. Then approving
`git status` also approves `git status && rm -rf /`.
**Cost accepted.** Approving `git` broadly takes several confirmations instead of one.
📄 [`tool-executor.ts:601`](../../packages/core/src/agent/execution/tool-executor.ts#L601)
---
## Not locking you in
### Vercel AI SDK as the provider port
**Decision.** One adapter (`ai-sdk-service.ts`) behind the `LLMService` interface, giving 18
providers including local Ollama and llama.cpp.
**Alternatives rejected.** Hand-written clients per provider — every new provider becomes a
project, and streaming plus tool-calling plus reasoning quirks get reimplemented each time.
**Cost accepted.** Jazz is bounded by what the SDK normalizes, and inherits its bugs.
Provider-specific behavior that leaks through — reasoning-effort semantics especially — is
normalized in `services/llm/reasoning/`. AI SDK's internal retries are turned off
(`AI_SDK_MAX_RETRIES = 0`) so Jazz owns retry policy via Effect rather than having two
retry loops fighting.
📄 [`ai-sdk-service.ts`](../../packages/adapters/src/llm/ai-sdk-service.ts) · [Providers & models](./providers-and-models.md)
### Model catalog from models.dev, with an on-disk snapshot
**Decision.** Context windows and pricing come from models.dev, cached to
`~/.jazz/cache/models-dev.json`. Offline mode reads the snapshot; `JAZZ_MODELS_DEV_URL`
points at an internal mirror.
**Alternatives rejected.** Vendoring a pricing table — stale within weeks, and wrong pricing
is worse than none. Requiring the network — breaks airgapped installs, which are a supported
deployment.
**Cost accepted.** A brand-new model may be missing from the catalog; Jazz falls back to
provider-reported metadata and a 128k default. Ollama and llama.cpp need no catalog at all —
model lists, context windows, and tool support are read from the local server.
📄 [`models-dev.ts`](../../packages/core/src/utils/models-dev.ts) · [Airgapped](../start/airgapped.md)
### Effect-TS for the entire runtime
**Decision.** Typed errors, tracked effects, `Layer`-based dependency injection throughout.
**Alternatives rejected.** Plain `async/await` with thrown exceptions. In an agent runtime
the failure paths _are_ the product — a tool that times out, a provider that 429s, a
malformed tool argument. With exceptions those become `undefined` three frames away.
**Cost accepted.** A real learning curve. Effect is unfamiliar to most contributors and the
type errors are intimidating. That's precisely why [Code map](./code-map.md) leads with the
DI and Layer patterns, and why `core/` is kept dependency-free so it can be tested with
plain mocks.
### Lazy MCP connection
**Decision.** MCP servers are not connected at startup. Tools are registered per-agent based
on the agent's tool list, and a server connects the first time one of its tools is actually
invoked.
**Alternatives rejected.** Connecting everything at boot. MCP servers are child processes;
half a dozen of them turn `jazz` into a several-second startup and a CLI that hangs when one
server misbehaves.
**Cost accepted.** The first call to an MCP tool pays the connection cost, and a broken
server surfaces mid-run instead of at startup.
📄 [`register-tools.ts`](../../packages/core/src/agent/tools/register-tools.ts)
### Progressive skill loading
**Decision.** Three tools: `find_skills` (names + descriptions), `load_skill` (full
instructions), `load_skill_section` (a referenced file).
**Alternatives rejected.** Preloading every skill's instructions into the system prompt. A
hundred skills would consume the window before the user says anything. Re-injecting a loaded
skill into the system prompt on later turns — that would bust the cached system prefix, so
the playbook stays in the conversation as the `load_skill` tool result.
**Cost accepted.** Two extra round trips before the agent starts working with a skill. Later
turns must follow a playbook that lives in transcript history, not in the system prompt.
📄 [`skill-tools.ts`](../../packages/core/src/agent/tools/skill-tools.ts) · [Skills loading](./skills-loading.md)
### Deferred tool schemas
**Decision.** Tool categories declare a `loadTier`: `eager` (schema sent every turn — files,
shell, todo, etc.) or `deferred` (name + one-line summary only — MCP servers, background jobs,
reminders, wake triggers, workspace, peers). A `search_tools` tool fetches a deferred tool's
full schema on demand; once fetched it stays callable for the rest of the run.
**Alternatives rejected.** Sending every registered tool's full schema every turn, including
every tool a connected MCP server advertises. Server tool counts are unbounded and
user-configured, so this cost grows without bound as someone adds servers, most of whose tools
never come up in a given conversation.
**Cost accepted.** An extra `search_tools` round trip before a deferred tool's first use per
run. Names/summaries must stay visible in the prompt regardless — hiding them entirely would
push the model toward replicating a listed tool with `execute_command` instead of discovering
it, which `execute_command`'s own description now warns against explicitly.
📄 [`search-tools-tool.ts`](../../packages/core/src/agent/tools/search-tools-tool.ts) · [Tools reference](../reference/tools.md)
---
## Related
- [Agent loop](./agent-loop.md) · [Context management](./context-management.md) · [Tools & approval](./tools-and-approval.md)
- [Code map](./code-map.md) — where the code for all of this lives
- [Discussions](https://github.com/lvndry/jazz/discussions) — decisions not yet made
---
## Evals — measuring the harness
Source: https://jazz-cli.vercel.app/docs/internals/evals.md
# Evals — measuring the harness
This page shows how to tell whether a harness change actually made agents better, rather than
assuming it did.
Source: [`evals/`](../../evals/) · run instructions: [`evals/README.md`](../../evals/README.md)
---
## The question the harness exists to answer
Given a weak model and a strong one, how much of the gap between them is closable by the
*harness* — better context management, better prompting, better tool design — rather than by
paying for a bigger model?
That framing drives the whole design. Every run measures a **system under test** against a
**ceiling**, so an improvement is expressed as a fraction of a known gap rather than as an
absolute score that means nothing on its own.
```mermaid
flowchart LR
subgraph configs["Three roles"]
direction TB
SUT["eval-sut
the weak target
(OpenRouter free model)"]
CEIL["eval-ceiling
strong model
the gap reference"]
JUDGE["eval-judge
strong model, rubric scoring
never the SUT"]
end
TASKS["Task suite
tooluse · planning
productivity · tutoring
grounding"]
TASKS --> SUT
TASKS --> CEIL
SUT --> CHECK["Verifiable checks
+ optional rubric"]
CEIL --> CHECK
JUDGE --> CHECK
CHECK --> METRICS["pass@1 · pass@k · Pass^k
bootstrap CI
cost-normalized"]
classDef weak fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef strong fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class SUT weak
class CEIL,JUDGE strong
```
The judge is never the model under test — a model grading itself measures its own confidence,
not its competence.
---
## Why Pass^k, not just pass@1
An agent that succeeds one time in three is not two-thirds of a working feature; it's an
unreliable one. So the harness reports:
| Metric | What it tells you |
| --- | --- |
| **pass@1** | Did it work on the first try |
| **pass@k** | Did it work at least once in k tries — an *optimistic* bound |
| **Pass^k** | Did it work on **every** one of k tries — the reliability number |
| **bootstrap CI** | Whether the difference you're looking at survives sampling noise |
| **cost-normalized** | Whether the improvement is real or just bought with more tokens |
`pass@k` rewards a lucky roll. **Pass^k** is the one to quote when claiming an agent is
dependable, because unattended surfaces — cron, CI, a chat bridge — get one attempt.
The bootstrap CI matters because eval suites are small. A jump from 6/10 to 7/10 is usually
noise, and reporting it as a win is how a harness accumulates changes that do nothing.
---
## Verifiable checks over vibes
Each task seeds a temp workspace, runs the agent, and checks **state** rather than asking a
model whether the answer looked good. Four check families live in
[`evals/checks.ts`](../../evals/checks.ts):
- **State checks** — did the file actually end up in the right place with the right content
- **Constraint checks** — did it avoid doing the thing it was told not to do
- **Citation grounding** — are the cited sources real and do they support the claim
- **Comprehension proxies** — did it understand the task, not just pattern-match the wording
### The grounding suite is the interesting one
[`evals/tasks/grounding/`](../../evals/tasks/grounding/) tests whether the agent resolves
indexical references — "this machine", "this repo", "the latest version" — against the real
environment instead of answering generically from training data. This is the failure mode most
likely to make an assistant feel useless while scoring fine on benchmarks.
Two checks are worth understanding because they encode a general principle:
- **`machineSpecGroundingCheck`** asserts against ground truth from `node:os`. Asking "how much RAM does this machine have" passes if the answer cites the real figure *or* the agent probed the system (`system_profiler`, `sysctl`). It **fails** on generic RAM-bucket advice, and it fails on asking the user to look it up themselves — because the agent has `execute_command` and could have just checked.
- **`toolGroundedAnswerCheck`** requires **both** a matching tool call **and** answer content consistent with it. Calling the tool proves nothing if the answer still guesses; this is the check that catches an agent going through the motions.
Web-dependent tasks use record-replay cassettes under `evals/fixtures/web/`, so
"what's the latest version of X" fails against the *actual* current version rather than
passing on a stale training-data guess.
---
## Judge calibration
Rubric scores come from an LLM judge, which is only trustworthy if it agrees with humans.
[`evals/judge/calibration.jsonl`](../../evals/judge/) holds human-labeled rows, and the runner
checks the judge correlates at **Pearson ≥ 0.7** before any rubric score is trusted.
An uncalibrated judge is a random number generator with good manners. Gating on correlation
means a drifting judge surfaces as a failed precondition rather than as quietly wrong results.
---
## Running it
```bash
cp evals/agents/*.json ~/.jazz/agents/ # install sut / ceiling / judge
bun run evals --agent eval-sut --samples 3 --stamp sut-baseline
bun run evals --agent eval-ceiling --samples 1 --stamp ceiling
```
**The A/B is the point.** Same tasks, two configs, so a harness change's lift is attributable
rather than asserted:
```bash
bun run evals --agent eval-sut --ab eval-sut-variant --samples 3 --stamp ab
```
Reports land in `evals/report/` (gitignored). Full flags and task-authoring guide:
[`evals/README.md`](../../evals/README.md).
---
## If you change the harness
Run the A/B. A change to context management, prompting, tool descriptions, or the agent loop
is exactly what this measures, and "it seems better in my testing" is not a result. If the
change is neutral on the suite, that's worth knowing too — it may mean the suite needs a task
that captures what you improved.
---
## Related
- [Agent loop](./agent-loop.md) · [Context management](./context-management.md) — the things most worth measuring
- [Design decisions](./design-decisions.md) — choices that should be defended with numbers
- [`evals/README.md`](../../evals/README.md) — flags, task authoring, agent configs
---
## Memory
Source: https://jazz-cli.vercel.app/docs/internals/memory.md
# Memory
This page explains where agent memory lives on disk, how it stays safe, and why
it's opt-in rather than always-on.
Source:
[`services/memory-service.ts`](../../packages/adapters/src/memory-service.ts) ·
[`interfaces/memory-service.ts`](../../packages/core/src/interfaces/memory-service.ts) ·
[`tools/memory-tools.ts`](../../packages/core/src/agent/tools/memory-tools.ts)
---
## What it is
A native, file-backed memory the agent manages itself via two tools — `view_memory` and
`manage_memory` (create/str_replace/insert/delete/rename) — mirroring the action surface of
Anthropic's own memory tool. There is no embedding index or vector search: memory is read in
full on `view`, which is the right tradeoff at the scale this targets (durable notes about
the people or projects one agent talks to), not a multi-tenant knowledge base.
Memory is scoped by `Agent.id`, the same identifier that already scopes conversation
history — so it follows an agent across every surface that invokes it (CLI, a Telegram
bridge, a Discord bridge), not by session or conversation.
## On disk
```text
~/.jazz/memory// agent's memory root, created lazily on first write
~/.jazz/memory/.lock/ directory-mutex, same convention as history's lock
```
Flat UTF-8 files, agent-organized (e.g. `people/alex.md`, `project-context.md`) — no
enforced schema. Guardrails cap depth, path-segment length, per-file size, total bytes, and
file count per agent (`core/constants/memory.ts`).
## Path safety
Every action goes through one function, `resolveMemoryPath`, before touching the
filesystem: it rejects `..`, null bytes, and absolute-path tricks, and **bans symlinks
outright** anywhere in the resolved chain — re-checked on every call, not cached, so a
same-run "create a file, swap it for a symlink, then read through it" race can't slip past a
one-time check.
Mutating actions (`create`/`str_replace`/`insert`/`delete`/`rename`) share one lock per
agent — not per file — because the size/count guardrails need a consistent view of the
whole directory tree, and the guardrail check plus the write happen inside the same lock
acquisition.
## Why opt-in
`view_memory`/`manage_memory` are registered like `file_management` or `git` — selected per
agent via `AgentConfig.tools`, not granted to every agent unconditionally. Memory persists
durable, potentially sensitive facts about a specific person to disk; forcing it on
everywhere would silently contradict personas (like `researcher`) that promise their tools
can't write files. Whoever wires up a persistent chat surface (Telegram, Discord) turns memory
on for that agent specifically.
---
## Peer invites — implementation plan
Source: https://jazz-cli.vercel.app/docs/internals/peer-invites.md
# Peer invites — implementation plan
This is the implementation plan for the peer-invite bootstrap flow described in
[Setting up peers](../start/peers-setup.md).
The goal is to make peer setup feel like an invitation instead of a manual
key exchange, while keeping the current trust model intact.
---
## Scope
### In scope
- invite creation
- invite acceptance
- invite expiration and single-use redemption
- automatic peer/config bootstrap after acceptance
- token storage in the OS keyring
- explicit tier confirmation
- a shareable invite URL
- optional QR output in the CLI
### Out of scope
- automatic peer discovery
- public directory listing of peers
- gRPC/protobuf transport migration
- peer actions
- approval prompts for peers
- changing the meaning of tiers
---
## Design constraints
1. **No trust by discovery.**
The invite may locate a peer, but it may not create trust by itself.
2. **No long-lived secret in the link.**
The invite URL should carry only a short-lived redeem secret and an invite id, not the final peer token.
3. **Keep the current endpoint model.**
The existing `POST /peer/ask` path should remain the runtime communication path.
4. **Make setup explicit.**
Humans should still see the endpoint, tier, and expiration before confirming.
5. **Keep the current peer policy.**
A redeemed invite should still end in the same explicit peer config, same tiers, same read-only toolset.
---
## Proposed user story
### One-way bootstrap
1. Alice wants Bob to be able to talk to her agent.
2. Alice creates an invite that points at her peer endpoint and proposes a tier.
3. Alice sends Bob the invite URL.
4. Bob accepts the invite.
5. Bob's Jazz writes the peer entry and shared secret locally.
6. Alice's Jazz marks the invite redeemed.
### Mutual bootstrap
If both sides want two-way communication, each side can create and accept an invite.
That keeps the mental model simple:
- one invite = one relationship direction
- two invites = two-way communication
The implementation can still support a convenience mode later if we decide that one invite should bootstrap both sides.
---
## Data model
### Invite record (`PeerInviteRecord`, `packages/core/src/types/peer-invite.ts`)
- `id` — 128 bits from `randomBytes(16)`, hex-encoded. Doubles as the filename
(`/invites/.json`), matching `FileRunStore`'s one-file-per-record pattern.
- `inviteeName` — the name the inviter will file the redeemer under, once accepted (their own
bookkeeping; the positional argument to `invite create`).
- `inviterDisplayName` — what the inviter wants to be called by whoever redeems this. Not
originally in the design sketch — added because the redeemer's accept confirmation ("this
invite is from ...") had nothing else to show. Defaults to the inviter's hostname.
- `inviterAskUrl` — the inviter's own `/peer/ask` URL, handed to the redeemer.
- `proposedTier`, `createdAt`, `expiresAt`, `secretHash` (sha256 of the secret — never the
secret itself), `redeemedAt?`, `redeemedAs?` (audit only), `revokedAt?`.
### Peer record
The accepted invite produces the normal `PeerConfig` entry plus keyring token storage — no new
permanent relationship type.
One correction to the original sketch: `PeerConfig.url` had to become optional. A `PeerConfig`
already conflates two independent capabilities (`url`: I can ask them; `disclosure`: they can ask me),
and a one-way invite naturally produces an entry with only one of those set. Making `url`
required would have forced a `""`-shaped placeholder on the granting side of every invite.
Fixing this surfaced a real, pre-existing bug: `ask_peer`'s "is this peer askable" filter was
checking `disclosure` instead of `url` — meaning a peer with `url` but no `disclosure` (exactly what a
one-way invite produces on the *asking* side, and what `docs/start/peers-setup.md`'s own
manual walkthrough describes) was silently un-askable. Fixed alongside this feature since the
feature would otherwise ship broken by inheriting it.
Acceptance **upserts** rather than replaces: if a peer entry with that name already exists
(from an earlier invite run in the other direction), the write merges in only the field the
new invite grants, leaving the other side's field untouched. This is what makes "run the flow
twice" a correct way to get mutual communication.
---
## API / CLI shape
```bash
jazz peers invite create --disclosure [--expires 24h] [--host 127.0.0.1] [--port 4747] [--as ] [--qr] [--json]
jazz peers invite accept [--as ] [--yes] [--json]
jazz peers invite list [--json]
jazz peers invite revoke
```
There is no `--endpoint` flag. `create` cannot introspect a running daemon's bind address (it
is a separate, short-lived CLI process, not code running inside the daemon), so it takes the
same `--host`/`--port` the eventual `jazz daemon` call will use, defaulting identically
(`127.0.0.1:4747`) — the common single-agent case needs neither flag.
`--host`/`--home` (a new global CLI flag, `jazz --home `, equivalent to exporting
`JAZZ_HOME` first) make the two-agents-on-one-host case usable without env-var juggling.
---
## HTTP endpoint shape
Only two of the four originally-sketched routes are actually network operations. `create`,
`list`, and `revoke` never touch the network — the inviter already has a shell on the machine
whose invite store and config they're changing, so those are plain CLI commands reading and
writing local files directly (`@jazz/adapters/peers/invites`), the same way `jazz peers log`
reads the ledger without going through the daemon. Only a *redeemer*, who by construction is
not on this machine, ever needs HTTP:
- `GET /peer-invites/:id` — unauthenticated metadata preview (inviter name, ask URL, proposed
tier, expiry, status). Enough to render a confirmation, not enough to be useful without the
secret.
- `POST /peer-invites/:id/accept` — redeem. Body is `{ secret, as }`; response is
`{ ok, inviterAskUrl, token }` on success, or a specific error (`404`/`410`/`401`/`500`) per
failure kind (not found / expired / already redeemed / revoked / bad secret / no keyring).
Both live in `makePeerInviteHandler` (`packages/adapters/src/daemon/server.ts`), a fourth
"door" alongside the operator's, a peer's, and a trigger's — its own auth model (the redeem
secret, not a standing bearer token).
The fragment secret in the URL is never sent on the `GET`; only the `POST .../accept` body
carries it, over whatever transport the endpoint uses (see the security-model note on
plaintext HTTP below).
### The bug this surfaced: `makePeerHandler` read the peer list once, at daemon startup
`jazz daemon` originally captured `appConfig.peers` once, before entering its request loop,
and closed over that array. An invite accepted five minutes into the process's life would
never be recognized by `/peer/ask` until the daemon restarted — silently defeating the entire
point of accepting a peer over HTTP instead of editing config by hand. Fixed by changing
`makePeerHandler` to take a `resolvePeers: () => Promise` thunk instead
of a static array; `daemon.ts` now re-reads `AgentConfigService.appConfig` on every `/peer/ask`
request. Proven by `packages/adapters/src/daemon/peer-invite-flow.test.ts`: the same handler
closure, built before an invite exists, authorizes the token that invite mints.
---
## Acceptance flow
### Step 1: render the invite
When a user accepts an invite, Jazz should show:
- inviter name
- inviter endpoint
- proposed tier
- expiry
- whether the invite is one-way or mutual
### Step 2: explicit confirmation
The user confirms that they want to create the peer relationship.
### Step 3: verify and redeem
The accepting side sends the invite id and secret to the inviter side.
The inviter side verifies:
- invite exists
- secret matches
- invite is not expired
- invite has not already been redeemed
- the proposed tier is valid
### Step 4: write the relationship
After verification, both sides store what they need:
- peer config entry
- token in the OS keyring
- invite marked redeemed
### Step 5: clean up
The invite should be treated as spent and no longer reusable.
---
## Security checks
All covered in `packages/adapters/src/peers/invites.test.ts` and
`packages/adapters/src/daemon/peer-invite-flow.test.ts`:
- expired invite cannot be redeemed (distinctly from a bad secret)
- redeemed invite cannot be reused, including under concurrent redemption attempts
- invite secret mismatch is rejected without consuming the invite
- revoked invite is rejected
- redemption is refused, without consuming the invite, when no keyring is available to store
the resulting token
- acceptance shows the endpoint and tier before any write happens (the CLI's confirmation
prompt, gated by `TerminalService.confirm`)
- the invite record never persists the redeem secret in plaintext (only its hash)
- invite ids are checked against a fixed shape before ever reaching a path join (`isInviteId`)
---
## Files changed
### Core types and contracts
- `packages/core/src/types/peer.ts` — `PeerConfig.url` made optional
- `packages/core/src/types/peer-invite.ts` — new: `PeerInviteRecord`, `inviteStatus`, `isInviteId`
- `packages/core/src/interfaces/peer-invites.ts` — new: `PeerInviteService`
- `packages/core/src/agent/tools/peer-tools.ts` — `ask_peer`'s askability filter fixed to check
`url`, not `disclosure` (see the data-model section above)
### Peer adapters and storage
- `packages/adapters/src/peers/invites.ts` — new: the file-backed invite store, redemption
state machine, `acceptInviteOnInviterSide`
- `packages/adapters/src/peers/config.ts` — new: `upsertPeer`, the merge-by-name write both
sides of acceptance use
- `packages/adapters/src/daemon/server.ts` — `makePeerHandler` takes a live `resolvePeers`
thunk instead of a static array; `makePeerInviteHandler` added
### CLI
- `packages/cli/src/commands/peer-invites.ts` — new: `create`/`accept`/`list`/`revoke`
- `packages/cli/src/utils/option-parsers.ts` — `parseDurationMs` for `--expires`
- `packages/runtime/src/cli-app.ts` — `peers invite ...` subcommands; new global `--home` flag
- `qrcode-terminal` — new dependency, for `--qr`
### Docs
- `docs/start/peers-setup.md`, `docs/internals/peer-invites.md` (this file) — corrected
against the implementation
- `docs/start/peers-setup.md` — invite-based path added alongside the manual one
- `docs/concepts/index.md`, `docs/internals/index.md` — linked
### Tests
- `packages/core/src/types/peer-invite.test.ts`
- `packages/adapters/src/peers/invites.test.ts`
- `packages/adapters/src/daemon/peer-invite-flow.test.ts` — the full HTTP round trip through
real handler factories, stopping at the authorization boundary (no real LLM)
- `packages/core/src/agent/tools/peer-tools.test.ts` — updated for the askability-filter fix
- `scripts/peers/two-agents-localhost.sh` — the same scenario with a real agent stack, run by hand
- `scripts/peers/cross-network/` — the same scenario across two Docker networks with no route
between them, bridged only by a reverse proxy, exercising `--public-url` for real
---
## Testing plan (as implemented)
### Unit tests (`invites.test.ts`, `peer-invite.test.ts`)
- invite creation mints a fresh, unguessable id and never persists the secret in plaintext
- redemption succeeds exactly once; a wrong secret does not consume it; concurrent redemption
attempts for the same invite never both succeed
- expired / revoked / already-redeemed / not-found are distinct, correctly-ordered outcomes
- `acceptInviteOnInviterSide` grants the tier, mints and stores a token, merges into an
existing peer entry rather than clobbering it, and refuses to consume the invite when no
keyring is available — all against an injected in-memory keyring (`KeyringDependency`),
never the real OS keychain, matching `keyring.test.ts`'s own reasoning for avoiding that
### Integration tests (`peer-invite-flow.test.ts`)
- the full HTTP round trip (preview → accept → authorized `/peer/ask`) through the real daemon
handler factories, with a real invite store and a fake keyring/agent stack
- proves the live-reload fix: the same `handlePeer` closure built before the invite existed
authorizes the token that invite minted
- a wrong token still fails after a real one exists; a second accept on the same invite fails
### Manual / real-agent verification
- `scripts/peers/two-agents-localhost.sh` — the same scenario with two real agents and a real LLM,
since a model's actual answer is not something a deterministic CI run should depend on
---
## Delivery status
All four phases shipped in one pass: model/storage, CLI/keyring, daemon endpoints, and docs —
see [Files changed](#files-changed) above.
---
## Recommendation (as followed)
The invite flow was implemented **before** touching transport, per the original
recommendation below. It also fixed one bug it inherited from the existing peer model
(`ask_peer`'s askability filter) and one architectural gap it would otherwise have shipped
broken by (the daemon's one-time peer-list snapshot).
The biggest win is not protobuf or gRPC. It is turning peer setup from:
- manual URL
- manual secret
- manual config edit
into:
- create invite
- send link
- accept
- done
---
## Providers & models
Source: https://jazz-cli.vercel.app/docs/internals/providers-and-models.md
# Providers & models
This page explains how Jazz stays provider-agnostic, and what it does about the
places providers genuinely differ.
Source:
[`services/llm/ai-sdk-service.ts`](../../packages/adapters/src/llm/ai-sdk-service.ts) ·
[`services/llm/reasoning/`](../../packages/adapters/src/llm/reasoning/) ·
[`core/utils/models-dev.ts`](../../packages/core/src/utils/models-dev.ts)
---
## One port, 18 providers
`core/` defines an `LLMService` interface. `services/llm/ai-sdk-service.ts` is the only
implementation, and it delegates to the Vercel AI SDK.
```mermaid
flowchart TB
CORE["core/interfaces/llm.ts
LLMService port
the agent loop only knows this"]
IMPL["services/llm/ai-sdk-service.ts
the single adapter"]
SDK["Vercel AI SDK"]
subgraph cloud["Cloud"]
direction TB
C1["OpenAI · Anthropic · Google · xAI"]
C2["Mistral · DeepSeek · Groq · Cerebras"]
C3["Fireworks · TogetherAI · Alibaba"]
C4["Moonshot · MiniMax · Zhipu"]
end
subgraph aggregators["Aggregators"]
A1["OpenRouter"]
A2["Vercel AI Gateway"]
end
subgraph local["Local — no API key"]
L1["Ollama"]
L2["llama.cpp"]
end
CORE --> IMPL --> SDK
SDK --> cloud
SDK --> aggregators
SDK --> local
classDef core fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef free fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
class CORE,IMPL core
class local free
```
Adding a provider means adding an SDK package and a catalog entry — not a new streaming
implementation, not a new tool-calling translation. The cost is that Jazz is bounded by what
the SDK normalizes, and inherits its bugs.
Configuration and API keys: [Integrations](../integrations/index.md).
---
## The model catalog
Jazz needs two things per model that providers don't reliably report: **context window** and
**pricing**. Both come from [models.dev](https://models.dev), with an on-disk snapshot so
this never becomes a hard dependency.
```mermaid
flowchart TD
NEED(["Need metadata for
provider/model"]) --> LOCAL{"Local provider?
ollama / llamacpp"}
LOCAL -->|yes| ASK["Ask the local server
Ollama /api/tags + /api/show
llama.cpp /props
no catalog needed at all"]
LOCAL -->|no| OFF{"JAZZ_OFFLINE?"}
OFF -->|no| FETCH["Fetch models.dev
(or JAZZ_MODELS_DEV_URL mirror)"]
FETCH --> SNAP["Refresh the snapshot
~/.jazz/cache/models-dev.json"]
OFF -->|yes| READ["Read the snapshot if present"]
SNAP --> USE
READ --> USE
ASK --> USE
READ -->|"no snapshot"| FALL["Provider-reported metadata,
else 128k default"]
FALL --> USE
USE(["contextWindow · pricing
· reasoning support"])
classDef local fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
class ASK,READ local
```
Consequences worth knowing:
- **Local models need no catalog.** Model lists, context windows, and tool-calling support are read straight from the local server. An airgapped install works with an empty cache.
- **The 80% compaction threshold tracks the real window.** A 1M-context model gets a 1M-based threshold, not a guess.
- **A brand-new model may be missing.** You get the provider's own metadata or a 128k default, and pricing display may be blank. Nothing breaks.
- **`JAZZ_MODELS_DEV_URL`** points at an internal mirror for airgapped networks that still want metadata. See [Airgapped](../start/airgapped.md).
---
## Reasoning: the messiest difference
Providers expose "thinking" in incompatible ways. Some return it as a structured field. Some
interleave `…` tags in the text stream. Some reject a reasoning-effort
parameter outright. Local models frequently emit reasoning tags that their own advertised
capabilities never mention.
```mermaid
flowchart TB
STREAM["Model stream"] --> SELECT{"selectParser(provider,
modelId, chatTemplate,
capabilities)"}
SELECT -->|"a factory claims it"| P1["That parser"]
SELECT -->|"Harmony format detected
<|channel|>analysis"| NONE["No parser —
passthrough would mangle
the delimiters"]
SELECT -->|"nothing claims it"| P2["Defensive TagPairParser
passthrough on plain text;
acts only if a real
<think> tag appears"]
P1 --> SPLIT
P2 --> SPLIT
SPLIT["Split each delta into
visibleText + thinkingText"]
SPLIT --> EV["thinking_start / thinking_chunk /
thinking_complete events"]
SPLIT --> TXT["text_chunk events"]
classDef guard fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
class P2,NONE guard
```
The default is **defensive rather than strict**, and that's a deliberate call. A strict
factory gate — only parse when metadata says the model reasons — silently leaks
`` tags into the user-visible answer for the many local models that don't declare it.
The fallback parser is a passthrough until it actually sees an opening tag, so the cost of
being wrong is zero. The one format explicitly refused is Harmony, where naive tag-pair
parsing would visibly corrupt the channel delimiters.
Parsers are stateful per request and buffer across chunk boundaries, so a `` tag split
across two network packets is stitched correctly rather than half-rendered.
**Reasoning effort** (`low` / `medium` / `high` / `disable`) is normalized per provider.
Models without reasoning support error if you ask for it — which is why `--reasoning disable`
exists and why the Discord/Telegram bridges' `/model` command sets it automatically from
whichever source knows that model's capabilities (a local Ollama's own reporting, the
models.dev catalog, or the provider's own model-listing endpoint), for any provider — not
just local models.
---
## Retries and timeouts
Jazz owns retry policy. The AI SDK's internal retries are turned **off**
(`AI_SDK_MAX_RETRIES = 0`) so there aren't two retry loops with different opinions about
backoff.
| Setting | Value | Meaning |
| ----------------------------- | ----- | ---------------------------------------------------- |
| `DEFAULT_MAX_LLM_RETRIES` | 10 | Attempts on transient failures (429, 5xx, network) |
| `MAX_RETRY_DELAY_SECONDS` | 30 | Caps exponential backoff between attempts |
| `LLM_TIMEOUT_SECONDS` | 900 | The whole call including every retry and all backoff |
| `LLM_SLOW_MODEL_HINT_SECONDS` | 45 | Show a "this model is slow" hint; not a failure |
A 15-minute ceiling exists because slow reasoning models on a long prompt genuinely take
minutes, and a tighter timeout would fail runs that were about to succeed. Rate-limit errors
are typed (`LLMRateLimitError`) so they're distinguishable from real failures.
---
## Cost accounting
Pricing comes from the catalog, per million input and output tokens:
```text
ownCost = promptTokens/1e6 × inputPrice + completionTokens/1e6 × outputPrice
total = ownCost + Σ(sub-agent cost)
```
A figure is emitted whenever *either* side is known — a free local parent that delegated to a
paid cloud sub-agent still reports real spend. When neither side is priced (an uncatalogued
local model), cost is omitted rather than reported as `$0.00`, because those aren't the same
claim.
Per-run records land in `~/.jazz/telemetry/` as local JSON. Nothing is transmitted anywhere.
Command-risk classifier tokens are stored separately (`classifierUsage` on the run,
`purpose: "classifier"` on each `llm_usage`) because that call uses the cheap harness
model, not the agent's, and mixing the two would hide both numbers. See
[Observability](../start/observability.md).
---
## Switching models
| Where | How |
| -------------------- | --------------------------------------------------------------- |
| Mid-conversation | `/switch` (or `/models`) to an agent configured with the model |
| Per agent | the agent's `llmProvider` / `llmModel` fields |
| For compaction only | the agent's `summarizerModel` — run a cheap model for summaries |
| For one headless run | `--reasoning` (effort); model comes from the agent config |
| Whole install | `~/.jazz/config.json` |
Running a cheap model for compaction while the main agent runs an expensive one is the
highest-value version of this, and it's one field.
---
## Related
- [Integrations: providers](../integrations/index.md#llm-providers) — API keys and setup
- [Airgapped & self-hosted](../start/airgapped.md) — local-only operation
- [Context management](./context-management.md) — what the context window is used for
- [Design decisions](./design-decisions.md#not-locking-you-in) — the trade-offs
---
## Reminders
Source: https://jazz-cli.vercel.app/docs/internals/reminders.md
# Reminders
This page explains how agent reminders are stored, parsed, and delivered.
Source:
[`services/reminder-service.ts`](../../packages/adapters/src/reminder-service.ts) ·
[`interfaces/reminder-service.ts`](../../packages/core/src/interfaces/reminder-service.ts) ·
[`tools/reminder-tools.ts`](../../packages/core/src/agent/tools/reminder-tools.ts) ·
[`utils/time.ts`](../../packages/core/src/utils/time.ts) ·
[`wake-triggers/reminder-os-scheduler.ts`](../../packages/core/src/wake-triggers/reminder-os-scheduler.ts) ·
[`utils/desktop-notify.ts`](../../packages/core/src/utils/desktop-notify.ts)
---
## What it is
A file-backed reminder list the agent manages with three tools — `add_reminder`,
`list_reminders`, and `cancel_reminder`. Reminders are scoped by `Agent.id`, so they follow
the agent across CLI, Telegram, and other surfaces rather than disappearing with a session.
`when` specs are timezone-aware: relative durations (`30m`, `1h30m`), a 24h clock time
(`18:00` → next occurrence), `tomorrow HH:MM`, a weekday and time (`tue 20:00` → next
occurrence of that weekday), or an absolute `YYYY-MM-DD HH:MM`. Clock times use the
execution context timezone when the surface supplies one, otherwise UTC.
`add_reminder` refuses a `when` that resolves to a time in the past.
## On disk
```text
~/.jazz/reminders/.json pending reminders for that agent
~/.jazz/reminders/.lock/ directory-mutex around read-modify-write
```
Delivery depends on which surface owns the agent:
- **Telegram and Discord**: each bot bridge runs its own 20-second `setInterval` sweep
(`packages/telegram-bot/src/reminders.ts`, `packages/discord-bot/src/reminders.ts`) and
delivers due reminders as a chat message, unchanged by anything below.
- **CLI-hosted agents** (`agentId` not prefixed `tg_`/`dc_`): `add_reminder` also installs a
real one-shot host-scheduler job — the same mechanism `register_trigger` uses for wake
triggers — so the reminder fires even without `jazz daemon` running. Firing invokes
`jazz reminder fire --agent --id `, which sends a native OS desktop
notification (`utils/desktop-notify.ts`) rather than resuming a conversation. `jazz daemon`'s
in-process ticker (`adapters/daemon/trigger-runner.ts`) remains a fallback sweep for hosts
with neither `launchd` nor `at`, and always skips `tg_`/`dc_` agent ids to avoid delivering
the same reminder twice.
Late delivery is preferred to dropping a reminder if the process was down at fire time.
## Guardrails
Per-agent count and text-length caps live in `core/constants/reminders.ts`. Invalid `when`
specs return a failed tool result the model can correct; they do not throw and kill the run.
---
## Feature Flag Service — Example Template
Source: https://jazz-cli.vercel.app/docs/internals/service-template.md
# Feature Flag Service — Example Template
This example is a self-contained template showing how to design, implement, wire, and test a simple FeatureFlagService following the project architecture:
- Contract (core) — `packages/core/src/interfaces` (interface + Tag)
- Adapter (services) — `packages/adapters` (implementation + Layer)
- App wiring — layer composition in `createAppLayer` (main)
- Tests — how to mock the service with `Layer.succeed`
IMPORTANT: This is a reference/template for contributors. Do NOT copy this file into production code paths without review.
---
Table of contents
- Overview
- Contract: core interface
- Adapter: HTTP-backed example implementation
- Wiring: add the layer to `createAppLayer`
- Usage: how core and CLI access the service
- Testing: unit test example using `Layer.succeed`
- Notes, safety, and checklist
---
## Overview
Feature flags are a common cross-cutting concern. The pattern below shows how to:
1. Define a small contract in `core` that core business logic depends on.
2. Implement the contract in `services` as a Layer that can depend on configuration/logger/etc.
3. Provide the Layer in app composition so core code can call the contract via its Tag.
4. Mock the contract in tests with `Layer.succeed`.
---
## 1. Contract — core interface
Place this under `packages/core/src/interfaces/feature-flag.ts` in your real codebase. In this example it's shown inline.
```ts
// packages/core/src/interfaces/feature-flag.ts — example
import { Context, Effect } from "effect";
export interface FeatureFlagService {
// return true/false for a named flag
// Effect.Effect means: returns boolean, never fails (always succeeds)
readonly isEnabled: (flagName: string) => Effect.Effect;
// optional: get rollout percentage (0..100)
readonly rolloutPercentage: (flagName: string) => Effect.Effect;
}
// Context.GenericTag creates a dependency injection token
// This tag is used to access the service via Effect's dependency system
export const FeatureFlagServiceTag = Context.GenericTag("FeatureFlagService");
```
Design guidance
- Keep the contract small and focused.
- Prefer safe return types (boolean/number) for non-critical features — allow graceful degradation.
- Put contracts in `packages/core/src/interfaces` so the core layer depends only on the contract.
- Use `Effect.Effect`:
- `ReturnType`: what the function returns (boolean, number, etc.)
- `ErrorType`: error types it can fail with (`never` means it never fails)
- `Dependencies`: services it needs (omitted here, added via Tag in usage)
---
## 2. Adapter — HTTP-backed example (template)
This is an example service implementation showing:
- How to read configuration via AgentConfigService
- How to build a Layer that depends on the config tag
- How to implement safe fallbacks
Place something like this under `packages/adapters/src/feature-flag/http.ts` in your real project (here we show the template).
```ts
// packages/adapters/src/feature-flag/http.ts
import { Effect, Layer } from "effect";
import { FeatureFlagServiceTag, type FeatureFlagService } from "../../core/interfaces/feature-flag";
import { AgentConfigServiceTag, type AgentConfigService } from "../../core/interfaces/agent-config";
class HTTPFeatureFlagService implements FeatureFlagService {
constructor(
private readonly baseUrl: string,
private readonly apiKey?: string,
) {}
isEnabled(flagName: string) {
return Effect.tryPromise({
try: async () => {
const url = `${this.baseUrl.replace(/\/$/, "")}/flags/${encodeURIComponent(flagName)}/enabled`;
const resp = await fetch(url, {
headers: this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined,
});
if (!resp.ok) {
// degrade safely
return false;
}
const body = await resp.json();
return Boolean(body?.enabled);
},
catch: () => false,
});
}
rolloutPercentage(flagName: string) {
return Effect.tryPromise({
try: async () => {
const url = `${this.baseUrl.replace(/\/$/, "")}/flags/${encodeURIComponent(flagName)}`;
const resp = await fetch(url, {
headers: this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : undefined,
});
if (!resp.ok) return 0;
const body = await resp.json();
const value = typeof body?.rollout === "number" ? body.rollout : 0;
return Math.max(0, Math.min(100, value));
},
catch: () => 0,
});
}
}
export function createHTTPFeatureFlagLayer(): Layer.Layer<
FeatureFlagService,
never,
AgentConfigService
> {
return Layer.effect(
FeatureFlagServiceTag,
Effect.gen(function* () {
const configService = yield* AgentConfigServiceTag;
const appConfig = yield* configService.appConfig; // AppConfig should include a featureFlags section
const baseUrl = appConfig.featureFlags?.baseUrl ?? "https://flags.example.com";
const apiKey = appConfig.featureFlags?.apiKey;
return new HTTPFeatureFlagService(baseUrl, apiKey);
}),
);
}
```
Notes:
- The Layer declares it requires `AgentConfigService` so it can read configuration (see the type parameter `AgentConfigService` in the return type).
- `Effect.tryPromise` wraps async operations and converts promise rejections to Effect errors. Here we catch all errors and return safe defaults (`false`/`0`) to avoid breaking the app if the flag service is unavailable.
- The Layer type `Layer.Layer` means:
- Provides: `FeatureFlagService` (what this layer gives you)
- Errors: `never` (this layer creation never fails)
- Requires: `AgentConfigService` (what this layer needs to be created)
---
## 3. Wiring — provide the layer in createAppLayer (example)
In your app bootstrap (e.g., `src/main.ts`) you compose layers. Example snippet:
```ts
import { Layer } from "effect";
import { createConfigLayer } from "./services/config"; // provides AgentConfigServiceTag
import { createLoggerLayer } from "./services/logger";
import { createHTTPFeatureFlagLayer } from "./services/feature-flag/http";
function createAppLayer() {
const configLayer = createConfigLayer(); // provides AgentConfigServiceTag
const loggerLayer = createLoggerLayer();
// feature flag layer depends on AgentConfigService; provide configLayer first
const featureFlagLayer = createHTTPFeatureFlagLayer().pipe(Layer.provide(configLayer));
return Layer.mergeAll(
configLayer,
loggerLayer,
featureFlagLayer,
// ...other layers
);
}
```
Important:
- **Layer dependencies**: The FeatureFlag layer requires `AgentConfigService`, so you must provide it before using the layer.
- **Two ways to provide dependencies**:
1. `Layer.provide`: Explicitly provide a dependency to a single layer (as shown above)
2. `Layer.mergeAll`: Merge multiple layers together; dependencies are resolved automatically if all required layers are included
- **Ordering matters**: When using `Layer.provide`, provide dependencies before the layer that needs them. When using `Layer.mergeAll`, include all required layers in the merge.
---
## 4. Usage — how core and CLI access the service
**Architecture overview**:
- **Services** implement core interfaces (services depend on core) — services are the concrete implementations
- **Core** and **CLI** access services through dependency injection via tags — they use the service without knowing the implementation
Both **core** and **CLI** layers can use the service by:
1. Importing the Tag from `core/interfaces`
2. Accessing it via `yield*` inside an `Effect.gen` block
3. Calling methods on the service
**Creating utility functions**:
Utility functions that wrap service calls should live in `packages/core/src/utils/` (or `packages/core/src/agent/` if agent-specific). These are convenience wrappers that use the service tag — they're not part of the service implementation itself.
````ts
// packages/core/src/utils/feature-flag.ts
import { Effect } from "effect";
import { FeatureFlagServiceTag, type FeatureFlagService } from "../interfaces/feature-flag";
/**
* Check if a feature flag is enabled.
*
* @param flagName - The name of the feature flag to check
* @returns An Effect that resolves to true if enabled, false otherwise
*
* @example
* ```ts
* const enabled = yield* isFeatureEnabled("new-ui");
* if (enabled) {
* // use new UI
* }
* ```
*/
export function isFeatureEnabled(
flagName: string,
): Effect.Effect {
return Effect.gen(function* () {
// yield* extracts the service from the Effect context
const flags = yield* FeatureFlagServiceTag;
// Call the service method
return yield* flags.isEnabled(flagName);
});
}
// Alternative: if you want a function that conditionally runs code
export function whenFeatureEnabled(
flagName: string,
whenEnabled: () => Effect.Effect,
whenDisabled?: () => Effect.Effect,
): Effect.Effect {
return Effect.gen(function* () {
const enabled = yield* isFeatureEnabled(flagName);
if (enabled) {
return yield* whenEnabled();
} else if (whenDisabled) {
return yield* whenDisabled();
}
return undefined as T;
});
}
````
**Usage examples**:
**In core code** (`packages/core/src/agent/agent-runner.ts`):
```ts
import { Effect } from "effect";
import { isFeatureEnabled } from "../utils/feature-flag";
// Use the utility in core business logic
export function executeAgent(agentId: string) {
return Effect.gen(function* () {
// Effect.gen is Effect's equivalent of async/await
// yield* extracts values from Effects and services from the context
const enabled = yield* isFeatureEnabled("new-agent-strategy");
if (enabled) {
yield* runNewStrategy(agentId); // new feature path
} else {
yield* runLegacyStrategy(agentId); // fallback path
}
});
}
```
**In CLI code** (`packages/cli/src/commands/chat-agent.ts`):
```ts
import { Effect } from "effect";
import { isFeatureEnabled } from "../../core/utils/feature-flag";
export function chatWithAIAgentCommand(agentId: string) {
return Effect.gen(function* () {
// Check feature flag in CLI command
const useNewUI = yield* isFeatureEnabled("new-chat-ui");
if (useNewUI) {
yield* renderNewChatInterface(agentId);
} else {
yield* renderLegacyChatInterface(agentId);
}
});
}
```
**Using the conditional runner** (alternative pattern):
```ts
import { whenFeatureEnabled } from "../utils/feature-flag";
export function executeAgent(agentId: string) {
return whenFeatureEnabled(
"new-agent-strategy",
() => runNewStrategy(agentId), // when enabled
() => runLegacyStrategy(agentId), // when disabled
);
}
```
**Direct usage** (without the utility):
You can also use the service directly without a utility function:
```ts
// In core/ or cli/ code
import { Effect } from "effect";
import { FeatureFlagServiceTag } from "../interfaces/feature-flag";
const program = Effect.gen(function* () {
// Access the service directly via its tag
const flags = yield* FeatureFlagServiceTag;
// Call the service method
const enabled = yield* flags.isEnabled("new-feature");
if (enabled) {
// feature is enabled
}
});
// Run the program with the app layer that provides FeatureFlagService
const result = yield * program.pipe(Effect.provide(appLayer));
```
---
## 5. Testing — mock the service with Layer.succeed
Unit tests should not call the remote flag service. Provide a mock implementation with `Layer.succeed`.
```ts
// packages/adapters/src/feature-flag-service.test.ts
import { describe, it, expect } from "bun:test";
import { Effect, Layer } from "effect";
import { FeatureFlagServiceTag } from "@/core/interfaces/feature-flag";
const mockService = {
isEnabled: (name: string) => Effect.succeed(name === "beta-dashboard"),
rolloutPercentage: (name: string) => Effect.succeed(50),
};
it("uses mocked feature flag", async () => {
const program = Effect.gen(function* () {
const flags = yield* FeatureFlagServiceTag;
return yield* flags.isEnabled("beta-dashboard");
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(Layer.succeed(FeatureFlagServiceTag, mockService))),
);
expect(result).toBe(true);
});
```
Notes:
- **`Layer.succeed`**: Provides a mock service implementation directly. This is the simplest way to mock services in tests.
- **Test isolation**: Unit tests should never call external services. Always mock with `Layer.succeed` or similar.
- **Integration tests**: For integration tests, provide the real layer (from `createAppLayer`) but run against a test flag service or use a local stub server.
- **Effect.provide**: This pipes the mock layer into your program, making the service available in the Effect context.
---
## 6. Notes, safety, and checklist
Checklist before copying into production `src/`:
- [ ] Add configuration types to `packages/core/src/types/config.ts` (featureFlags config block)
- [ ] Add typed errors in `packages/core/src/types/errors.ts` if the service needs to surface structured failures
- [ ] Use LoggerServiceTag for structured logging inside the service (instead of console)
- [ ] Add unit tests and, optionally, integration tests that run against a test flag server
- [ ] Ensure no secrets (API keys) are committed to the repo — use env variables or secure stores
- [ ] Ensure feature-flag calls are safe and fail closed or open according to product policy (this example fails safe to `false`)
## FAQ
**Q: What if feature flags are not critical and their failure shouldn't break the app?**
A: Use conservative defaults (false/0) to avoid unexpected behavior. Consider adding metrics to detect when flags are unavailable.
For the directory structure, Effect patterns, and testing conventions this template follows, see the [Code map](./code-map.md).
---
## Skills loading — progressive disclosure
Source: https://jazz-cli.vercel.app/docs/internals/skills-loading.md
# Skills loading — progressive disclosure
This page explains how Jazz can have a hundred skills installed without paying for
them in every request.
Source:
[`tools/skill-tools.ts`](../../packages/core/src/agent/tools/skill-tools.ts) ·
[`core/skills/skill-service.ts`](../../packages/core/src/skills/skill-service.ts)
For what skills *are* and how to write one, see [Skills](../concepts/skills.md). This page
is about the loading mechanism.
---
## The problem
A skill is a markdown playbook — often long, sometimes with supplementary files. Preloading
every installed skill into the system prompt is the obvious design and it doesn't scale: a
hundred skills would consume the context window before the user has said anything.
Jazz solves it with three levels of detail, each fetched only when the previous level isn't
enough.
```mermaid
flowchart TB
L0["Level 0 · Always present
Skill index in the system prompt:
names + one-line descriptions
cost: a few hundred tokens total"]
L0 --> Q{"Enough to pick
a skill?"}
Q -->|"yes"| L2
Q -->|"no — need detail"| L1
L1["Level 1 · find_skills(query)
ranked matches with full descriptions
cost: one tool call"]
L1 --> L2
L2["Level 2 · load_skill(name)
the skill's full instructions
cost: one tool call + the skill body"]
L2 --> Q2{"Instructions reference
another file?"}
Q2 -->|yes| L3["Level 3 · load_skill_section(name, section)
one supplementary file
cost: one tool call + that file"]
Q2 -->|no| DONE(["Work"])
L3 --> DONE
classDef cheap fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef mid fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
class L0 cheap
class L2,L3 mid
```
Most runs stop at level 0 — the index is enough to decide no skill applies, or to name the
one that does and load it directly. You pay for depth only when depth is used.
---
## The three tools
| Tool | Input | Returns | Risk |
| -------------------- | --------------------------------------------- | ---------------------------------------------- | ----------- |
| `find_skills` | `query`, optional `limit` (max 10, default 5) | Ranked `name: description` lines | `read-only` |
| `load_skill` | `skill_name` | The skill's full instructions | `read-only` |
| `load_skill_section` | `skill_name`, `section_name` | One supplementary file referenced by the skill | `read-only` |
A detail worth noticing: **`skill_name` is a `z.enum` built from the actually-discovered
skill names**, not a free string. The model literally cannot hallucinate a skill name — an
invalid one is a schema violation caught before execution rather than a "skill not found"
round trip.
`find_skills` ranks with a scoring function over names and descriptions, and when nothing
matches it says so and points at `load_skill` for exact names, rather than returning an empty
result the model has to interpret.
---
## Where skills come from
Three sources, merged by precedence. Later wins, so you can shadow a built-in skill with
your own.
```mermaid
flowchart LR
B["1 · Built-in
shipped in the jazz-ai package
18 skills"]
G["2 · Global
~/.jazz/skills/
cached index"]
L["3 · Local
./skills/
project-specific"]
B --> M["Merged catalog
later source wins on name collision"]
G --> M
L --> M
M --> IDX["Skill index
→ system prompt"]
classDef win fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class L,M win
```
The global directory's index is cached at `~/.jazz/global-skills-index.json` so startup
doesn't re-scan a large skill library every time. Local skills are scanned per project, which
is what makes a checked-in `./skills/` directory work — clone the repo, get the team's
skills.
Jazz follows the [`.agents` convention](https://agentskills.io), so skills written for other
agents work here. `npx skills add` installs from the ecosystem; `/skills` in chat lists
what's available.
---
## Cost profile
| Scenario | Tool calls | Context cost |
| ------------------------------- | ---------- | ----------------------------- |
| No skill needed | 0 | the index only |
| Agent knows the skill by name | 1 | index + skill body |
| Agent needs to search first | 2 | index + matches + skill body |
| Skill pulls in a reference file | 3 | index + matches + body + file |
**The trade-off:** up to two extra round trips before the agent starts working. That's the
price of not spending the window on skills nobody asked for. When the agent already knows
the name from the index — the common case — it's one call.
The loaded body stays in the conversation as a tool result. It is not copied into the system
prompt on later turns: that prefix is cached, and mutating it on `load_skill` would miss on
every subsequent request. The Skills instructions tell the model to treat that tool result
as the playbook and execute it rather than improvise a shorter path.
---
## Related
- [Skills](../concepts/skills.md) — writing and installing skills
- [Tools & approval](./tools-and-approval.md) — the registry these live in
- [Design decisions](./design-decisions.md#progressive-skill-loading) — the trade-off stated plainly
---
## Sub-agents
Source: https://jazz-cli.vercel.app/docs/internals/subagents.md
# Sub-agents
This page explains how Jazz delegates, and why delegation is a context strategy
rather than a parallelism trick.
Source: [`tools/subagent-tools.ts`](../../packages/core/src/agent/tools/subagent-tools.ts)
---
## The point is isolation, not speed
A sub-agent gets **its own context window**. That's the whole idea. Deep research burns
100,000 tokens reading twelve sources — and if that happens in the parent's context, the
parent is compacting by iteration fifteen and has forgotten the task. Delegated, the same
work costs the parent one paragraph.
```mermaid
flowchart TB
subgraph without["Without delegation"]
direction TB
P1["Parent context"]
P1 --> R1["12 sources
100k tokens of raw pages"]
R1 --> P2["Parent context: 95% full
→ compacting
→ losing the plan"]
end
subgraph with["With a sub-agent"]
direction TB
Q1["Parent context"]
Q1 -->|"spawn_subagent(task)"| CHILD["Child context
12 sources · 100k tokens
discarded on return"]
CHILD -->|"one paragraph"| Q2["Parent context: 12% full
→ still on task"]
end
classDef good fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef bad fill:#c1443c,stroke:#7d2b26,color:#ffffff
class Q2,CHILD good
class P2 bad
```
---
## Spawning
```ts
spawn_subagent({
task: "Research the current state of WASM GC proposals. Return a 5-bullet summary with source URLs.",
name: "WASM researcher", // optional — label for the UI panel
persona: "researcher", // "default" | "coder" | "researcher"
});
```
| Field | Purpose |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `task` | The full brief, **including the expected output shape**. The child cannot see the parent's conversation, so an underspecified task produces an unusable answer. |
| `name` | Short role label shown in the sub-agent panel, so parallel children are distinguishable. |
| `persona` | `coder` for code and git work, `researcher` for investigation, `default` for general. |
## Structured results (experimental)
Most sub-agents return a concise text answer. For a parent that needs a dependable
machine-readable handoff, pass a JSON Schema in `resultSchema`:
```ts
spawn_subagent({
name: "Release researcher",
task: "Find the three most relevant releases this week.",
resultName: "release candidates",
resultSchema: {
type: "object",
additionalProperties: false,
required: ["candidates"],
properties: {
candidates: { type: "array", items: { type: "string" } },
},
},
});
```
Jazz tells the child to return exactly this envelope and validates `result` before
giving it to the parent:
```json
{
"summary": "Found three releases worth reviewing.",
"result": { "candidates": ["…"] }
}
```
On success, the parent receives the text `summary`, `structuredResult`, and child
duration/cost metadata. An invalid envelope or result returns a tool error with
validation details, so the parent can retry or change course rather than treating
unstructured prose as data. `resultSchema` is opt-in; calls without it preserve
the existing text-only return shape.
The schema must be a local JSON Schema object. Jazz caps schema and result sizes,
does not allow a root `$ref`, and validates it with its supported JSON Schema
subset. Use a file or database for large durable research artifacts; structured
results are for bounded handoffs, not a shared workspace.
When streaming `--events subagent`, Jazz also emits `subagent_result` for a
structured call. It records the child id, duration, known/unknown cost, and
whether validation succeeded—never the result payload itself.
A sub-agent always runs as the parent agent itself — same provider, same model, same config —
varying only the persona. Delegating to a _different_ saved agent, or running the child on a
different model, is deliberately not offered.
---
## The tool ceiling
A child never holds a tool its parent lacks. `spawn_subagent` passes the parent's effective
tool names down as the child's allowlist, and the child's own toolset is intersected with it
after personas and built-in categories resolve.
```mermaid
flowchart LR
P["Parent
read_file · grep · spawn_subagent"]
N["Persona 'coder'
read_file · grep · execute_command"]
P -->|"persona: 'coder'"| C["Child
read_file · grep · spawn_subagent"]
N -.->|"intersected"| C
classDef good fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class C good
```
The parent chooses the child's persona, and a persona resolves its own built-in tool
categories — so without the intersection, a read-only CI reviewer could reach
`execute_command` by spawning a child under a broader persona. This is what keeps the claim in
[Agents](../concepts/agents.md) true: that omitting a tool is the strongest safety control
available. Withheld tools are logged at `info`.
Because the ceiling lives in the runner rather than at the call site, it holds for any future
caller of a nested run, not just `spawn_subagent`.
---
## Nesting depth
A child gets its **own** iteration budget — `maxSubagentIterations`, default 30 — not its
parent's remainder. A sub-agent spawned on the parent's last iteration would be useless with one
round to work in, and the point of delegation is a clean context with room to use it. It is set
well below a top-level run's 100 because a sub-agent answers one scoped task; a child still
working after 30 iterations has usually misunderstood the brief rather than found more to do.
That means the parent's remaining budget bounds nothing. Depth does. Each run carries how many
levels sit above it, and `spawn_subagent` refuses once the limit is reached:
```text
Sub-agent nesting limit reached (depth 3 of 3). Do this task yourself instead of delegating it further.
```
It refuses rather than silently running the child at the wrong depth: a parent told its
delegation was declined can do the work itself, whereas one handed a child that quietly ignored
the limit cannot tell. Refusal is a normal tool error the parent can act on in its next
iteration, and no sub-agent panel opens.
The limit is `maxSubagentDepth` in `~/.jazz/config.json` (or `./.jazz/config.json`), defaulting
to **3** — enough for orchestrator → specialist → helper:
```json
{
"maxSubagentDepth": 3
}
```
Set it to `0` to stop agents delegating at all. The runner resolves the value once per run, so
every level of a tree obeys the same number.
Breadth is bounded separately: `MAX_CONCURRENT_TOOLS` (10) caps how many tool calls — sub-agents
included — run at once within a single iteration.
Because the parent can issue several tool calls in one iteration, sub-agents run in parallel
up to the concurrency cap — each with its own panel in the TUI.
```mermaid
sequenceDiagram
autonumber
participant P as Parent agent
participant C1 as Sub-agent: researcher
participant C2 as Sub-agent: coder
participant M as Metrics
P->>C1: task: "research WASM GC"
P->>C2: task: "audit our wasm bindings"
Note over C1,C2: independent contexts,
independent iteration budgets,
30-minute ceiling each
C1-->>P: 5-bullet summary
C1->>M: recordChildCost(0.08)
C2-->>P: list of findings
C2->>M: recordChildCost(0.11)
Note over P: parent context grew by
two paragraphs, not 200k tokens
```
---
## What crosses the boundary
| Crosses in | Crosses out | Never crosses |
| ---------------------------------- | --------------------------------- | -------------------------------- |
| The `task` string | The child's final answer | The parent's message history |
| The chosen persona | Its cost, into the parent's total | The parent's tool results |
| The agent's provider/model | | The child's intermediate work |
| The parent's toolset, as a ceiling | | Any tool the parent itself lacks |
**Cost rolls up.** Each child reports spend via `recordChildCost`, and the parent's
`costUSD` is its own tokens plus all child cost. A run on a free local model that delegated
to a cloud model still reports a real number.
**The isolation cuts both ways.** A child that needed a detail from the parent's history
can't see it. Put everything it needs in `task`. This is the main failure mode, and the
usual symptom is a confidently wrong answer to a question the child misunderstood.
---
## Limits
| Limit | Value | Why |
| ------------ | -------------------------------- | -------------------------------------------------------------------------------- |
| Timeout | 30 min | A delegated task that hasn't finished in half an hour isn't going to |
| Iterations | 30, via `maxSubagentIterations` | Its own budget, not the parent's remainder — and far below a top-level run's 100 |
| Nesting | 3 levels, via `maxSubagentDepth` | Depth is what bounds total spend, since each level gets a fresh budget |
| Toolset | at most the parent's | A child must never be an escalation path |
| Panel height | 12 lines | UI only |
Budget pressure interacts deliberately with delegation: at 70% of its iteration budget the
parent is told to **stop spawning new research sub-agents** and start consolidating. Without
that, a parent can spend its whole budget delegating and never write the answer — see
[Agent loop](./agent-loop.md#guard-1--budget-pressure).
---
## `summarize_context` — the other context tool
Registered alongside `spawn_subagent`, `summarize_context` lets the agent compact its _own_
history on purpose rather than waiting for the automatic 80% threshold. Useful when it knows
it's about to go deep and would rather enter that phase with a clean window.
Automatic compaction is the safety net. This is the deliberate version. See
[Context management](./context-management.md).
---
## When delegation is the wrong call
- **Small, well-scoped work.** Two extra round trips and a fresh context cost more than just reading the file.
- **Anything needing the conversation so far.** If the task can't be written down without "as we discussed", the child will misunderstand it.
- **Work that must mutate shared state in order.** Parallel children have no coordination between them.
---
## Related
- [Context management](./context-management.md) — the other half of the context strategy
- [Agent loop](./agent-loop.md) — how the parent's budget bounds delegation
- [Personas](../concepts/personas.md) — what `coder` and `researcher` change
---
## Threat model
Source: https://jazz-cli.vercel.app/docs/internals/threat-model.md
# Threat model
**Status: draft — v1 to be dated and published. Items marked ☐ are self-audit checks still
to be run before any public safety claim.**
Jazz's safety stance is *fail closed by construction*: when the agent, the classifier, or
the operator hasn't explicitly widened what may run, the answer is "ask a human." This
document lists what that means concretely, what it does **not** protect against, and how to
check both.
---
## What runs without asking
One dial — the approval policy — governs every surface the same way (terminal, CI, bots):
| Policy | Auto-approves |
| ----------- | --------------------------------------------------- |
| `false` | Nothing. Every gated tool asks. |
| `read-only` | Reading files, search, web requests |
| `low-risk` | + todo tracking, reminders, spawning sub-agents |
| `high-risk` | + file changes, shell commands, git commit and push |
Everything above the dial blocks and emits `approval_required`; the run resumes only when
an `approval_decision` comes back — from the terminal, or from your phone via a chat
bridge. An unattended run that hits its ceiling waits; it does not die and it does not
proceed. Source: [tools-and-approval](./tools-and-approval.md).
Two sharper controls sit under the dial:
- **Toolset omission.** An agent's tool list is explicit; a tool not listed does not exist
for that agent. An agent without `execute_command` cannot run shell commands regardless
of policy. This is the strongest control and the recommended one for anything unattended.
- **`autoApprovedCommands`.** A persisted allowlist that admits single commands without
raising the whole tier. Matching is a parsed key (binary + first subcommand) with
word-boundary comparison — `git status` does not also permit
`git status && rm -rf /`. Source: `packages/core/src/agent/tools/command-risk.ts`,
[configuration](../reference/configuration.md).
## Shell commands fail closed
Commands with no static risk annotation are classified before approval. The classifier's
instruction is explicit: *"high-risk = anything else, including uncertainty"* and *"a
clearly mutating command is high-risk even if the conversation asked for something
milder."* Text inside the command is treated as data to classify, never as instructions.
An ambiguous command on an unattended run therefore blocks rather than runs.
Source: `packages/core/src/agent/tools/command-risk.ts`.
## Where secrets live
- **API keys** are stored in the OS keyring, not in config files
(`packages/adapters/src/secrets/keyring.ts`; `JAZZ_DISABLE_KEYRING` opts out).
- **Child processes are scrubbed.** Any environment variable whose name matches
`API|KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH` is stripped before a shell command or
custom command tool spawns. Exceptions require an explicit per-agent `envAllowlist`
entry; `SSH_*` names can never be allowlisted. Source:
[configuration → envAllowlist](../reference/configuration.md).
- **Known weakness, stated plainly:** conversation transcripts are plaintext JSON under
`~/.jazz/history/`. Treat that directory as sensitive. Anything the agent read during a
run may be in there.
## Network posture
- The CLI opens **no listening port**. `jazz run` is a process that starts, works, and
exits.
- The Telegram bridge defaults to **long-polling** (`getUpdates`): outbound connections
only, no public URL, works behind NAT. Webhook mode exists but is opt-in and requires a
secret. One caveat stated honestly: the bridge container always runs a minimal `/health`
HTTP endpoint for container health checks. Source: `packages/telegram-bot/bridge.ts`.
- `JAZZ_OFFLINE=1` stops every outbound request Jazz makes on its own behalf except model
inference itself. Source: [airgapped](../start/airgapped.md).
## Runaway protection
Unattended runs are budgeted, not trusted: an iteration ceiling with escalating wrap-up
pressure, loop detection keyed on tool-name *plus arguments*, context compaction, and cost
reported on every run. Source: [agent-loop](./agent-loop.md).
## What Jazz does NOT protect against
Claiming less is part of the model. Jazz does not currently defend against:
- **Prompt injection steering permitted actions.** Content the agent reads (a web page, an
email, a PR body) can influence what it does *within* its approved tier. The mitigations
are structural — narrow toolsets, low tiers for unattended runs, approval walls for
everything mutating — not content analysis.
- **A hostile deployment operator.** `customTools` command handlers run what the
deployment configured; they are deployment-authored trust, always registered high-risk.
- **A compromised model provider.** Inference traffic goes to whichever provider you
configured. Local models via `ollama` remove that dependency.
## Self-audit checklist (run before every public safety claim)
Failure classes below are the ones that burned other agent deployments in 2026. Each gets
re-checked, on the released binary, before we say the word "safe" anywhere:
- ☐ Fresh install: no plaintext key written anywhere under `~/.jazz` when the keyring is
available.
- ☐ Bridge `.env` handling: bot token never logged, never echoed into transcripts.
- ☐ Default agent toolset after the wizard: confirm `execute_command` posture and that the
default approval policy asks before mutations.
- ☐ `read-only` tier semantics: enumerate exactly which outbound requests it permits, and
document that list.
- ☐ Port scan of a default `docker compose up` bridge: nothing listening except `/health`.
- ☐ `jazz bench safety` tripwire suite passes 10/10 on the release candidate (planned —
see the eval harness).
- ☐ Webhook mode: secret required, requests without it rejected.
## Reporting
Vulnerabilities: see [SECURITY.md](../../SECURITY.md). Reports that demonstrate any checked
item above failing are treated as release blockers, not enhancements.
---
## Tools & approval
Source: https://jazz-cli.vercel.app/docs/internals/tools-and-approval.md
# Tools & approval
This page explains how a tool call becomes an action, and what stands between the
two.
Source:
[`execution/tool-executor.ts`](../../packages/core/src/agent/execution/tool-executor.ts) ·
[`tools/tool-registry.ts`](../../packages/core/src/agent/tools/tool-registry.ts) ·
[`tools/register-tools.ts`](../../packages/core/src/agent/tools/register-tools.ts) ·
[`tools/register-mcp-tools.ts`](../../packages/core/src/agent/tools/register-mcp-tools.ts) ·
[`types/tools.ts`](../../packages/core/src/types/tools.ts)
---
## The lifecycle of one tool call
### Operator shell escapes
Interactive terminal users can explicitly run a command with `! `. This path runs
before the model turn and feeds the bounded result back as tagged command context. It shares
the shell executor's working-directory resolution, sanitized environment, timeout, interruption
handling, denylist, and stdout/stderr caps. Because the operator authored the command directly,
the `!` path is an explicit operator action rather than a model-issued approval request; it does
not make the command safe or weaken the denylist. Shell output is untrusted data and must be
treated as context, not as instructions. This syntax is available only in the interactive
terminal; headless and remote surfaces retain their own authorization contracts.
```mermaid
flowchart TD
IN(["Model emits a tool call"]) --> PARSE{"Arguments
valid JSON?"}
PARSE -->|no| ERR["Return an error result —
the agent can retry"]
PARSE -->|yes| LOOKUP["Look up in the registry:
schema · risk level · timeout"]
LOOKUP --> RUN["Invoke the tool
timeout: per-tool, else 3 min
(longRunning tools: no timeout)"]
RUN --> GATED{"Result is an
approval request?"}
GATED -->|"no — read-only tool"| RESULT
GATED -->|yes| POLICY{"Auto-approved?"}
POLICY -->|"policy tier covers
this risk level"| EXEC
POLICY -->|"per-tool allowlist"| EXEC
POLICY -->|"per-command allowlist"| EXEC
POLICY -->|"no — ask"| PROMPT["Approval prompt
args + preview diff"]
PROMPT -->|approve| EXEC
PROMPT -->|deny| DENIED["Return a refusal —
the agent reasons around it"]
EXEC["Execute the real tool
the second half of the pair"]
EXEC --> RESULT["Format for context
+ record metrics"]
RESULT --> OUT(["Result → next iteration"])
classDef gate fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef act fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class POLICY,PROMPT gate
class RUN,EXEC act
```
---
## Two-phase execution
A gated tool doesn't act when the model calls it. It returns a description of what it
_would_ do:
```mermaid
sequenceDiagram
autonumber
participant M as Model
participant P as write_file
(propose)
participant G as Gate
policy or human
participant E as write_file_execute
M->>P: write_file({path, content})
P->>P: resolve the path, read the current file,
compute a diff — no mutation
P-->>G: ApprovalRequired{message, previewDiff,
executeToolName, executeArgs}
alt policy covers this risk tier
G->>E: execute immediately
else needs a human
G->>G: show args + diff, wait
G->>E: execute on approval
end
E-->>M: result
```
**Why a pair rather than a `dangerous: true` flag.** You cannot show a useful preview
without doing the work — computing a diff means reading the target and resolving the path.
Two phases let the propose step do real work and _still_ not mutate anything, which is what
makes "here is the exact diff, approve?" possible.
**What it buys.** Interactive and unattended runs go down the _same_ path. The only
difference is who answers the gate. There is no separate headless mode to drift out of sync
with the interactive one.
### Picker-style approvals
Most gates are yes/no. Some decisions are "_which one_" — `analyze_media` asks which
image-capable model should do the looking. A proposal may carry `options` alongside its
message; surfaces then render a picker (same card, rows instead of Yes/No), and the chosen
row's id returns on the outcome as `selectedOptionId`, merged into the execution tool's
args under `_selectedOptionId` — a key the model never writes and cannot spoof.
Two properties are load-bearing:
- **A request with options is never auto-approved**, under any policy including yolo.
There is nothing to approve until somebody picked a row. The pre-bound path
(`config.companions`) skips approval inside the tool itself instead — binding is the
consent. Bindings are keyed by companion role — `":"` —
because reading a modality and producing it are different models: `analyze:image` and
`generate:image` bind independently on the same agent.
- **Unattended runs fail loudly when nothing is bound.** Nobody to ask plus no standing
consent means a clear refusal naming what would fix it ("bind an `analyze:image`
companion"), not a silent fallback to whichever model happens to be first.
---
## Risk tiers
Every tool declares a level. One dial decides what runs without asking.
```mermaid
flowchart LR
subgraph tiers["Tool risk levels"]
direction TB
RO["read-only
read_file · grep · find · ls
web_search · web_fetch · http_request"]
LR["low-risk
manage_todos
spawn_subagent"]
HR["high-risk
write_file · edit_file · rm
mv · cp · mkdir"]
UN["unknown
execute_command"]
end
subgraph policies["--approval-policy"]
direction TB
P0["unset / false
read-only + low-risk"]
P1["read-only"]
P2["low-risk"]
P3["high-risk"]
end
P0 -.->|approves| RO
P0 -.->|approves| LR
P1 -.->|approves| RO
P2 -.->|approves| RO
P2 -.->|approves| LR
P3 -.->|approves| RO
P3 -.->|approves| LR
P3 -.->|approves| HR
P3 -.->|approves| UN
UN -.->|classified as| RO
UN -.->|classified as| LR
UN -.->|classified as| HR
classDef safe fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
classDef warn fill:#f9a03f,stroke:#b3541e,color:#1a1a1a
classDef bad fill:#c1443c,stroke:#7d2b26,color:#ffffff
classDef muted fill:#6b7280,stroke:#374151,color:#ffffff
class RO safe
class LR warn
class HR bad
class UN muted
```
The policy is read through a **getter**, not captured once, so a mid-run change takes effect
immediately — that's how Shift+Tab mode switching works in the TUI, and why a tool queued
behind another can pick up a policy that changed while it waited.
### Command classifier
`execute_command` is declared `unknown` because the command decides the blast radius. Jazz
asks the cheap harness model (`summarizerModel`, else the agent's own) whether this
particular command is `read-only`, `low-risk`, or `high-risk`, and the tier then applies to
the verdict as it would to any declared level. So `--approval-policy read-only` runs
`git log` unattended without also unlocking `rm`, and an interactive session skips the
prompt for a listing but still asks about a push.
The classifier is skipped when it cannot change anything: yolo approves either way, the
command is already allowlisted, or the level was never `unknown`. Everywhere else it runs,
including on surfaces that cannot prompt — an unclassified command stays `unknown`, which
approves nowhere, so skipping it there would park a run on `git status`.
While it runs, the live zone shows `classifying` on that command (the round-trip can take a
few seconds). The verdict then lands on the settled receipt — `read-only`, `low-risk`, or
`high-risk` — so an auto-approved inspect-only command is visibly why it ran without a
prompt. The raw classifier prompt is not shown: it includes recent user requests, and the
token is the decision.
Fail closed: timeouts, provider errors, empty replies, and anything other than the exact
token `read-only` or `low-risk` stay `high-risk`. A clearly mutating command stays
`high-risk` regardless of context.
**What the classifier is allowed to read.** The command, always. Plus the last five _user_
requests (hard-capped at 800 characters) when the session is interactive, so an ambiguous
command can be lowered only when the person at the keyboard asked for that milder action.
Two exclusions are deliberate:
- **Assistant turns are never included.** The agent proposing the command also writes those
turns, so quoting them back would let a model that has been talked into something by a web
page or a tool result supply its own corroborating evidence.
- **No conversation at all on a bridge or a headless run.** There the "user" turns come from
whoever is messaging the bot, and corroboration from a stranger is not corroboration.
Both blocks are wrapped as tagged data with `<` escaped, so the model is told not to follow
instructions inside them and cannot close the tag early. A wrong milder verdict is still
possible; the shell denylist is the backstop for known-destructive patterns, not for
`git push`.
Each classifier call is recorded as `llm_usage` with `purpose: "classifier"` (tokens in/out
and wall-clock latency) and rolled into `classifierUsage` on `agent_run_completed`. Those
numbers stay beside the agent-loop `usage` — they are not mixed into it — so a run shows how
much was approval gating versus the conversation. See [Observability](../start/observability.md).
Implementation: [`command-risk.ts`](../../packages/core/src/agent/tools/command-risk.ts).
### Two sharper controls
Tiers are coarse on purpose. When you need precision:
| Control | Scope | Behavior |
| ------------------------- | ------------ | ----------------------------------------------------------- |
| **Per-tool allowlist** | this session | "Always approve this tool" — chosen from an approval prompt |
| **Per-command allowlist** | persisted | "Always approve this command" — `execute_command` only |
The command allowlist does **not** prefix-match raw strings. It extracts an approval key
(binary + first subcommand) and matches exactly or on a word boundary:
```text
approved: "git status"
✅ git status
✅ git status --short
❌ git statusfoo
❌ git status && rm -rf / ← the reason prefix matching was rejected
```
The strongest control isn't a policy at all: **an agent whose toolset omits
`execute_command` cannot run shell commands regardless of tier.** Trim the toolset in the
agent config when the blast radius matters — see [Chat platforms](../use-cases/chat-platforms.md#security-for-chat-surfaces).
---
## Concurrency and timeouts
```mermaid
flowchart TB
BATCH(["Model requested 6 tool calls"]) --> META["Pre-fetch metadata for
unique tool names
(parallel, ≤10)"]
META --> FORK["Fork all 6 as fibers
≤10 running concurrently"]
FORK --> JOIN{"Race"}
JOIN -->|"all complete"| RESULTS(["6 results"])
JOIN -->|"interrupt signal
(double-Esc)"| KILL["Interrupt every fiber
settle UI · stop the loop"]
classDef act fill:#4f9d9d,stroke:#2f6d6d,color:#ffffff
class FORK act
```
| Setting | Value | Notes |
| ---------------------- | ---------- | ---------------------------------------------------------------- |
| `MAX_CONCURRENT_TOOLS` | 10 | Prevents resource exhaustion when a model asks for 40 file reads |
| `TOOL_TIMEOUT_MS` | 3 min | Default; a tool can declare its own |
| `longRunning` tools | no timeout | e.g. `ask_user_question` — waiting for a human isn't a hang |
A timeout is **not** a crash. It comes back as a failed result with the message, the agent
sees it, and the run continues. A tool that can't finish shouldn't take the whole run down.
Approval prompts are queued rather than raced, and re-checked at dequeue time — a parallel
tool's "always approve" may have changed the answer while this one waited.
Double-Esc during a running tool is a **clean stop**, not a crash. In-flight tools get a
cancelled receipt, `execute_command`'s child process is killed, dangling `tool_calls` in
the transcript get a matching tool-result so the next turn stays valid, and the loop
exits the same way an LLM-stream interrupt does.
---
## Two shell-specific defenses
`execute_command` gets two protections beyond the approval gate, both in
[`shell-tools.ts`](../../packages/core/src/agent/tools/shell-tools.ts).
### A 56-pattern denylist
Commands are matched against a denylist _before_ execution — privilege escalation (`sudo`,
`su`), filesystem destruction (`rm -rf /`), remote code execution (`curl … | sh`),
power/runlevel changes (`shutdown`), and reads or copies of `/etc/passwd`, `/etc/shadow`,
`/etc/sudoers`. A blocked command returns the specific reason, so the agent learns why rather
than retrying blindly.
It carries one carve-out worth knowing: `tmp="$(mktemp -d)"; …; rm -rf "$tmp"` passes, because
temp-dir cleanup is routine and blocking it trains users to disable the denylist. `rm -rf`
against a real path — or a mix of temp and real paths — still blocks.
**This is explicitly not a sandbox.** From the implementation:
> It cannot stop a determined attacker — variable expansion, base64 obfuscation, eval, and
> other indirection paths can route around any string matcher.
Its purpose is catching an accident from a confused model. Approval is the real control, and
container isolation is the real boundary. The known bypasses are documented as a regression
suite in [`shell-tools.security.test.ts`](../../packages/core/src/agent/tools/shell-tools.security.test.ts) —
worth reading before you rely on the denylist for anything.
### Environment sanitization
Shell commands do not inherit your full environment. Variables whose _names_ match
`API|KEY|SECRET|TOKEN|PASSWORD|CREDENTIAL|AUTH` (case-insensitive), plus everything prefixed
`SSH_`, are stripped before the command runs — so a command that echoes its environment cannot
exfiltrate your provider keys.
When a command genuinely needs one, an agent's `envAllowlist` exempts specific names. The
allowlist can only un-hide a variable that already exists in the parent environment; it never
invents a value, and the `SSH_` block applies regardless. Implementation:
[`env.ts`](../../packages/core/src/utils/env.ts).
---
## The tool registry
Tools are registered by category at startup, except MCP:
| Category | Tools | Examples |
| ---------------------- | ------- | ----------------------------------------------------------------------------------------------------- |
| File Management | 15 | `read_file` `write_file` `edit_file` `find` `grep` `read_pdf` `pdf_page_count` `mkdir` `rm` `mv` `cp` |
| Shell Commands | 1 | `execute_command` (`unknown`; classified before the approval decision) |
| Web Search / Web Fetch | 2 | `web_search` `web_fetch` |
| HTTP | 1 | `http_request` |
| Todo | 2 | `manage_todos` `list_todos` |
| Memory | 2 | `view_memory` `manage_memory` |
| Reminders | 3 | `add_reminder` `list_reminders` `cancel_reminder` |
| Context | 3 | `context_info` `get_time` `retrieve_tool_result` |
| Sub Agents | 2 | `spawn_subagent` `summarize_context` |
| User Interaction | 2 | `ask_user_question` `ask_file_picker` |
| Web App | 1 | `create_web_app` |
| **Total agent-facing** | **35** | plus 7 hidden `execute_*` counterparts |
| **Skills** | 3 | `find_skills` `load_skill` `load_skill_section` — per agent |
| **MCP** | dynamic | `mcp__` — per agent, connected lazily |
**MCP is lazy by design.** Servers are child processes; connecting six of them at boot makes
`jazz` slow to start and hangs the CLI when one misbehaves. Instead, an agent's MCP tools are
registered from its tool list and the server connects on first invocation. Connected servers
are tracked so they can be cleaned up when the conversation ends.
**Captured process output is bounded.** `execute_command` and find/grep each keep at
most 256 KB of stdout and 256 KB of stderr, collected as bytes so a flood cannot grow until
the timeout. Truncated `execute_command` streams include a marker. The grep and find
parsers keep stdout clean (so a partial last line is not treated as a path or match) and set
`truncated` on the tool result — without that flag a cut-short enumeration is
indistinguishable from an exhaustive one. Custom `command` tools use the same collector with
a 16 KB cap — they are typically small, trusted argv programs, not a general shell.
**MCP argument schemas are advisory, not a gate.** `convertMCPSchemaToZod` is lossy — `$ref`
is unresolved and an untyped property carries no constraint — so MCP tools forward the
model's arguments untouched and let the server's own schema reject bad calls. Validating
locally against the converted schema would reject or silently empty calls the server accepts.
Builtin tools, whose schemas are authored alongside their handlers, are validated normally.
Current tool list: [Tools reference](../reference/tools.md).
---
## Related
- [Agent loop](./agent-loop.md) — where the tool phase sits
- [Headless](../use-cases/headless.md) — setting the policy for unattended runs
- [Security](../../SECURITY.md) — the threat model and hardening guidance
- [Design decisions](./design-decisions.md#risk-tiers-instead-of-a-tool-allowlist) — why tiers, why two phases
---
## Interface Design
Source: https://jazz-cli.vercel.app/docs/design.md
# Interface Design
The visual language of the Jazz terminal interface: one mark, one accent, six
hues, and the rules that decide every case not covered explicitly.
Jazz is a conversation, not an instrument panel. The answer is the point and
everything else is apparatus, so the design is measured against legibility
rather than density.
The tables below are generated from the modules that define them — run
`bun run docs:design` after changing [`theme.ts`](../../packages/cli/src/ui/theme.ts) or
[`glyphs.ts`](../../packages/cli/src/ui/glyphs.ts). A design document that restates hex
values by hand starts drifting the first time someone tunes a colour, and a
drifted design doc is worse than none.
---
## What is shipped, and what is specified
This document covers both, and says which is which.
**Shipped.** The fullscreen single-column layout, live zone, approval card,
renderer-neutral prompts, in-app history search, both palettes, and the glyph and
emphasis rules are in the code and under test. Unsupported terminals use the
append-only interface; an OpenTUI startup failure or an Ink-only workflow hands
the session to the complete legacy interface rather than leaving an inert frame.
**Still staged.** Search finds and browses persisted session matches, but does
not yet reopen a selected historical session. Copy-out and the command palette
are intentionally absent from the key legend until their actions exist.
---
## The two laws
### Colour is semantics
Every hue answers the question *"what is this?"* — who is speaking, is this a
tool, did it work, should I worry, is this about to touch my real accounts. That
is six questions, so six hues. Everything else — headings, rules, borders,
labels, timestamps, paths — lives on the neutral ramp.
Six is a set you can hold in your head, which is the point: after an hour in the
app you read colour without deciding to.
Two consequences worth stating, because both were bugs before:
- **Emphasis is not a hue.** Bold text, headings and table chrome get weight,
not colour. Emphasis does not answer "what is this?", and the accent means
*live* everywhere else, so spending it on bold prose would actively mislead.
Hierarchy comes from stroke weight (`▏▎▍▌`), rule weight (`─ ━`), shade
density (`░▒▓█`), and indentation.
- **There is one accent.** Who is speaking is carried by the marker glyph, not
by giving each party its own colour. Previously brand, warning and inline code
were all the same amber, so a bulleted list with bold text and a code span
rendered as a wall of orange.
### Motion is allowed where text is not
While the model is silent the interface may move — that is the one moment when
motion is pure information. The instant a token lands, everything except the
status line goes still, and the only thing changing in the frame is the sentence
being read.
Animation runs at roughly 6 frames per second, and is replaced rather than merely
slowed when there is no TTY: a spinner written to a pipe produces thousands of
junk frames, so headless emits one line per state transition instead.
---
## Identity
The mark is `▞` (U+259E). Two filled squares offset off the grid: syncopation,
which is the actual musical content of jazz, rather than a picture of a musical
note. The ascending diagonal is also the stroke of a **z**.
It is chosen for three measurable properties as much as for its shape:
- **Block Elements is one of only two Unicode ranges with full coverage** in
Menlo, SF Mono, Consolas, DejaVu Sans Mono and JetBrains Mono.
- It is East-Asian **Neutral**, so it occupies exactly one column in every
terminal and every locale.
- Modern terminals draw that range **procedurally**, as exact rectangles rather
than font glyphs, so it tiles seamlessly at any size.
The same range generates the family: `▞▚` for call and response, `▖▘▝` for an
ensemble, `▙▚▖` for a lead voice with the section behind it.
### Wordmark
```text
▄▀▀▄▀▄▄▀▀▄▄▀▄▀▀▄▀▀▄▀▄▄▀▀▄▄▀▄▀▀▄▀▀▄▀▄▄▀▀▄
▞ jazz
One agent. Every surface. Your rules.
```
The ornament is not decoration and not the name spelled a second time. Upper
half-cells and lower half-cells are two independent rhythmic voices sharing one
line of text — a five-cell figure against a three-cell one, so the pattern never
settles into a square loop.
---
## Glyphs
Two things decide whether a glyph is safe, and both were measured rather than
assumed. **Coverage**, from the `cmap` tables of the fonts people actually use:
| Block | Menlo | SF Mono | Courier |
| --- | --- | --- | --- |
| Box Drawing U+2500–257F | 128/128 | **128/128** | 0/128 |
| Block Elements U+2580–259F | 32/32 | **32/32** | 0/32 |
| Geometric Shapes U+25A0–25FF | 96/96 | **14/96** | 1/96 |
| Arrows U+2190–21FF | 112/112 | **11/112** | 0/112 |
| Misc Technical U+2300–23FF | 117/256 | **7/256** | 0/256 |
| Misc Symbols U+2600–26FF | 149/256 | **0/256** | 1/256 |
| Dingbats U+2700–27BF | 144/192 | **15/192** | 0/192 |
| Braille U+2800–28FF | **0/256** | **0/256** | **0/256** |
And **width**, from Unicode's East Asian Width data: an *Ambiguous* glyph
occupies two columns in a CJK-width locale and one everywhere else, so it
silently doubles its footprint.
Three consequences drive every choice:
1. Box Drawing and Block Elements are the only ranges with full coverage
everywhere. Dingbats (`✓ ✗ ❯`), Geometric Shapes (`◆ ◐ ● ○`), Arrows and
Misc Symbols (`♪`) are not safe — SF Mono, the default macOS coding font, is
missing most of them and substitutes a fallback at a mismatched advance width.
2. **Braille has zero coverage in every target font.** Only DejaVu ships it, so
every braille spinner in the ecosystem is drawn by fallback — which is where
the familiar right-hand gap comes from.
3. Within Block Elements the *quadrants* (`▖▗▘▝▚▞▙▛▜▟`) plus `▐ ░` are
East-Asian Neutral: exactly one column in every locale. The eighth-block
ladder and shading are Ambiguous, so they appear only where nothing aligns
beneath them.
Status marks are Box Drawing stubs, so they read as weight rather than as
pictograms: `╺` is heavier than `╴`, which is the distinction being drawn anyway.
| Glyph | ASCII | Meaning |
| --- | --- | --- |
| `▞` | `*` | the mark |
| `»` | `>` | you are speaking |
| `╶` | `-` | the agent is speaking |
| `▐` | `\|` | the agent is asking for authority |
| `╺` | `+` | a tool succeeded |
| `╻` | `x` | a tool failed |
| `╵` | `!` | needs attention, not broken |
| `╴` | `o` | not started |
| `╺` | `*` | connected and live |
| `╹` | `+` | a delegated lane closing |
| `▎` | `\|` | speaker rail |
| `▏` | `:` | one level deeper |
| `▏` | `>` | quoted or subordinate text |
| `∙` | `*` | list item |
| `─` | `-` | rule |
| `━` | `=` | heavy rule, and the filled run of a meter |
| `█` | `#` | context used |
| `░` | `.` | context free |
`packages/cli/src/ui/glyphs.test.ts` enforces this: every character in the Unicode set
must come from a verified-safe range, and the glyphs that were previously
shipping from unsafe ranges are named so they cannot return.
---
## The activity indicator
Five lanes, each resting and then playing a three-step burst on its own period.
The point is that it can **count**. A generalist agent's characteristic state is
several things in flight at once — reaching into a mailbox, a search and a
calendar simultaneously — and a single rotating glyph cannot express that. A
longer period means a longer rest, so the number of moving lanes tracks how much
work is actually happening.
| Property | Value |
| --- | --- |
| Lane periods | 3, 4, 5, 7, 11 frames |
| Burst | `▖▚▘` — opening, live, closing |
| At rest | `░` |
| Cycle before repeating | 4620 frames, about 13 minutes at 170ms |
The periods are pairwise coprime, which matters: a previous version used 4, 6, 3,
4, 6, so the composite looped every 12 frames — about two seconds — and the two
pairs of equal periods were locked together permanently.
Two properties hold for every frame, and both are guaranteed by using periodic
oscillators rather than a cellular automaton: no frame is ever entirely at rest,
and full alignment of all five lanes happens in 3 frames out of 4620. An activity
indicator that can appear frozen is broken.
---
## Palette
Every value has an exact xterm-256 index. The accent is index 45 exactly, so it
is byte-identical over SSH rather than approximated by a downgrade.
| Token | dark | Role |
| --- | --- | --- |
| `canvas` | `#0B0D10` | the window's own ground |
| `primary` | `#00D7FF` | live, and your own affordances |
| `agent` | `#00D7FF` | live agent identity — the same accent, because the glyph says who |
| `accentDim` | `#00AFD7` | subordinate live content, links, citations |
| `link` | `#00AFD7` | |
| `success` | `#5FD787` | it worked |
| `error` | `#FF6B6B` | it broke |
| `warning` | `#D7AF5F` | a scope worth noticing |
| `info` | `#A9B2BD` | on the neutral ramp — info is not a hue |
| `selected` | `#E8EBEF` | primary text |
| `prompt` | `#00D7FF` | |
| `secondary` | `#A9B2BD` | secondary text |
| `muted` | `#5C6673` | metadata, settled receipts, timestamps |
| `reasoning` | `#00AFD7` | live, but subordinate to an answer |
| `toolBorder` | `#22272E` | |
| `surface` | `#14171B` | |
| `surfaceSoft` | `#14171B` | |
| `surfaceStrong` | `#22272E` | |
| `border` | `#22272E` | |
| `borderSoft` | `#22272E` | |
| `syntaxStructure` | `#9B8CFF` | keywords and structure |
| `syntaxValue` | `#D787AF` | strings, numbers, and inline code |
| `syntaxType` | `#92B4C8` | types and constructors |
The light palette is not an inversion. The accent has to carry real contrast
against paper, so cyan darkens to a teal that still reads as the same role, and
the syntax tints are re-chosen rather than merely darkened — on paper they have
to separate by hue rather than by lightness.
| Token | light | Role |
| --- | --- | --- |
| `canvas` | `#FBFCFD` | the window's own ground |
| `primary` | `#00718F` | live, and your own affordances |
| `agent` | `#00718F` | live agent identity — the same accent, because the glyph says who |
| `accentDim` | `#005F87` | subordinate live content, links, citations |
| `link` | `#005F87` | |
| `success` | `#116B3E` | it worked |
| `error` | `#B3261E` | it broke |
| `warning` | `#8A5F00` | a scope worth noticing |
| `info` | `#4A525E` | on the neutral ramp — info is not a hue |
| `selected` | `#12151A` | primary text |
| `prompt` | `#00718F` | |
| `secondary` | `#4A525E` | secondary text |
| `muted` | `#767F8C` | metadata, settled receipts, timestamps |
| `reasoning` | `#005F87` | live, but subordinate to an answer |
| `toolBorder` | `#D9DEE5` | |
| `surface` | `#F1F3F6` | |
| `surfaceSoft` | `#F1F3F6` | |
| `surfaceStrong` | `#D9DEE5` | |
| `border` | `#D9DEE5` | |
| `borderSoft` | `#D9DEE5` | |
| `syntaxStructure` | `#5B3FBF` | keywords and structure |
| `syntaxValue` | `#9B2C6F` | strings, numbers, and inline code |
| `syntaxType` | `#2F6690` | types and constructors |
`packages/cli/src/ui/theme.test.ts` asserts contrast against the canvas, perceptual
distance between roles that must never be confused, and that the accent sits on
an exact cube vertex. It forces truecolor to do so, because the rest of the suite
runs with colour disabled, which makes ordinary colour assertions vacuous.
---
## Layout
```text
┌────────────────────────────────────────────┐
│ header identity · model · apps · context │
├────────────────────────────────────────────┤
│ │
│ the conversation, full width │
│ prose tracks the terminal │
│ │
├────────────────────────────────────────────┤
│ live zone what is running right now │
├────────────────────────────────────────────┤
│ input │
├────────────────────────────────────────────┤
│ footer mode · keys · usage · elapsed │
└────────────────────────────────────────────┘
```
Usage on the right is billed input/output tokens plus estimated USD, compactly
formatted (`20k/40k $0.26`). Mode and spend never drop at a narrow width.
[**interface.html**](./interface.html) renders the specified design in full colour —
the session, approval, subagents, reasoning and search screens, plus an 80-column
variant, with the activity indicator animating. Open it in a browser; GitHub shows
HTML files as source rather than rendering them.
One column at every width. No sidebar, and no breakpoint at which one appears —
which also removes the collapse behaviour, the two-column reflow, and every
"sidebar hidden" variant.
**The live zone** is a bounded region pinned directly above the input, holding
one row per tool in flight plus the current step of any multi-step task. It is
always in the same place, so "what is jazz doing right now" has exactly one place
to look — and it sits against the input, where the eye already is. The input and
footer are anchored to the bottom, so the zone grows *upward* and the
conversation yields the rows; typing never moves under your hands.
**The measure.** The transcript is the width of the terminal. Running text
takes that content column (minus the rail and a two-column right margin); a
short flush-right strip holds timestamps and lane labels once the frame is
wide enough that they would otherwise sit on the sentence. Tool output,
entity lists, tables and code fences take the same full content width,
because those are scanned rather than read.
---
## The approval card
A coding agent asks permission to edit a file you can revert. Jazz asks
permission to send an email, write to a calendar, or post in a channel other
people read. There is no undo, so this is the most consequential component in the
product.
| Rule | Why |
| --- | --- |
| It is a different class of object | Whatever the visual language is, this block breaks it in one deliberate way. It must never look like another log line |
| It names the real account, verbatim | Not "your calendar" — the actual address. The trust argument is that Jazz always says which real-world object is in scope |
| Every resulting field, before you commit | Title, exact time with timezone, every attendee, which calendar. Nothing discoverable only after pressing enter |
| Irreversibility stated in prose | A sentence, not an icon |
| It reads as a decision, not a fault | Red belongs to things that already broke; colouring a choice like an error teaches people to dismiss errors |
| It animates in, then holds perfectly still | Persistent motion reads as pressure, and pressure on an irreversible choice is a dark pattern |
| Controls sit outside the data frame | The card is what will happen; the line beneath is what you can do |
| Reject is as available as accept | Hiding the alternative is how consent theatre works |
| "Always allow" is the least attractive thing on screen | The irreversible convenience option should be findable, never inviting |
| A distinct glyph for asking versus speaking | `▐` asking, `╶` speaking — one codepoint carrying a real semantic distinction |
| On failure, say what did *not* happen | Silence about state destroys trust, and auth failure is this product's characteristic error |
Two of these are safety requirements rather than aesthetics. The card opens in a
deny-only state for 250ms, so buffered Enter and always-allow keys are discarded
while Escape remains immediate. Rejection removes the approval card before the
optional guidance prompt appears. Long fields collapse to a 120-cell preview
so a heredoc does not become the whole card; Ctrl+O expands them into a wrapping,
scrolling record, so the tail is still inspectable before you commit.
---
## Reach
### Any 256-color terminal, over SSH
Both accents sit on real xterm cube vertices, so they are byte-identical over a
link rather than approximated. No truecolor is required for anything. Every glyph
is single-width in every locale, so a frame that lines up locally lines up on a
server with a different `LANG`. Animation is quantised to whole cells and
discrete colour steps, so a high-latency link degrades the frame rate and nothing
else.
### Cutting-edge terminals
Terminals such as Warp, Ghostty, kitty and WezTerm offer capabilities Jazz can
detect and use, but never depends on: synchronized output so a frame composites
atomically instead of tearing; procedural block rendering, where the mark, meters
and rails are drawn as exact rectangles that tile seamlessly; OSC 8 hyperlinks so
paths and sources are clickable; the kitty keyboard protocol for real modifier
chords; and desktop notifications when a long run finishes while you are in
another window.
Turn all of them off and the design is unchanged in structure.
### Mouse and scroll
Wheel scrolling is on in the fullscreen interface so a long transcript can move
without arrow keys. OpenTUI does not expose wheel-only mouse reporting — scroll
also replaces the terminal's native click-drag selection with the renderer's own
selection layer. Releasing a highlight copies it immediately and the footer says
`copied` for two seconds; Cmd+C and Ctrl+Shift+C copy whatever is currently
selected. Shift+drag still reaches native selection on many hosts. Copy also uses
OSC 52 where the terminal supports it.
### Headless
Every state carries a word — `ok`, `failed`, `running`, `asking`, `renew`,
`stopped` — so nothing is encoded in colour alone. The interface collapses to a
clean append-only log with one line per state transition, which is more useful in
a CI log than a spinner and is diffable.
The best consequence of designing for headless: **the approval card does not
disappear when nobody is watching — it travels.** The same object, carrying the
same fields and the same named account, reaches you as a band in the terminal, a
message from a bot, or a scoped decision a scheduled run is allowed to make on
its own. It is the one component that has to render in three places, which is why
its content is specified as facts about what will happen rather than as a layout.
See [Use cases](../use-cases/index.md) for where Jazz runs, and
[Headless](../use-cases/headless.md) for the `jazz run` contract.
---
## Related
- [Tools and approval](../internals/tools-and-approval.md) — how approval decisions are made
- [Context management](../internals/context-management.md) — what the context meter measures
- [Subagents](../internals/subagents.md) — what the lanes represent
- [Personas](../concepts/personas.md) — where the house voice is defined
- [**website.html**](./website.html) — the website's design direction ("the terminal, unboxed"): the moodboard with the equalizer hero, the motion language, and the OG/SEO strategy, all animated. Open in a browser; GitHub shows HTML as source
---
## Jazz Documentation
Source: https://jazz-cli.vercel.app/docs/index.md
# Jazz Documentation
**Jazz runs your AI agent everywhere you are** — terminal, script, cron, CI,
chat. Any model, including local ones. These docs are organized by what you're trying to do.
---
## Start here
| I want to… | Go to |
| --- | --- |
| **Install it and see it work** | [Quick Start](./start/quick-start.md) |
| **Know where it can run** | [Where it runs](./use-cases/index.md) |
| **Copy a finished thing** | [Playbooks](./playbooks/index.md) |
| **Understand a concept** | [Concepts](./concepts/index.md) |
| **Look up a flag or tool** | [Reference](./reference/index.md) |
| **See how it works inside** | [Internals](./internals/index.md) |
| **Understand the interface design** | [Design](./design/index.md) |
---
## Sections
### [Start](./start/index.md) — get running
- [Quick Start](./start/quick-start.md) — install, configure a provider, first answer
- [Creating Agents](./start/creating-agents.md) — build an agent for a job
- [Creating a Telegram or Discord bot](./start/chat-bots.md) — bot token to a working agent in your chats
- [Airgapped & Self-Hosted](./start/airgapped.md) — fully offline with Ollama or llama.cpp
- [Observability](./start/observability.md) — telemetry to your own OpenTelemetry collector or Langfuse
### [Use cases](./use-cases/index.md) — concrete jobs Jazz is good at
One agent, many front doors. Start with the matrix below.
- [Headless](./use-cases/headless.md) — the `jazz run` contract: stdout/stderr, JSON envelope, per-chat memory, live events
- [Chat platforms](./use-cases/chat-platforms.md) — Telegram and Discord (shipped), Slack / Google Chat (bring your own bridge)
- [CI/CD](./use-cases/ci-cd.md) — PR review with inline comments, the `/jazz` assistant, release notes
- [Scheduled](./use-cases/scheduled.md) — launchd / cron, catch-up, unattended safety
### [Concepts](./concepts/index.md) — the building blocks
- [Agents](./concepts/agents.md) · [Personas](./concepts/personas.md) · [Skills](./concepts/skills.md) · [Tools](./concepts/tools.md) · [Workflows](./concepts/workflows.md) · [Scheduling](./concepts/scheduling.md) · [Agent-to-agent](./concepts/agent-to-agent.md) · [Webhooks](./concepts/webhooks.md)
### Walkthroughs
Concrete sessions from ask to artifact: [setting up peers](./start/peers-setup.md), [deep research into Obsidian](./use-cases/deep-research.md), [git history surgery](./use-cases/git-squash.md), [security scans](./use-cases/security-scan.md), and more.
### [Playbooks](./playbooks/index.md) — copy-pasteable recipes
Production-ready workflows with install steps and risk tiers: inbox triage, PR watchdog, competitor watch, tech-debt radar, research digest, CI reviewer, release notes.
### [Integrations](./integrations/index.md) — connect things
- [LLM Providers](./integrations/providers.md) — 18 of them, including local
- [MCP Servers](./integrations/mcp.md) · [Web Search](./integrations/web-search.md) · [Email & Calendar](./integrations/email-calendar.md)
### [Reference](./reference/index.md) — look it up
- [CLI](./reference/cli.md) · [Configuration](./reference/configuration.md) · [Tools](./reference/tools.md) · [Workflow frontmatter](./reference/workflow-frontmatter.md)
### [Internals](./internals/index.md) — how it works
- [Agent loop](./internals/agent-loop.md) — iterations, budget pressure, meltdown detection
- [Context management](./internals/context-management.md) — token counting, trimming, compaction
- [Tools & approval](./internals/tools-and-approval.md) — risk tiers, two-phase execution
- [Sub-agents](./internals/subagents.md) · [Skills loading](./internals/skills-loading.md) · [Providers & models](./internals/providers-and-models.md)
- [Evals](./internals/evals.md) — measuring whether a harness change actually helped
- [Design decisions](./internals/design-decisions.md) — every harness choice and what it trades away
- [Code map](./internals/code-map.md) — for contributors
### [Design](./design/index.md) — how the interface is built
The terminal interface: the mark, the single-column layout, the six-hue palette, the activity indicators, and the approval card. Includes the two rules that decide every case not covered explicitly, and how the same design holds over SSH, in a cutting-edge terminal, and with no terminal at all.
### [Security](../SECURITY.md)
The threat model, the approval tiers, hardening for unattended and chat-facing deployments, and how to report a vulnerability. Lives at the repository root.
---
## Help
- [Discord](https://discord.gg/yBDbS2NZju) — fastest way to get an answer
- [GitHub Discussions](https://github.com/lvndry/jazz/discussions) — ideas and questions
- [Issues](https://github.com/lvndry/jazz/issues) — bugs and feature requests
- [`CONTRIBUTING.md`](../CONTRIBUTING.md) — contributor guide
---
## Jazz Playbooks
Source: https://jazz-cli.vercel.app/docs/playbooks.md
# Jazz Playbooks
Forkable, production-ready workflow recipes you can drop into your machine or CI runner. Each recipe is a complete `WORKFLOW.md` file plus the install steps.
Workflows are what set Jazz apart from chat-only CLIs: they run on a cron schedule, headless, with a per-workflow auto-approve policy that controls how much autonomy the agent gets. Pick a recipe, copy it, customize the prompt, schedule it, and forget about it.
## Recipes
| Recipe | Schedule | Risk | What it does |
| --- | --- | --- | --- |
| [inbox-triage](./inbox-triage.md) | Weekday mornings | `low-risk` | Summarizes important email and archives the noise via Himalaya |
| [pr-watchdog](./pr-watchdog.md) | Daily | `read-only` | Scans open PRs, flags stale ones, posts a digest |
| [competitor-watch](./competitor-watch.md) | Weekly | `low-risk` | Scrapes competitor blogs/changelogs and writes a digest into Obsidian |
| [codebase-tech-debt-radar](./codebase-tech-debt-radar.md) | Weekly | `read-only` | Greps for FIXME / TODO / HACK and tracks the trend over time |
| [release-notes-draft](./release-notes-draft.md) | On `git tag` push (CI) | `high-risk` (auto) | Drafts release notes from commits and creates a GitHub release |
| [ci-pr-reviewer](./ci-pr-reviewer.md) | On every PR (CI) | `high-risk` (auto) | Reviews the diff and posts inline review comments |
| [research-digest](./research-digest.md) | Weekly | `read-only` | Web-research summary on a topic of your choice, saved to a file |
## How recipes are organized
Each recipe is one page with:
- The full `WORKFLOW.md` to copy
- Concrete shell commands to install and schedule it
- A short customization checklist
- An example of the output you should expect
## Where workflows live
Jazz looks for workflows in three places, in this order (later overrides earlier):
1. **Built-in** — shipped with the `jazz-ai` package
2. **Global** — `~/.jazz/workflows//WORKFLOW.md`
3. **Local** — `./workflows//WORKFLOW.md` (relative to cwd, scanned up to depth 4)
Most recipes here install to `~/.jazz/workflows/` so they work from any directory. The CI recipes live under `.github/jazz/workflows/` in your repo and are copied into a workspace at runtime.
## Auto-approve risk tiers
Set `autoApprove:` in frontmatter:
| Value | Auto-approves |
| --- | --- |
| `false` | Nothing — always asks (not useful for headless runs) |
| `read-only` | File reads, search, web requests, `git status`/`log`/`diff` |
| `low-risk` | + `manage_todos`, `spawn_subagent` — **not** email, calendar, or file writes |
| `high-risk` | + edits to your repo, shell commands, git commits, git push |
| `true` | Same as `high-risk` |
Pick the lowest tier that lets the recipe finish.
## CLI cheat sheet
```bash
jazz workflow list # discover what's available
jazz workflow show # see the prompt + schedule
jazz workflow run # run once, foreground, with prompts
jazz workflow run --auto-approve # run unattended (uses the policy)
jazz workflow schedule # install into launchd / cron
jazz workflow unschedule # remove from launchd / cron
jazz workflow scheduled # list what's scheduled
jazz workflow history # last runs for a workflow
jazz workflow catchup # run any that missed their slot
```
Logs land in `~/.jazz/logs/.log` and `~/.jazz/logs/.error.log` once scheduled.
---
## ci-pr-reviewer
Source: https://jazz-cli.vercel.app/docs/playbooks/ci-pr-reviewer.md
# ci-pr-reviewer
**What it does:** On every opened PR (or on `/jazz-review` comment from a trusted user), runs a Jazz agent that reviews the diff and posts inline review comments on the actual changed lines.
**Schedule:** Triggered by GitHub Actions.
**Risk:** `autoApprove: true` inside the workflow — but the runner has only read tools and `write_file` to `/tmp`. The agent cannot push, comment, or merge. Posting comments is done by a downstream `actions/github-script` step parsing the agent's JSON output.
**Tools used:** `execute_command`, `read_file`, `find`, `grep`, `ls`, `http_request`, `web_search`, `load_skill`, `write_file`, `spawn_subagent`.
## Why this is useful
This is the recipe Jazz uses on its own pull requests. Generic "AI PR reviewers" tend to spray boilerplate. This one:
- Writes its findings to a JSON array with `path` / `line` / `side` / `body`.
- The CI step **validates each comment against the actual diff hunks** before posting — comments referencing lines outside the diff get rolled into a review-body section instead of being rejected by the GitHub API.
- Spawns sub-agents on large PRs (10+ files or 500+ lines) to review batches in parallel.
## The workflow file (`.github/jazz/workflows/code-review/WORKFLOW.md`)
Trimmed for the cookbook — see the full version in this repo for the complete checklist.
```markdown
---
name: code-review
description: Review pull request changes for quality, security, and correctness
autoApprove: true
agent: ci-reviewer
maxIterations: 100
skills:
- code-review
---
# Pull Request Code Review
Review the changes in this pull request.
**Collect ALL issues, never stop at first error**: You MUST review the entire PR and return every issue you find.
**Write to a file**: Use `write_file` to accumulate issues in a scratch file. **Always write to /tmp only** — e.g. `/tmp/jazz-review-issues.md`. Never write to the repo workspace.
**Large PRs — `spawn_subagent`**: If the PR has 10+ files or 500+ lines, spawn subagents to review batches in parallel. Aggregate.
To get the diff, use `execute_command` with `git diff __PR_BASE_SHA__...__PR_HEAD_SHA__`.
## Workflow
1. Get the file list: `git diff --name-only __PR_BASE_SHA__...__PR_HEAD_SHA__`.
2. Get the diff content. If small (<~500 lines), full diff. If large, batches of 5–10 files.
3. Use the `code-review` skill for the full checklist.
## Output Format
You MUST output ONLY a JSON array as the very last thing you write, wrapped in a four-backtick fenced code block. Each element:
```
{
"path": "src/example.ts",
"line": 42,
"side": "RIGHT",
"body": "**Critical**: This can throw if `user` is null.\n\nSuggestion:\n```ts\nif (!user) return;\n```"
}
```text
Rules:
- `path`: relative file path from repo root (must exist in the diff)
- `line`: NEW version (RIGHT) for added/modified, OLD (LEFT) for deleted
- `side`: `RIGHT` for new code; `LEFT` or omit for deleted files
- `body`: markdown — include severity (Critical/Suggestion/Nice-to-have) and a concrete fix
**CRITICAL — Line number accuracy:** the `line` MUST appear in the diff hunks. Lines outside the diff are rejected by the GitHub API. If the line you want to comment on is not in the diff, attach the comment to the nearest valid diff line and reference the real line in the body.
If there are no issues, output `[]`.
```
## The agent config (`.github/jazz/agents/ci-reviewer.json`)
```json
{
"id": "ci-reviewer",
"name": "ci-reviewer",
"description": "CI code review agent",
"model": "openai/gpt-4o-mini",
"config": {
"persona": "coder",
"llmProvider": "openai",
"llmModel": "gpt-4o-mini",
"reasoningEffort": "medium",
"tools": [
"find",
"grep",
"ls",
"read_file",
"execute_command",
"http_request",
"web_search",
"load_skill",
"load_skill_section",
"context_info",
"summarize_context",
"write_file",
"spawn_subagent",
]
},
"createdAt": "2026-01-01T00:00:00.000Z",
"updatedAt": "2026-01-01T00:00:00.000Z"
}
```
## How to install
```bash
# In your repo:
mkdir -p .github/jazz/workflows/code-review .github/jazz/agents .github/workflows
# WORKFLOW.md (paste the content above; keep the __PR_BASE_SHA__ / __PR_HEAD_SHA__ placeholders)
$EDITOR .github/jazz/workflows/code-review/WORKFLOW.md
# Agent config
$EDITOR .github/jazz/agents/ci-reviewer.json
# Driver workflow
$EDITOR .github/workflows/jazz.yml
```
A minimal `jazz.yml` driver (the full version in this repo also adds an on-demand `/jazz` assistant job):
```yaml
name: Jazz
on:
pull_request:
types: [opened]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
issues: write
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
pr_number: ${{ steps.r.outputs.pr_number }}
base_sha: ${{ steps.r.outputs.base_sha }}
head_sha: ${{ steps.r.outputs.head_sha }}
pr_head_repo_full_name: ${{ steps.r.outputs.pr_head_repo_full_name }}
steps:
- id: r
uses: actions/github-script@v7
with:
script: |
let prNumber, baseSha, headSha, repo;
if (context.payload.pull_request) {
const pr = context.payload.pull_request;
prNumber = pr.number; baseSha = pr.base.sha; headSha = pr.head.sha;
repo = pr.head.repo.full_name;
} else {
const issueNumber = context.payload.issue.number;
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner, repo: context.repo.repo, pull_number: issueNumber,
});
prNumber = pr.number; baseSha = pr.base.sha; headSha = pr.head.sha;
repo = pr.head.repo.full_name;
}
core.setOutput('pr_number', String(prNumber));
core.setOutput('base_sha', baseSha);
core.setOutput('head_sha', headSha);
core.setOutput('pr_head_repo_full_name', repo);
code-review:
needs: resolve
runs-on: ubuntu-latest
if: |
(github.event_name == 'pull_request'
&& github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'issue_comment'
&& github.event.issue.pull_request != null
&& contains(github.event.comment.body, '/jazz-review')
&& (github.event.comment.author_association == 'OWNER'
|| github.event.comment.author_association == 'MEMBER'
|| github.event.comment.author_association == 'COLLABORATOR')
&& needs.resolve.outputs.pr_head_repo_full_name == github.repository)
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.resolve.outputs.head_sha }}
fetch-depth: 0
- uses: actions/setup-node@v4
with: { node-version: "22" }
- run: npm install -g jazz-ai
- env:
PR_BASE_SHA: ${{ needs.resolve.outputs.base_sha }}
PR_HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
run: |
mkdir -p "$HOME/.jazz/agents" workflows/code-review
cp .github/jazz/agents/ci-reviewer.json "$HOME/.jazz/agents/"
sed -e "s/__PR_BASE_SHA__/$PR_BASE_SHA/g" \
-e "s/__PR_HEAD_SHA__/$PR_HEAD_SHA/g" \
.github/jazz/workflows/code-review/WORKFLOW.md \
> workflows/code-review/WORKFLOW.md
- env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CI: "true"
JAZZ_DISABLE_CATCH_UP: "1"
run: |
set -euo pipefail
jazz --output raw workflow run code-review \
--auto-approve --agent ci-reviewer \
| tee /tmp/jazz-review.txt
- if: always()
env:
PR_NUMBER: ${{ needs.resolve.outputs.pr_number }}
PR_HEAD_SHA: ${{ needs.resolve.outputs.head_sha }}
uses: actions/github-script@v7
with:
script: |
// (Full diff-validation + posting logic lives in this repo's
// .github/workflows/jazz.yml — about 180 lines. Copy it in.)
```
For the full `actions/github-script` step that parses the JSON output, validates each comment against the diff hunks, and falls back to general comments for out-of-diff lines, copy [`/.github/workflows/jazz.yml`](../../.github/workflows/jazz.yml) verbatim from this repo.
## How to customize
- **Different model** — change the `llmProvider` / `llmModel` / `reasoningEffort` in `ci-reviewer.json`. The CI workflow surfaces the model name in the review header.
- **Stricter / looser tone** — edit the WORKFLOW.md "What To Do" / "What NOT To Do" lists. They control whether the reviewer flags style nits.
- **Add an on-demand assistant** — copy the `assistant` job from `.github/workflows/jazz.yml` to let trusted reviewers post `/jazz ` and get a tailored answer on the PR.
- **Self-hosted runner** — works the same; install `jazz-ai` on the runner image instead of `npm install -g` per run.
## What you'll see
When a PR opens, GitHub shows an "AI Code Review" check running. ~1–3 minutes later, a review appears with inline comments tied to specific lines, plus a top-level summary like:
> ## Jazz Code Review
>
> Found 4 comment(s).
>
> *Model: openai/gpt-4o-mini*
If there are no issues, the bot posts a single comment: `## Jazz Code Review — No issues found.`
## Limits
- **Costs an LLM API key.** Per-PR cost varies with diff size; the recipe spawns sub-agents to keep context bounded.
- The reviewer is **fork-PR safe by default** — the `if:` clause requires `head.repo.full_name == github.repository`, so PRs from forks don't get to use your secrets.
- Inline comments only land on lines that are part of the diff hunks. Out-of-diff comments are still posted, but as part of the review summary body.
---
## codebase-tech-debt-radar
Source: https://jazz-cli.vercel.app/docs/playbooks/codebase-tech-debt-radar.md
# codebase-tech-debt-radar
**What it does:** Once a week, walks one or more local repos, counts and categorizes `TODO` / `FIXME` / `HACK` / `XXX` markers, and writes a trend report so you can see whether tech debt is growing or shrinking.
**Schedule:** `0 18 * * 5` — 18:00 every Friday (end of week).
**Risk:** `read-only` — only `grep`, `git log`, and a single `write_file` to a scratch dir.
**Tools used:** `grep`, `find`, `execute_command`, `read_file`, `write_file`.
## Why this is useful
You either ship a tech-debt jira queue nobody touches, or you stay blind to the trend. This recipe gives you a 30-second weekly snapshot: how many markers, where, and whether the line is going up or down. That's enough signal to bring up at retro without a full audit.
## The workflow file
```markdown
---
name: codebase-tech-debt-radar
description: Weekly FIXME/TODO/HACK trend report across selected repos.
schedule: "0 18 * * 5"
autoApprove: read-only
catchUpOnRestart: false
maxIterations: 50
---
# Codebase Tech-Debt Radar
Walk the repos below, count debt markers, and write a trend report.
## Repos to scan
Edit these absolute paths to match your machine:
- `$HOME/code/jazz`
- `$HOME/code/`
- `$HOME/code/`
## Step 1 — Verify each repo exists
For each path:
- `ls ` to confirm.
- If missing, note it under `## Errors` in the final report and skip.
## Step 2 — Count markers
For each repo, run grep for all five markers, separately, scoped to source code only:
```bash
grep -rni --include='*.{ts,tsx,js,jsx,py,go,rs,java,kt,rb,php,c,cc,cpp,h,hpp,swift,scala}' \
-E '\b(TODO|FIXME|HACK|XXX|DEPRECATED)\b'
```
Use the `grep` tool. Aggregate counts as:
| repo | TODO | FIXME | HACK | XXX | DEPRECATED | total |
## Step 3 — Categorize hotspots
For each repo, identify the top 5 files by total marker count (any kind). For each hotspot file, list:
- path (relative to repo root)
- count
- the 3 most recent markers in that file (line + the marker line text, truncated to 120 chars).
## Step 4 — Trend
For each repo, look up the count of markers as of the previous report (if one exists). The previous report path is `$HOME/.jazz/tech-debt-radar//.md`. Parse the totals table out of the report's frontmatter (see Step 5 — frontmatter is the source of truth).
Compute the delta vs last week. Output `+N` or `-N` per repo per marker.
If no previous report exists, write `(first run)` for every delta.
## Step 5 — Write reports
Write one file per repo at `$HOME/.jazz/tech-debt-radar//.md`.
Use this layout, with the totals duplicated into YAML frontmatter so next week's run can parse them:
```markdown
---
generated:
repo:
totals:
TODO:
FIXME:
HACK:
XXX:
DEPRECATED:
total:
---
# Tech Debt Radar — —
## Totals
| Marker | Count | Δ vs last week |
| --- | --- | --- |
| TODO | | <+/-N or (first run)> |
| ...
## Hotspots
1. **** — markers
- L:
- L:
- L:
2. **** — markers
- ...
## New this week
[List markers added in commits in the last 7 days. Use `execute_command` with `git log -p --since='7 days ago'` and grep additions for marker patterns. Show file:line + the line text. Cap at 20.]
## Resolved this week
[Same approach but for marker lines deleted in the last 7 days. Cap at 20.]
```
## Rules
- Read-only. Never edit any source file.
- Never run `git pull` or any network git op. The local working copy is the source of truth.
- Skip any path that isn't a git repo.
- Skip vendored / generated dirs: `node_modules`, `dist`, `build`, `target`, `vendor`, `.next`, `.turbo`. Use `--exclude-dir=...` on `grep`.
```text
## How to install
```bash
mkdir -p ~/.jazz/workflows/codebase-tech-debt-radar
$EDITOR ~/.jazz/workflows/codebase-tech-debt-radar/WORKFLOW.md
# Edit the "Repos to scan" section to match your machine
# First run, foreground
jazz workflow run codebase-tech-debt-radar
# Schedule
jazz workflow schedule codebase-tech-debt-radar
# Look at the result
ls ~/.jazz/tech-debt-radar/*/
```
## How to customize
- **Different markers** — edit the `-E '\b(TODO|FIXME|...)\b'` regex. Add `WIP`, `OPTIMIZE`, etc.
- **Daily, not weekly** — set `schedule: "0 18 * * *"`. The trend computation still works since reports key on date.
- **Single repo** — keep one entry in "Repos to scan". The output dir layout still works.
- **Slack post** — pipe the latest report through your Slack webhook in a wrapper shell script, or add a final step that uses `http_request` to post.
## What you'll see
After two runs, each repo's folder under `~/.jazz/tech-debt-radar//` contains weekly markdown snapshots with a trend column. After a quarter, you can `grep -h "^| total" ~/.jazz/tech-debt-radar//*.md` to plot the line.
## Limits
- The "New this week" / "Resolved this week" sections rely on `git log -p --since='7 days ago'`. That misses force-pushed history rewrites — fine for most teams, false-zero for some.
- Only counts markers in source files matched by the `--include` glob. Markers in markdown, YAML, or shell scripts are skipped on purpose; widen the glob if you want them.
---
## competitor-watch
Source: https://jazz-cli.vercel.app/docs/playbooks/competitor-watch.md
# competitor-watch
**What it does:** Once a week, fetches the public blog/changelog/release feeds of competitors you list, summarizes what changed, and writes the digest into your Obsidian vault (or a plain folder if you don't use Obsidian).
**Schedule:** `0 9 * * 1` — 09:00 every Monday.
**Risk:** `low-risk` — fetches HTTP, writes a single new file. Never overwrites your notes.
**Tools used:** `web_search` (Brave/Tavily/Exa/Perplexity/Parallel — whichever you have configured), `http_request`, `defuddle` skill (for clean text extraction), `obsidian` skill (optional), `write_file`.
## Why this is useful
Knowing what your competitors shipped last week is most of the work of competitive analysis, and almost nobody actually does it weekly because reading a dozen blogs is boring. This recipe is the boring part automated. The interesting part — what it means for your roadmap — stays human.
## The workflow file
```markdown
---
name: competitor-watch
description: Weekly competitor changelog/blog digest, written to Obsidian.
schedule: "0 9 * * 1"
autoApprove: low-risk
catchUpOnRestart: true
maxCatchUpAge: 604800
maxIterations: 80
skills:
- defuddle
- digest
- obsidian
---
# Competitor Watch — Weekly Digest
You are building a weekly digest of what my competitors shipped or said publicly in the last 7 days.
## Competitors
Edit this block to whatever you actually care about. Each entry is `name | homepage | feed/changelog URL (optional)`.
- Acme AI | https://acme.ai | https://acme.ai/changelog
- Foo Labs | https://foo.dev | https://foo.dev/blog/feed.xml
- BarCorp | https://barcorp.com |
If a feed URL is empty, fall back to a `web_search` for `site: after:<7 days ago>`.
## Step 1 — Collect
For each competitor:
1. If a feed URL is provided, `http_request` it. Parse the items dated within the last 7 days. Keep title, URL, date.
2. If no feed URL, use `web_search` with `site:` and a 7-day `fromDate`.
3. For each item URL, fetch the page (`http_request`) and use the `defuddle` skill to strip nav/ads/junk down to clean main content.
Cap each competitor at the 5 most recent items.
## Step 2 — Summarize
For each item, extract:
- **What** — one sentence on what was announced.
- **Why it matters** — one sentence on the implication. Be honest. "Probably nothing" is a valid answer.
- **Tag** — one of: `product`, `pricing`, `funding`, `hiring`, `partnership`, `blog`, `other`.
## Step 3 — Write the digest
Use this template, save to my Obsidian vault if `obsidian` is reachable, otherwise save to `$HOME/competitor-watch///week-of-.md` (the Monday of the week).
If using Obsidian: target path `Competitive/Weekly/Week of .md`. Use `obsidian create path=...` (do not overwrite if it exists — append a numeric suffix).
```markdown
# Competitor Watch — Week of
## Headlines
[3–5 bullets summarizing the week across competitors. Skip if quiet.]
##
- **- ** []
What:
Why it matters:
Source: ·
##
- ...
## Quiet this week
[List any competitors with zero items.]
## Sources
[Bullet list of every URL fetched, for traceability.]
```
## Rules
- Don't invent items. If `http_request` returns 4xx/5xx for a feed, log it under a `## Errors` section and skip that competitor.
- Don't editorialize beyond "Why it matters". One sentence is enough.
- Read-only on the web. Never POST anywhere.
- Never overwrite an existing weekly file — suffix `-2`, `-3`, etc.
```text
## How to install
```bash
# 1. Make sure web_search is configured. Pick one provider:
jazz config show | grep -i webSearch
# or set it through the chat with: /config
# 2. (Optional) make sure Obsidian is running and the CLI is enabled
# (Settings → General → Command line interface)
which obsidian || echo "Obsidian CLI not found — recipe will fall back to plain files"
# 3. Drop in the workflow
mkdir -p ~/.jazz/workflows/competitor-watch
$EDITOR ~/.jazz/workflows/competitor-watch/WORKFLOW.md # paste, then edit the competitor list
# 4. Dry run
jazz workflow run competitor-watch
# 5. Schedule
jazz workflow schedule competitor-watch
```
## How to customize
- **More frequent** — change `schedule` to `0 9 * * 1,4` (Mon and Thu).
- **No Obsidian** — remove `obsidian` from the `skills:` list and delete the Obsidian branch in Step 3. The fallback path under `$HOME/competitor-watch/` is already there.
- **Notion instead of Obsidian** — install the upstream Notion MCP server (`npx -y mcp-remote https://mcp.notion.com/mcp` or [makenotion/notion-mcp-server](https://github.com/makenotion/notion-mcp-server)), wire it into Jazz with `jazz mcp add`, then ask the workflow to write the page via the Notion MCP tool. Jazz does **not** ship a Notion integration on its own.
- **Changelog-only mode** — drop the homepage scraping step and require a feed URL for each competitor. Cleaner, less noise.
## What you'll see
A new note every Monday with one section per competitor and a short headline section. Over time the `Competitive/Weekly/` folder becomes a searchable archive of how each competitor moved.
## Limits
- **Costs an API key for the search provider.** Brave and Tavily have free tiers. Exa, Perplexity, and Parallel are paid. Pick one and set it in your Jazz config.
- Some competitor sites block scrapers. The `defuddle` skill handles most blogs but won't beat a Cloudflare challenge — the recipe will skip those and surface them under `## Errors`.
- The Obsidian skill talks to a running Obsidian app via IPC. If the app isn't open when the workflow fires, the fallback file path is used.
---
## inbox-triage
Source: https://jazz-cli.vercel.app/docs/playbooks/inbox-triage.md
# inbox-triage
**What it does:** Each weekday morning, scans your inbox via the `email` skill (Himalaya CLI), surfaces the handful of messages that actually need a human, and archives the noise (newsletters, marketing, automated notifications).
**Schedule:** `0 8 * * 1-5` — 08:00 on weekdays.
**Risk:** `low-risk` — archiving and flagging are auto-approved; sending or deleting are not. The prompt explicitly forbids destructive actions.
**Tools used:** `email` skill (uses `shell_command`/`execute_command` under the hood to drive `himalaya`), `write_file` to a scratch summary file.
## Why this is useful
Most inbox-triage tools are either dumb filters (regex on subject) or full SaaS that read everything. This recipe runs locally against any IMAP account Himalaya supports (Gmail, Outlook, iCloud, Proton via Bridge, plain IMAP), uses an LLM to do the actual judgement, and is conservative by construction: when in doubt, the agent does nothing.
## The workflow file
```markdown
---
name: inbox-triage
description: Morning triage — summarize what matters, archive newsletters and noise.
schedule: "0 8 * * 1-5"
autoApprove: low-risk
catchUpOnRestart: true
maxCatchUpAge: 86400
maxIterations: 60
skills:
- email
---
# Morning Inbox Triage
You are triaging my INBOX for the work day. The goal is a short, scannable summary plus a clean inbox.
## Step 1 — Inventory
1. Run `himalaya account list` and use the default account unless I have only one.
2. Pull the last 24 hours of mail from INBOX:
```bash
himalaya envelope list --folder INBOX --output json --page-size 100 "after $(date -u -v-1d +%Y-%m-%d 2>/dev/null || date -u -d '1 day ago' +%Y-%m-%d)"
```
1. Parse JSON. For each envelope keep `id`, `subject`, `from`, `date`, `flags`.
## Step 2 — Classify
Tag each message with exactly one bucket:
- `important` — a real person writing me directly, an action item, a reply I owe, a bill, a security alert, anything from a known coworker / family member.
- `fyi` — a real human update I should read but does not need a reply (release notes from a small team, a friend sharing a link).
- `newsletter` — anything that smells like a periodic publication (Substack, marketing, product updates, conference roundups).
- `automated` — CI failures, GitHub notifications, calendar invites, monitoring alerts, anything from `noreply@` / `no-reply@`.
- `unknown` — you genuinely cannot tell.
## Step 3 — Act
- **Archive** all `newsletter` and `automated` messages older than 24h:
```bash
himalaya message move --folder Archive
```
Batch IDs when possible. The `Archive` folder name varies by provider — check `himalaya folder list` first and use whatever maps to "All Mail" / "Archive".
- **Do nothing** with `important`, `fyi`, or `unknown`. Never delete. Never mark seen. Never reply.
## Step 4 — Summary
Write a markdown summary to `$HOME/.jazz/inbox-triage/$(date +%Y-%m-%d).md` with this shape:
```markdown
# Inbox Triage —
## Needs your attention ()
- **** —
Why:
## FYI ()
- **** —
## Archived ()
- newsletters
- automated notifications
## Skipped (unknown) ()
- **** —
```
## Safety rules — read every run
- When in doubt, **leave the message alone**.
- Never delete. Never empty trash.
- Never reply, never forward, never send.
- Never touch flags besides what you need to read the message.
- If `himalaya account list` returns nothing or errors, write a one-line failure note to the summary file and stop.
```text
## How to install
```bash
# 1. Install Himalaya and configure at least one account *before* scheduling.
# This recipe cannot bootstrap a fresh machine — the weekday run is
# unattended. First-time setup is a separate interactive session
# (ask Jazz "check my latest emails", or do it yourself):
brew install himalaya # macOS
# or: cargo install --locked --git https://github.com/pimalaya/himalaya.git
himalaya account configure default
# 2. Drop the workflow into your global Jazz workflows dir
mkdir -p ~/.jazz/workflows/inbox-triage
$EDITOR ~/.jazz/workflows/inbox-triage/WORKFLOW.md # paste the file above
# 3. Verify Jazz sees it
jazz workflow list | grep inbox-triage
# 4. Run it once foreground, watch what it does
jazz workflow run inbox-triage
# 5. Once you trust it, schedule it
jazz workflow schedule inbox-triage
# Tail the log on the next run
tail -f ~/.jazz/logs/inbox-triage.log
```
## How to customize
- **Multiple accounts** — change Step 1 to loop `himalaya account list --output json | jq -r '.[].name'` and triage each.
- **Different threshold** — edit the `after $(date ...)` window in Step 1 (e.g. `-3d` over a weekend).
- **Different rules** — add categories (e.g. `receipt` → move to `Receipts/`). Keep the "when in doubt, do nothing" rule.
- **Trusted senders** — paste a list of allow-listed addresses in the prompt; ask the agent to never archive anything from them.
## What you'll see
- A daily file at `~/.jazz/inbox-triage/YYYY-MM-DD.md` with three or four short sections.
- A measurably smaller inbox (newsletters and automated notifications gone).
- Zero deleted mail, zero sent mail, zero replies — by design.
## Limits
- Requires a working Himalaya install and at least one configured account *before* you schedule it. The email skill can walk you through that in an interactive session; the weekday cron run will not.
- `low-risk` policy auto-approves the `move` command. If you'd rather hand-approve, drop to `read-only` and run interactively.
---
## pr-watchdog
Source: https://jazz-cli.vercel.app/docs/playbooks/pr-watchdog.md
# pr-watchdog
**What it does:** Once a day, scans the open pull requests on your repo(s) using the GitHub CLI, flags PRs that are stale or blocked, and writes a short digest you can paste into Slack.
**Schedule:** `0 9 * * 1-5` — 09:00 weekdays.
**Risk:** `read-only` — only reads from the GitHub API, writes a single markdown file.
**Tools used:** `shell_command` (to run `gh`), `write_file`, `web_search` is *not* needed.
## Why this is useful
PRs rot. A 14-day-old PR is usually a 14-day-old conflict + 14-day-old context. A daily nudge surfaces them before they become impossible to merge. This recipe is intentionally CLI-only — no GitHub Actions, no webhooks — so it works on private mirrors and self-hosted setups too, as long as `gh` is logged in.
## The workflow file
```markdown
---
name: pr-watchdog
description: Daily scan of open PRs — flag stale ones, draft a digest.
schedule: "0 9 * * 1-5"
autoApprove: read-only
catchUpOnRestart: true
maxCatchUpAge: 86400
maxIterations: 40
---
# Pull Request Watchdog
Build a digest of open pull requests across the repos I care about. Output is a markdown file I can copy-paste into Slack.
## Repos to scan
Edit this list directly in the WORKFLOW.md:
- `lvndry/jazz`
- `/`
- `/`
## Step 1 — Verify `gh` is authenticated
```bash
gh auth status
```
If this fails, write a single-line failure note to the output file (see Step 4) explaining `gh auth login` is required, and stop.
## Step 2 — Pull open PRs per repo
For each repo, run:
```bash
gh pr list \
--repo / \
--state open \
--json number,title,author,createdAt,updatedAt,isDraft,labels,reviewDecision,mergeable,additions,deletions,url \
--limit 100
```
## Step 3 — Classify
Bucket each PR using these rules. Stop at the first match.
- **Blocked** — `mergeable == "CONFLICTING"` or `reviewDecision == "CHANGES_REQUESTED"`.
- **Stale** — `updatedAt` is more than 7 days ago and not draft.
- **Awaiting review** — open ≥ 24h, no `reviewDecision`, not draft.
- **Big** — `additions + deletions > 800`.
- **Draft** — `isDraft == true` (separate section, not flagged).
- **Healthy** — everything else (don't include in the digest).
A PR can land in only one bucket. Order matters: blocked > stale > awaiting review > big > draft.
## Step 4 — Write the digest
Save to `$HOME/.jazz/pr-watchdog/$(date +%Y-%m-%d).md`. Use this layout:
```markdown
# PR Watchdog —
## Blocked ()
- **#** — by @ ·
Why:
## Stale ()
- **