# CLI Reference (/agent/cli)

`jan` with no subcommand opens the [interactive console](/agent/console). Everything else is
non-interactive, for scripts and CI.

```bash
jan [OPTIONS]
jan <COMMAND>
```

## Opening the console [#opening-the-console]

| Flag | Description |
| --- | --- |
| `--project <PATH>` | Project root; its `agent.toml` lives in `~/.jan/projects/<slug>/`. Defaults to `.` |
| `--task <TEXT>` | Seed the session with a first message |
| `--model <ID>` | Model id, overriding `[agent].model` |
| `--image <PATH>` | Attach an image to the first message. Repeatable |
| `--plan` | Start in read-only plan mode |
| `--safe` | Ask for approval before writes, shell commands, and MCP tool calls |
| `--sandbox` | Run shell commands under OS confinement. Off by default |
| `--no-sandbox` | Run shell commands unconfined, overriding a persistent `sandbox` setting |
| `--allow-escalation` | Let a sandboxed shell command that asks to leave the sandbox run without an escalation prompt (`--safe` still asks once). See [Running a command outside the sandbox](/agent/permissions#running-a-command-outside-the-sandbox) |
| `--resume [ID]` | Resume the most recent session, or one whose id starts with `ID` |
| `-c`, `--continue` | Resume the most recent session |
| `--fork-session` | Open the resumed session as a new thread, leaving the original resumable |
| `--worktree` | Work in a dedicated git worktree instead of the project directory. Off by default |
| `--no-worktree` | Work in the project directory, overriding a persistent `worktree` setting |
| `--provider <ID>` | Target a specific provider for this run |
| `--api-key <KEY>` | API key for that provider |
| `--base-url <URL>` | Base URL for the `--provider` named, e.g. a gateway. Requires `--provider`. See [Using a gateway](/agent/providers#using-a-gateway) |

```bash
jan                                     # current directory
jan --project ~/code/app                # somewhere else
jan --task "fix the failing test"       # with a first message
jan -c                                  # resume the latest session
jan --resume 3f7a91c2                   # resume by id prefix
jan --plan                              # read-only
jan --safe                              # ask before writes and shell commands
jan --sandbox                           # confine shell commands to the project
jan -c --fork-session                   # branch the latest session into a new one
jan --worktree                          # work in a dedicated checkout, not yours
```

<Callout type="warning">
Tool calls are auto-approved by default, and shell commands are **not** sandboxed by default - an
approved command runs with your own access. The hard denies never lift:
anything in `[tools] deny` and everything plan mode blocks. `--safe` adds the interactive approval
prompt; `--sandbox` adds OS confinement (bubblewrap, Seatbelt, or AppContainer). See
[tool permissions](/agent/permissions#the-shell-sandbox).
</Callout>

## `jan cli agent` [#jan-cli-agent]

Run the agent without a UI. This is the form to use in scripts.

### `run` [#run]

Run to completion. There is no turn cap by default - the agent runs as many turns as the task needs,
bounded by `--max-turns` or by cancelling it.

<Callout type="warning">
`--max-session-tokens` and `[budget].max_tokens` do **not** stop a run. Crossing the ceiling is
advisory: the run compacts its history, records a note, and carries on, tool calls included. Use
`--max-budget-usd` to bound what an unattended run may spend, or `--max-turns` to bound how many
turns it may take.
</Callout>

```bash
jan cli agent run "fix the failing test in tests/auth"
jan cli agent run --project ~/code/app "update the changelog"
jan cli agent run --model gpt-4o "add unit tests for the parser"
jan cli agent run --safe "run the migration"   # approve each step
jan cli agent run --max-turns 5 --max-session-tokens 20000 "triage the build failure"
```

| Flag | Description |
| --- | --- |
| `--project <PATH>` | Project root. Defaults to `.` |
| `--model <ID>` | Model id, overriding `[agent].model` |
| `--safe` | Ask for approval before writes, shell commands, and MCP tool calls |
| `--sandbox` | Run shell commands under OS confinement. Off by default |
| `--no-sandbox` | Run shell commands unconfined, overriding a persistent `sandbox` setting |
| `--allow-escalation` | Let a sandboxed shell command that asks to leave the sandbox run without an escalation prompt (`--safe` still asks once). See [Running a command outside the sandbox](/agent/permissions#running-a-command-outside-the-sandbox) |
| `--resume[=ID]` | Resume the most recent session, or one by id |
| `-c`, `--continue` | Resume the most recent session |
| `--fork-session` | Open the resumed session as a new thread, leaving the original resumable |
| `--worktree` | Work in a dedicated git worktree instead of the project directory. Off by default |
| `--no-worktree` | Work in the project directory, overriding a persistent `worktree` setting |
| `--max-turns <N>` | Hard cap on agentic turns. If the model is still calling tools at N the run fails with `reached the N-turn limit...` and exits `53`; with `--output-format json` the partial answer is still in `result`. Bounds the parent run only - subagent turns are not counted. `0` means unbounded, which is the default |
| `--max-session-tokens <N>` | Advisory token ceiling for this run, overriding `[budget].max_tokens`. Triggers compaction and a recorded note when passed; it does not terminate the run. `0` means no ceiling |
| `--max-budget-usd <USD>` | Stop the run once it has spent this much, overriding `[budget].max_usd`. Priced from the provider's published rates, so a model with no published price is **refused** rather than run uncapped - including a subagent whose definition names its own model. Subagents inherit the ceiling. Compaction is billed against it like any other request. Stopping this way exits `0` with `stop_reason: budget_exceeded`; `0` as a limit stops at the first billed request |
| `--provider`, `--api-key`, `--base-url` | Provider overrides for this run. `--base-url` requires `--provider`; see [Using a gateway](/agent/providers#using-a-gateway) |
| `--output-format <FORMAT>` | `text` (default) streams the answer; `json` prints one result object; `stream-json` prints one JSON event per line |
| `--input-format <FORMAT>` | `text` (default) ignores stdin; `stream-json` reads client messages while the run is in flight. Requires `--output-format stream-json` |
| `--host-tools <FILE>` | JSON file declaring tools **the client executes**, as a list of `{"name", "description", "parameters", "capability"}`. The model calls them as `host__<name>` (see [name mapping](#host-tools)); each call arrives as a `tool_request` on stdout and must be answered with a `tool_result` on stdin, so this requires `--input-format stream-json`. Validated before the run starts |
| `--host-gate` | The host approves its own tool calls: the run raises no `permission_request` for any host tool, whatever its capability. Built-in tools are unaffected. Requires `--host-tools` |

<Callout type="info">
`run` takes a positional task, so a space-separated `--resume ID` would swallow it. Write the value
form with an equals sign: `--resume=3f7a91c2`.
</Callout>

#### JSON output [#json-output]

`--output-format json` suppresses the streamed answer and prints a single object on stdout when the
run finishes. Progress and diagnostics still go to stderr, so stdout is safe to pipe into `jq`.

```bash
jan cli agent run --output-format json "review auth.rs" | jq -r .result
```

```json
{
  "type": "result",
  "protocol_version": 1,
  "is_error": false,
  "result": "APPROVED: the retry loop is correct...",
  "stop_reason": "end_turn",
  "session_id": "3f7a91c2",
  "model": "tokamak-1-preview",
  "num_turns": 3,
  "duration_ms": 48213,
  "usage": {
    "prompt_tokens": 9011,
    "completion_tokens": 655,
    "total_tokens": 9666,
    "cached_tokens": 8123,
    "cache_write_tokens": null
  }
}
```

A failed run adds an `error` object, sets `stop_reason` to `error`, and reports whatever the model
had said before it broke as `result`:

```json
{
  "type": "result",
  "protocol_version": 1,
  "is_error": true,
  "result": "I started reviewing auth.rs and...",
  "stop_reason": "error",
  "error": { "code": "upstream_error", "message": "Upstream returned HTTP 400: tool_choice does not match any of the specified tools" },
  "session_id": null,
  "model": "tokamak-1-preview",
  "num_turns": 1,
  "duration_ms": 1204,
  "usage": { "prompt_tokens": 8123, "completion_tokens": 0, "total_tokens": 8123 }
}
```

| Field | Notes |
| --- | --- |
| `protocol_version` | The wire contract this object belongs to. See [the protocol version](#the-protocol-version) |
| `result` | The final answer, or the partial answer of the turn that failed. Reasoning is stripped |
| `stop_reason` | The upstream finish reason, `budget_exceeded` when `--max-budget-usd` stopped the run, or `error` |
| `error.code` | `context_overflow`, `upstream_error`, `setup_error`, or the raw code |
| `session_id` | Short session id for `--resume`. `null` only when no thread was saved: a fresh run that fails before its first save reports `null`, while a resumed run keeps the id it already had |
| `num_turns` | Agent turns taken. Subagent turns are not counted |
| `usage` | Summed over every request the run made, subagents included |
| `usage.cached_tokens` | Prompt tokens the provider served from its cache. `null` when the route reported no cache usage at all, `0` when it reported a zero read |
| `usage.cache_write_tokens` | Prompt tokens written into the provider's cache. `null` when the route reports no write count |

`null` and `0` are different answers: a route that reports no cache fields is not a cold cache, and a
route that reports a zero read is - the prefix was written every turn and never reused. Piped
consumers should branch on the difference rather than coalescing with `0`.

One-shot exit codes: `0` on completion, `1` on failure, `2` on bad CLI arguments, `53` when `--max-turns` is exhausted while the model is still calling tools. `--max-session-tokens` is advisory and does not produce exit `53`.

A run stopped by `--max-budget-usd` exits **`0`**: the turns it took are real work with a real
answer, and the ceiling did what it was asked to do. Hitting `--max-turns` while the model is still
calling tools exits **`53`**: it has no final answer. A plain-text consumer cannot tell a budget
stop from a finished task by exit code alone - the answer may be knowingly unfinished. CI can
read `stop_reason` from `--output-format json` and branch on `budget_exceeded`; in text mode the run
records a `[cost ceiling reached]` note instead.

#### Streaming JSON, in both directions [#streaming-json-in-both-directions]

`--output-format stream-json` prints one JSON object per line as the run proceeds -- assistant
text, tool calls, tool results, permission requests, and tool requests -- and ends with the same
`result` object `--output-format json` prints on its own.

`--input-format stream-json` opens the other direction: newline-delimited messages read on stdin
for the run's lifetime. It requires `--output-format stream-json`, because the client is the only
thing that can answer a permission request and it needs to be reading them.

| Message | Shape |
| --- | --- |
| Follow-up turn | `{"type":"user","text":"also check the tests"}` |
| Follow-up turn with an image | `{"type":"user","content":[{"type":"text","text":"what changed here?"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo..."}}]}` |
| Permission answer | `{"type":"permission","request_id":"perm-1","decision":"allow_once"}` |
| Host tool result | `{"type":"tool_result","request_id":"host-1","content":"{\"ok\":true}","is_error":false}` |
| Host tool result with an image | `{"type":"tool_result","request_id":"host-2","content":[{"type":"text","text":"front camera"},{"type":"image_url","image_url":{"url":"data:image/png;base64,iVBORw0KGgo..."}}],"details":{"exposure":12}}` |
| Stop the run | `{"type":"abort"}` |

A follow-up joins the run at the next turn boundary. A `user` message carries either `text` or
`content`, not both. `content` is the OpenAI content-part array: `text` parts and `image_url` parts
whose `url` is a base64 `data:` URL. The run shares no filesystem with a client, so a path would
only work for a client on the same machine. `init.input_content_parts` publishes the caps before a
client sends anything: at most 5 MiB decoded per image, 10 MiB across a message's images, 8 images
per message, and 16 MiB for one input line. Only `image/png`, `image/jpeg`, `image/gif` and
`image/webp` are accepted. A line over the cap is rejected without being parsed, and
`input_error.line` carries at most the first 4 KiB of it, with `line_truncated: true`: a rejected
line can be as large as the cap it crossed.

<Callout type="info">
A message's parts travel to the provider as written, images included: a `user` turn keeps its text
and `image_url` parts in order. Whether a model accepts an image is the model's business, not the
channel's - a provider without vision answers with its own error.
</Callout>

`decision` is `allow_once`, `allow_always`, or `deny`, and each `request_id` is answerable once. A
line that parses as none of these is reported as an `input_error` record and skipped, so one bad
line cannot end a healthy run.

#### Request provenance [#request-provenance]

Every outbound provider request emits a `request_provenance` record immediately before it goes out,
so a harness that records runs can tell **what was sent** without being handed the prompt. The
record is identity, not content:

```json
{"type":"request_provenance","session_id":"3f7a91c2","provider":"anthropic","model":"claude-sonnet-5","api_type":"anthropic","request_sha256":"9f2c...","body_bytes":9011,"tools_sha256":"1ab4...","images":[{"sha256":"77de...","mime_type":"image/png","bytes":41234,"tool_call_id":"call_7"}]}
```

| Field | Notes |
| --- | --- |
| `run_id` | The run that made the request. Absent for the main run, set to the subagent's own id for a child's turns |
| `session_id` | The session the request belongs to, as the `init` record names it. The correlation id the request itself carries is derived from it |
| `provider`, `model`, `api_type` | The configured provider the model resolved to, the bare model id the upstream receives, and the wire API the body is built for (`anthropic`, `google`, `openai-responses`; absent when the body is chat/completions) |
| `request_sha256` | SHA-256 of the request body as Jan built it, as canonical JSON: every object's keys sorted, recursively, so re-encoding the same members in another order gives the same digest. The whole request's identity, so two runs are comparable even when something this record does not itemize changed |
| `body_bytes` | The canonical body's serialized length, measured before the provider adapter serializes it its own way. Key order does not change it, so it describes the built body either way |
| `tools_sha256` | SHA-256 of the `tools` array as sent, canonical JSON in the same sense. Absent when the request carries no non-empty tool list. The tool array can change while the model id does not, which is why it is hashed separately from the body |
| `images` | One entry per image in the body, in order. Absent when there are none |

`images` hashes each image over its **decoded** bytes, so a host can hash the same frame it captured
and match it: `sha256` is that digest, `mime_type` and `bytes` describe the image, and
`tool_call_id` names the host tool call whose result carried it (`null` for an image the user
attached).

Emitted for subagent turns as well as the main run's, and for the side calls a run makes (a
compaction summary, a `/goal` evaluation inside a turn), since those are real requests with real
spend. A record rides the stream of the run that made the request.

On the RPC surface a record travels with the run it belongs to: `item/request_provenance` for a
request the main run made, and for a child's request the `request_provenance` record nested in
`item/subagent` (`{run_id, name, event}`), because a child's own events are wrapped rather than
raised at the top level - `run_id` names the child on either surface. A request made with no run in
flight -- the TUI's `/compact`, a `/goal` evaluation between turns -- has no run stream to ride and
is not reported.

The record deliberately carries no prompt, completion, header, or credential -- `request_sha256` is
how the two sides agree they are talking about the same request. The digest covers the body Jan
built, before the provider's adapter serializes it into that provider's own shape and appends the
transport fields it owns (`stream`, `stream_options`), so it is stable for a given run and
comparable across runs against the same provider -- and a harness that records what the provider
received can recompute it by sorting keys and dropping those two fields. Two runs on different wire
APIs differ by construction: the tool array each provider is sent is not the same shape.

#### Host tools [#host-tools]

A host tool is one the **client** implements: a device only the host can reach, an editor's own
buffer, anything whose state does not live in this process. Declare the set with `--host-tools`:

```json
[
  {"name": "camera", "description": "Grab a frame.", "capability": "read"},
  {"name": "yam.move_ee_ik", "description": "Move the arm.", "capability": "actuator",
   "parameters": {"type": "object", "properties": {"joints": {"type": "array", "items": {"type": "number"}, "minItems": 6}}, "required": ["joints"]}}
]
```

Each tool is advertised to the model as `host__<name>`. A name that is not ASCII letters, digits,
`_` and `-`, contains `__`, or would make the advertised name longer than 64 characters is
**mapped** rather than refused: other characters become `_`, and an 8-hex-digit hash of the
original name is appended so names that sanitize alike stay distinct. `yam.move_ee_ik` is
advertised as `host__yam_move_ee_ik_<hash>`. The mapping is deterministic, and `init.tool_specs`
shows the name the model will call. Still refused: an empty name, a name with a control character,
a built-in's name (`shell`, `read`, ...), and two declarations that land on the same advertised name.

When the model calls one, the run emits a `tool_request` and waits:

```json
{"type":"tool_request","request_id":"host-1","tool_name":"yam.move_ee_ik","args":{"joints":[0,0,0,0,0,0]}}
```

`tool_name` is the name **you declared**, never the advertised one: you dispatch on the name you
chose. A call made by a subagent carries that subagent's `run_id` as well; a main-run request has
none. Answer either with a `tool_result` carrying the same `request_id`.

Before a request is emitted, the call's arguments are checked against the tool's `parameters`. A
call that breaks the schema never reaches you; the model is told
`ERROR: arguments for host tool '<name>' do not match its schema: /joints: expected at least 6 items, got 3`
and can try again. The check covers `type`, `properties`, `required`, `additionalProperties`,
`items`, `minItems`, `maxItems`, `enum`, `const`, `minLength`, `maxLength`, `minimum`, `maximum`,
`exclusiveMinimum`, `exclusiveMaximum` and `pattern`; any other keyword is ignored, never failed.

A host tool is gated exactly as an MCP tool is: auto-approved in a default run like every other
call, prompted under `--safe` (its `permission_request` carries `prompt_kind: "mcp"`, the class for
any opaque third-party capability, naming the qualified `host__<name>`), withheld entirely in
read-only plan mode, and subject to the same deny list. The `init` handshake echoes the schemas
actually advertised in `tool_specs`, so a tool you declared but that is missing there was withheld
by policy and will never be called.

`content` is required even for a failure -- set `is_error: true` and put the reason in `content`,
which reaches the model as the tool's error. It is either a string or a content-part array of
`text` and `image_url` parts, held to the same caps as a `user` message. The parts are kept as the
tool message, in order, in the session transcript; the `tool_result` event carries only their text
(or `(image)` for an image alone).

<a id="tool-result-images"></a>Where an image lands on the provider request depends on the provider's API:

| Provider API | Tool image goes |
|---|---|
| OpenAI-compatible (`/chat/completions`, local servers, most gateways) | Tool message carries the text; the images lead the next user turn, each labeled with the call it answers |
| Anthropic Messages | Inside the call's `tool_result` block, as `image` blocks |
| Google Gemini 3+ | Nested in the call's `functionResponse.parts` as `inlineData` |
| Google Gemini 2.x and earlier | Beside the `functionResponse`, in the same turn |
| OpenAI Responses | In the `function_call_output`'s item list, as `input_image` |

Gemini accepts only inline (`data:`) images in a tool result; a remote image URL there is dropped. An
image in the transcript is re-sent on every later request of the session, and it is **not counted**
by the context indicator or auto-compaction (an image's token cost depends on its resolution and
the provider). A tool that returns a frame every turn should keep images small, or return a
fresh frame only when asked.

An optional `details` object is for your own display: it is **never sent to the model**, and the
run echoes it as a record right after that call's `tool_result`:

```json
{"type":"tool_details","id":"call_7","details":{"exposure":12}}
```

`details` are run-scoped: they are not saved with the thread, so a resumed session has the tool
result but not its details. Keep anything your UI needs after a resume on your side, keyed by the
call `id`.

A result over a cap is an `input_error` and the request stays pending, so you can answer again.
Each `request_id` is answerable once; a late or repeated answer is an `input_error` naming it as not
pending.

`capability` decides how a call is treated:

| `capability` | Prompted | Plan mode | Several in one turn |
| --- | --- | --- | --- |
| `read` | never | advertised | run concurrently |
| `actuator` | always, even when the run auto-approves | withheld | one at a time |
| absent | unless the run auto-approves (the default; `--safe` turns it off) | withheld | one at a time |

A prompt is a `permission_request` with `prompt_kind: "mcp"`, naming the advertised
`host__<name>`. With `--host-gate` none is raised for any host tool, because your `tool_request`
handler is the gate. The deny list applies to every class. The `init` handshake echoes the schemas
actually advertised in `tool_specs`, so a tool you declared that is missing there was withheld by
policy and will never be called. Host tools are never served by `jan mcp serve`.

A request you should no longer answer is withdrawn with a record, printed before the final `result`:

```json
{"type":"tool_request_cancelled","request_id":"host-1","reason":"aborted"}
```

`reason` is `aborted` after an `abort` message (the model is told the call was cancelled) and
`client_gone` when stdin closed first (it is told the client is gone). Either way the turn finishes
rather than hanging on an answer that cannot arrive.

<Callout type="info">
Closing stdin is not an abort -- the run finishes and still prints its `result`. But it does end the
only thing that can approve a gated tool call, so any request still pending is denied, as is any
that comes afterwards. Keep the pipe open for as long as you want a say.
</Callout>

#### The protocol version [#the-protocol-version]

The first line of a `stream-json` run is always an `init` record, printed before the run's first
event:

```json
{"type":"init","protocol_version":1,"session_id":"3f7a91c2-4e5d-4a6b-8c7d-0e1f2a3b4c5d","model":"tokamak-1-preview","cwd":"/home/me/app","tools":["read","write","shell","host__robot_arm_move"],"tool_specs":[{"type":"function","function":{"name":"host__robot_arm_move","description":"Move the arm.","parameters":{"type":"object"}}}],"input_kinds":["user","abort","permission","tool_result"],"input_content_parts":{"mime_types":["image/png","image/jpeg","image/gif","image/webp"],"max_image_bytes":5242880,"max_message_image_bytes":10485760,"max_images":8,"max_line_bytes":16777216,"max_echo_bytes":4096}}
```

| Field | Notes |
| --- | --- |
| `protocol_version` | The wire contract this stream speaks. The `result` object carries it too, so a `--output-format json` caller can pin it without an `init` record |
| `session_id` | The session this run saves under, unabbreviated -- the id `--resume` takes. `result.session_id` is its first 8 characters, and is `null` when the run saved nothing, which is how you tell whether this id names a session on disk |
| `model` | The model the run dispatches to, as `result` reports it |
| `cwd` | The project root the run's tools are confined to. `null` when the run was given none |
| `tools` | The tool names the request advertises, in the order it sends them. The tool array is the head of the cached request prefix, so the order is part of the payload, not a detail |
| `tool_specs` | The full schemas of the host tools this run advertises, so a host can check its constraints survived rather than trusting that they did. The advertised subset, not everything declared: a tool withheld by the deny list or plan mode is absent. Omitted entirely when the run advertises no host tools |
| `input_kinds` | The `type` values this run accepts on stdin. Empty when the run was not asked to read stdin, so "nothing can be sent back" is an answer rather than a missing field |
| `input_content_parts` | The content-part form of `user` and the caps a client must stay inside: `mime_types`, `max_image_bytes`, `max_message_image_bytes`, `max_images`, plus the channel-wide `max_line_bytes` and `max_echo_bytes`. `null` when the run reads no stdin. Additive, so a client that reads only `input_kinds` is unaffected |

One stream has no handshake: a run that fails before a model is resolved (nothing configured, no
usable provider) prints the terminal `result` alone, with `error.code` set to `setup_error`. There
is no session and no tool set to name yet, so a client treats "no `init`" together with that code
as the setup failure it is.

What a client may assume, for `protocol_version` 1:

- **A tag is never renamed and never removed.** `token` stays `token`. A rename is a version bump.
- **Fields and tags may be added**, so ignore what you do not recognise instead of failing on it.
  That is what lets a provider-neutral field land without breaking every consumer.
- **Assert the version, then degrade.** A client that needs behaviour from a later version checks
  `protocol_version` and takes its fallback path; it does not assume the behaviour is there.
- **`protocol_version`, `session_id` and `model` are always present** on `init`; everything else is
  optional in a later minor shape.

The version is bumped only for a change a v1 consumer cannot survive: a renamed or removed tag, a
removed field, or a new meaning for an existing one. Adding a field is not a bump.

Both directions of this channel are published as a JSON Schema by `jan cli agent schema`, so a
client validates against the types instead of re-declaring them (see [`schema`](#schema)).

### `step` [#step]

Run a single turn. Useful when debugging a prompt or a tool.

```bash
jan cli agent step "list the files you would change"
```

### `status` [#status]

Print the resolved project config and available providers as JSON.

```bash
jan cli agent status
jan cli agent status --project ~/code/app
jan cli agent status --provider tokamak   # as a launched session would resolve it
```

It takes the same `--provider`, `--api-key` and `--base-url` flags as `run`. The top-level
`capabilities` array lists what this build supports, and each provider entry names its
`header_names` (never their values) and its `base_url_source`. See
[Checking what a build supports](/agent/providers#checking-what-a-build-supports).

### `rpc` [#rpc]

Run one long-lived Jan process with multiple addressable sessions over LF-delimited JSON-RPC on stdin/stdout. `jan cli agent rpc` prints **protocol lines only** to stdout. The first request is `initialize` with `protocolVersion: 1` and a `clientInfo` name/version; wait for its response, then send the `initialized` notification before any other method. Calling a method early returns `-32002`.

```bash
jan cli agent rpc
jan cli agent rpc-schema   # generated request/event schemas on stdout
```

One client flow:

```text
initialize {protocolVersion:1,clientInfo:{name,version}}   -> version, serverInfo, input_content_parts
initialized                                                -> notification
session/start {cwd,model?,ephemeral?}                      -> {sessionId}
turn/start {sessionId,input:"Hello"}                       -> {turnId} immediately
item/<stream-json tag>                                     -> {sessionId,turnId,event}
turn/completed                                             -> {sessionId,turnId,stopReason,error?}
```

The `initialize` result carries the same `input_content_parts` object `init` does - the MIME types and the image, message and line caps a content-part array is held to - so a client that sends an image learns the limits from the handshake rather than by having a message rejected.

The `item/*` payload contains the existing `StreamEvent` object unchanged. A client can also call `session/list`, `session/resume`, `session/fork`, `session/archive`, `turn/steer`, `turn/interrupt` and `permission/respond {requestId,decision}`. Session ids address this **process**, not another RPC process. `turn/interrupt` closes the turn with one `turn/completed` carrying `stopReason: "interrupted"`, and puts that record on the wire before it answers the request. A client that closes stdin ends the process, and the turn that is still running is closed with `stopReason: "interrupted"` before it exits. An outstanding permission request is never granted - closing the channel is not an approval, and the gate's fallback for a request that is dropped without an answer is denial. Only one turn is active per process; a second `turn/start` returns `-32001` with `data.retryable: true` rather than silently queueing it, and so does a `turn/start` arriving while the output queue has no room left for the terminal record - the turn is refused instead of accepted and then lost. `turn/completed.stopReason` is the turn's outcome (`completed`, `error`, `interrupted`), not the upstream finish reason: that one is the `stop_reason` inside `item/done`. Transport errors (`-32700` malformed JSON, `-32600` invalid request, `-32601` unknown method, `-32602` bad params) differ from an agent failure reported by `turn/completed` with `stopReason: "error"`.

Input frames are capped at 16 MiB and the writer queue holds at most 1025 records. If a client stops reading, the queue stops growing and the turn ends with a terminal overload outcome rather than the run accumulating deltas without bound; the process stays alive while the writer is blocked on the full stdout pipe, and that outcome is delivered as soon as the client reads again. The server exits when stdin closes, or when a later record finds the client gone. `--max-turns` is a one-shot flag rather than a session setting: an RPC turn runs until it finishes or a client sends `turn/interrupt`. `ephemeral: true` skips thread persistence and automatic memory recall: the session neither indexes its answers nor has earlier answers recalled into its prompt. Memory notes written with `memory_write` still apply. Host tools are declared on `session/start` (`tools`, `builtins`) and answered with `tool/respond`; see [Protocol](./protocol#rpc-host-tools). The `schema` command below documents the older `stream-json` channel, while `rpc-schema` describes this RPC envelope, accepted requests and event types. RPC clients use `turn/completed`, not the process exit code, to classify an individual turn.

### `schema` [#schema]

Print the protocol as a JSON Schema document, generated from the Rust types that define the channel
rather than written by hand. No project and no provider are involved: the document is the same on
every machine, and it is the same document for a client that validates `stream-json` output as for
one that writes stdin messages.

```bash
jan cli agent schema                              # stdout
jan cli agent schema --out protocol/schema.json   # or a file
```

The document has two root properties:

| Property | Contents |
| --- | --- |
| `output` | The records a run writes to stdout: every `StreamEvent`, plus the `init`, `result`, `permission_decision` and `input_error` records the CLI adds around the loop |
| `input` | The lines a client may write to stdin: the `user`, `abort`, `permission` and `tool_result` messages |

`x-protocol-version` is the contract version the document describes, so a client validates against
the version it pinned rather than whatever happens to be installed. `$defs` names the record types,
so a client can validate one family (`#/$defs/StreamEvent`) as well as the whole stream.

The committed copy is `protocol/schema.json`:

```bash
make protocol-schema   # regenerate it after changing a protocol type
```

A unit test compares the file against a fresh generation, so a change to a protocol type cannot
land without the document moving with it, and generating twice from unchanged types is
byte-identical rather than a diff.

### `rpc-schema` [#rpc-schema]

Print the RPC surface as a JSON Schema document, generated from the Rust types that deserialize each
request. Like `schema`, no project and no provider are involved. It describes this surface only: the
`stream-json` records are in `protocol/schema.json`, and ACP (`jan acp`) uses the
upstream [ACP schema](https://agentclientprotocol.com) rather than one of its own.

```bash
jan cli agent rpc-schema                                  # stdout
jan cli agent rpc-schema --out protocol/rpc-schema.json   # or a file
```

| Property | Contents |
| --- | --- |
| `protocol_version` | The envelope version a client asserts in `initialize` |
| `envelope` | The JSON-RPC 2.0 request frame |
| `requests` | One entry per callable method, with the params it accepts: `initialize`, `session/list` (no params, so an empty object), `session/start`, `session/resume`, `session/fork`, `session/archive`, `turn/start`, `turn/steer`, `turn/interrupt`, `permission/respond` |
| `notifications` | The frame a notification uses - a `method` plus `params`. The `item/<tag>` method names come from each event's `type`, and `params.event` is the `StreamEvent` below |
| `events` | The `StreamEvent` an `item/<tag>` notification carries |

The committed copy is `protocol/rpc-schema.json`:

```bash
make protocol-rpc-schema   # regenerate it after changing an RPC type
```

A unit test compares the file against a fresh generation, and a second pins the method list the document
declares. The integration test then calls every method the document names and requires an answer that
is not `-32601`, so the artifact cannot offer a verb the server does not dispatch. CI regenerates both
this document and `protocol/schema.json` in one job and fails on a diff.

## `jan plugin` [#jan-plugin]

Manage project-local plugins without the interactive console. Plugin commands use
the `plugins/` folder of the project's store (`~/.jan/projects/<slug>/`) for `--project` (default `.`) and print JSON for list, install, and search.

```bash
jan plugin list
jan plugin install https://github.com/acme/release-tools
jan plugin install https://github.com/anthropics/claude-plugins-official/tree/main/plugins/claude-code-setup
jan plugin remove release-tools
jan plugin search prepare
```

Direct git URLs, optional `#ref` suffixes, marketplace names, and GitHub tree URLs selecting a
repository subdirectory are supported. A project marketplace is configured in `agent.toml`:

```toml
[plugins]
marketplace = "https://example.com/jan-plugins/index.json"
```

## `jan config` [#jan-config]

Manage provider credentials in `~/.jan/config.toml`.

```bash
jan config set --provider openai --api-key sk-... --base-url https://api.openai.com/v1 --model gpt-4o
jan config unset --provider openai
jan config list    # JSON, API keys redacted
jan config path    # print the file path, scaffolding a template if absent
```

| `set` flag | Description |
| --- | --- |
| `--provider <ID>` | Provider id, e.g. `openai`, `anthropic`, `groq`. Required |
| `--api-key <KEY>` | API key |
| `--base-url <URL>` | Base URL for the endpoint |
| `--model <ID>` | Model to expose. Repeatable; replaces any existing list |
| `--api-type <TYPE>` | Wire protocol (`openai`, `anthropic`). Defaults to OpenAI-compatible |

See [Configuration](/agent/providers).

## `jan login` [#jan-login]

Sign in to Tokamak and save the API key to `~/.jan/config.toml`.

```bash
jan login
```

## `jan cli threads` [#jan-cli-threads]

Inspect saved conversations. Output is JSON, so it pipes into `jq`.

```bash
jan cli threads list
jan cli threads get <ID>
jan cli threads messages <THREAD_ID>
jan cli threads delete <ID>
```

## `jan cli models` [#jan-cli-models]

Print every configured provider's models as JSON, keys redacted.

```bash
jan cli models list
```

```json
[
  {
    "provider": "anthropic",
    "api_type": "anthropic",
    "base_url": "https://api.anthropic.com/v1",
    "has_api_key": true,
    "id": "claude-fable-5"
  },
  {
    "provider": "tokamak",
    "api_type": "openai",
    "base_url": "https://api.tokamak.sh/v1",
    "has_api_key": true,
    "id": "anthropic/claude-fable-5",
    "info": {
      "context_length": 1000000,
      "max_output_tokens": 128000,
      "prompt_usd": 0.00001,
      "completion_usd": 0.00005
    }
  }
]
```

This includes providers inherited from Jan Desktop, so it can list models even when
`jan config list` shows none of its own.

`info` carries whatever the provider published about the model the last time its list was
refreshed - context window, output cap, per-token prices. It is absent for an endpoint that lists
ids and nothing else, which is the common case: a router-style gateway publishes this, while a
vendor API such as Anthropic's `/v1/models` returns little more than the ids.

### Refreshing the list [#refreshing-the-list]

A provider's model list is recorded when you sign in, and a gateway gains and retires models after
that. Re-read it:

```bash
jan cli models refresh                    # every provider in ~/.jan/config.toml
jan cli models refresh --provider tokamak # just one
```

Each provider's stored list is **replaced** with what its endpoint serves now, and the per-model
metadata behind `info` is cached alongside. An endpoint that answers with no models at all is
reported as a failure and its stored list is left alone - an empty answer is far more often an
outage than a retirement. If the refresh drops the model `default_model` names, the summary says
so, rather than leaving you to meet it as a 404 on the next run. The command exits non-zero if any
provider could not be listed - including a `--provider` that names nothing refreshable - so a
partial refresh is never reported as a complete one.

Only providers with an entry in `~/.jan/config.toml` are refreshed. A provider inherited from Jan
Desktop has nothing here to rewrite, so it is left alone; add it with `jan config set` first if you
want its list kept current.

The interactive console refreshes too, more cautiously: opening `/model` re-lists each provider once
per session and **adds** what it finds, keeping ids the endpoint did not mention (a
permission-scoped key or a paginated gateway can answer with a subset, and merely opening the picker
should not delete a model you configured by hand). The picker's note names how many were kept that
way. <kbd>Ctrl</kbd>+<kbd>R</kbd> inside the picker runs the full replacing refresh on demand.

## `jan cli mcp` [#jan-cli-mcp]

Manage Model Context Protocol servers in the shared `mcp_config.json` -- the same store Jan
Desktop and the interactive console read. Servers you add or enable here are available in both.

The desktop-only `Jan Browser MCP` bridge is never listed or editable from the CLI.

```bash
jan cli mcp list                            # JSON, env/header values redacted
jan cli mcp list --show-secrets             # include secret values
jan cli mcp get <NAME>                      # one server's full config
jan cli mcp add <NAME> --command npx --arg -y --arg my-mcp
jan cli mcp remove <NAME>
jan cli mcp enable <NAME>                   # connect on the next session
jan cli mcp disable <NAME>
```

A stdio server needs a command and optional args; an `http`/`sse` server needs a URL.

```bash
# stdio (the default transport)
jan cli mcp add files --command npx --arg -y --arg @modelcontextprotocol/server-filesystem \
  --env FOO=bar

# http, with a header and marked active immediately
jan cli mcp add exa --type http --url https://mcp.exa.ai/mcp --header Authorization=Bearer-secret \
  --active
```

| `add` flag | Description |
| --- | --- |
| `--command <CMD>` | Command for a stdio server (e.g. `npx`, `uvx`). Required for stdio |
| `--arg <ARG>` | Argument for the command. Repeatable |
| `--env KEY=VALUE` | Environment variable for a stdio server. Repeatable |
| `--type <TYPE>` | Transport: `stdio` (default), `http`, or `sse` |
| `--url <URL>` | URL for an `http`/`sse` server. Required unless stdio |
| `--header KEY=VALUE` | Header for an `http`/`sse` server. Repeatable |
| `--active` | Mark the server active immediately. Defaults to inactive |

Adding a server that already exists replaces it (an edit); its `active` flag is preserved. Newly
added servers default to inactive, so they are persisted but not connected until you `enable` them.

See [MCP](/agent/mcp).

## `jan mcp serve` [#jan-mcp-serve]

The other direction: serve Jan's own built-in tools to another MCP client. One project root, fixed
at startup; a path argument that escapes it is refused.

```bash
jan mcp serve --project .                   # stdio, for a client that spawns it as a child
jan mcp serve --transport http              # loopback HTTP; prints its URL and bearer token
jan mcp serve --allow-write --allow-exec    # add the mutating and shell tools
jan mcp serve --tool read --tool grep       # narrow the set
```

| Flag | Description |
| --- | --- |
| `--project <PATH>` | Project root the served tools are confined to. Defaults to `.` |
| `--transport <T>` | `stdio` (default) or `http` |
| `--port <PORT>` | Port for `http`. Defaults to an ephemeral one |
| `--token <TOKEN>` | Bearer token for `http`. One is generated and printed if omitted |
| `--allow-write` | Offer `write` and `edit`, confined to the project root |
| `--allow-exec` | Offer `shell`, under the same OS sandbox the agent's shell uses |
| `--tool <NAME>` | Serve only these tools. Repeatable; never widens what the flags above permit |

The default set touches no project file, and the two classes that can change the machine are opt-in:
the caller is another program, so anything that would wait for an approval returns an error rather
than blocking. On `http` the listener is loopback-only and every request must carry the token.

See [Serving Jan's own tools](/agent/mcp#serving-jans-own-tools).

## `jan acp` [#jan-acp]

**Experimental.** Serves the agent over the [Agent Client Protocol](https://agentclientprotocol.com)
on stdio, so Zed, JetBrains and other ACP editors can run it. The command is hidden from `--help`
and is off unless you set `JAN_EXPERIMENTAL_ACP=1` or add `[experimental] acp = true` to
`~/.jan/config.toml`. Without either, it exits with status 2.

```bash
JAN_EXPERIMENTAL_ACP=1 jan acp           # what an editor spawns
JAN_EXPERIMENTAL_ACP=1 jan acp --login   # the sign-in an ACP client runs in a terminal
```

See [Editors (ACP)](/agent/acp) for editor setup and the supported surface.

## `jan update` [#jan-update]

Update the binary to the latest build of the channel it was built for.

```bash
jan update
jan update --check   # report without installing
jan update --force   # reinstall even if current
```

<Callout type="info">
Builds compiled from source have no update channel embedded, so `jan update` will say so rather than
doing anything. Re-run the installer with `--source` to rebuild.
</Callout>

## Environment variables [#environment-variables]

| Variable | Description |
| --- | --- |
| `JAN_API_KEY` | API key for the target provider |
| `<PROVIDER>_API_KEY` | Per-provider key, e.g. `ANTHROPIC_API_KEY` |
| `JAN_BASE_URL` | Base URL for the provider named with `--provider`. Ignored without it |
| `JAN_CUSTOM_HEADERS` | Extra request headers for that provider, one `Name: Value` per line. Ignored without `--provider` |
| `TOKAMAK_BASE_URL` | Tokamak deployment for sign-in and account calls; `/v1` is appended to a bare origin |
| `TOKAMAK_WEB_URL` | Tokamak web app for the sign-in and API-keys pages, when it can't be derived from the API host |
| `JAN_INSTALL_DIR` | Install directory used by the install script |
| `JAN_EXPERIMENTAL_ACP` | `1` allows the experimental `jan acp` server; `0` blocks it even if `[experimental] acp` is set |

## Using it in CI [#using-it-in-ci]

A headless run with credentials from the environment and no prompts:

```bash
export ANTHROPIC_API_KEY="$SECRET_KEY"

jan cli agent run \
  --project . \
  --provider anthropic \
  "update the changelog for the current release"
```

No flag is needed: tool calls are auto-approved by default, which is what a CI runner wants since
there's nobody at a keyboard to answer a prompt. Do not pass `--safe` here. With no TTY on stdin
there is no way to answer, so every prompt is denied and the run stalls out on its first write.
