CLI Reference
jan with no subcommand opens the interactive console. Everything else is
non-interactive, for scripts and CI.
jan [OPTIONS]jan <COMMAND>
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 |
--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 |
jan # current directoryjan --project ~/code/app # somewhere elsejan --task "fix the failing test" # with a first messagejan -c # resume the latest sessionjan --resume 3f7a91c2 # resume by id prefixjan --plan # read-onlyjan --safe # ask before writes and shell commandsjan --sandbox # confine shell commands to the projectjan -c --fork-session # branch the latest session into a new onejan --worktree # work in a dedicated checkout, not yours
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.
jan cli agent
Run the agent without a UI. This is the form to use in scripts.
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.
--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.
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 stepjan 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 |
--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 |
--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); 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 |
run takes a positional task, so a space-separated --resume ID would swallow it. Write the value
form with an equals sign: --resume=3f7a91c2.
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.
jan cli agent run --output-format json "review auth.rs" | jq -r .result
{ "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:
{ "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 |
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
--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.
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.
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
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:
{"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
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:
[ {"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 (bash, read, ...), and two declarations that land on the same advertised name.
When the model calls one, the run emits a tool_request and waits:
{"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).
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:
{"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:
{"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.
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.
The protocol version
The first line of a stream-json run is always an init record, printed before the run's first
event:
{"type":"init","protocol_version":1,"session_id":"3f7a91c2-4e5d-4a6b-8c7d-0e1f2a3b4c5d","model":"tokamak-1-preview","cwd":"/home/me/app","tools":["read","write","bash","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.
tokenstaystoken. 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_versionand takes its fallback path; it does not assume the behaviour is there. protocol_version,session_idandmodelare always present oninit; 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).
step
Run a single turn. Useful when debugging a prompt or a tool.
jan cli agent step "list the files you would change"
status
Print the resolved project config and available providers as JSON.
jan cli agent statusjan cli agent status --project ~/code/appjan 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.
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.
jan cli agent rpcjan cli agent rpc-schema # generated request/event schemas on stdout
One client flow:
initialize {protocolVersion:1,clientInfo:{name,version}} -> version, serverInfo, input_content_partsinitialized -> notificationsession/start {cwd,model?,ephemeral?} -> {sessionId}turn/start {sessionId,input:"Hello"} -> {turnId} immediatelyitem/<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. 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
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.
jan cli agent schema # stdoutjan 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:
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
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 (opens in a new tab) rather than one of its own.
jan cli agent rpc-schema # stdoutjan 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:
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
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.
jan plugin listjan plugin install https://github.com/acme/release-toolsjan plugin install https://github.com/anthropics/claude-plugins-official/tree/main/plugins/claude-code-setupjan plugin remove release-toolsjan 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:
[plugins]marketplace = "https://example.com/jan-plugins/index.json"
jan config
Manage provider credentials in ~/.jan/config.toml.
jan config set --provider openai --api-key sk-... --base-url https://api.openai.com/v1 --model gpt-4ojan config unset --provider openaijan config list # JSON, API keys redactedjan 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.
jan login
Sign in to Tokamak and save the API key to ~/.jan/config.toml.
jan login
jan cli threads
Inspect saved conversations. Output is JSON, so it pipes into jq.
jan cli threads listjan cli threads get <ID>jan cli threads messages <THREAD_ID>jan cli threads delete <ID>
jan cli models
Print every configured provider's models as JSON, keys redacted.
jan cli models list
[ { "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
A provider's model list is recorded when you sign in, and a gateway gains and retires models after that. Re-read it:
jan cli models refresh # every provider in ~/.jan/config.tomljan 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. Ctrl+R inside the picker runs the full replacing refresh on demand.
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.
jan cli mcp list # JSON, env/header values redactedjan cli mcp list --show-secrets # include secret valuesjan cli mcp get <NAME> # one server's full configjan cli mcp add <NAME> --command npx --arg -y --arg my-mcpjan cli mcp remove <NAME>jan cli mcp enable <NAME> # connect on the next sessionjan cli mcp disable <NAME>
A stdio server needs a command and optional args; an http/sse server needs a URL.
# 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 immediatelyjan 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.
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.
jan mcp serve --project . # stdio, for a client that spawns it as a childjan mcp serve --transport http # loopback HTTP; prints its URL and bearer tokenjan mcp serve --allow-write --allow-exec # add the mutating and shell toolsjan 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 bash, 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.
jan acp
Experimental. Serves the agent over the Agent Client Protocol (opens in a new tab)
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.
JAN_EXPERIMENTAL_ACP=1 jan acp # what an editor spawnsJAN_EXPERIMENTAL_ACP=1 jan acp --login # the sign-in an ACP client runs in a terminal
See Editors (ACP) for editor setup and the supported surface.
jan update
Update the binary to the latest build of the channel it was built for.
jan updatejan update --check # report without installingjan update --force # reinstall even if current
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.
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
A headless run with credentials from the environment and no prompts:
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.