Docs
Jan Agent
CLI

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

FlagDescription
--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
--planStart in read-only plan mode
--safeAsk for approval before writes, shell commands, and MCP tool calls
--sandboxRun shell commands under OS confinement. Off by default
--no-sandboxRun shell commands unconfined, overriding a persistent sandbox setting
--resume [ID]Resume the most recent session, or one whose id starts with ID
-c, --continueResume the most recent session
--fork-sessionOpen the resumed session as a new thread, leaving the original resumable
--worktreeWork in a dedicated git worktree instead of the project directory. Off by default
--no-worktreeWork 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 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

⚠️

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 step
jan cli agent run --max-turns 5 --max-session-tokens 20000 "triage the build failure"

FlagDescription
--project <PATH>Project root. Defaults to .
--model <ID>Model id, overriding [agent].model
--safeAsk for approval before writes, shell commands, and MCP tool calls
--sandboxRun shell commands under OS confinement. Off by default
--no-sandboxRun shell commands unconfined, overriding a persistent sandbox setting
--resume[=ID]Resume the most recent session, or one by id
-c, --continueResume the most recent session
--fork-sessionOpen the resumed session as a new thread, leaving the original resumable
--worktreeWork in a dedicated git worktree instead of the project directory. Off by default
--no-worktreeWork 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-urlProvider 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-gateThe 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 }
}

FieldNotes
protocol_versionThe wire contract this object belongs to. See the protocol version
resultThe final answer, or the partial answer of the turn that failed. Reasoning is stripped
stop_reasonThe upstream finish reason, budget_exceeded when --max-budget-usd stopped the run, or error
error.codecontext_overflow, upstream_error, setup_error, or the raw code
session_idShort 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_turnsAgent turns taken. Subagent turns are not counted
usageSummed over every request the run made, subagents included
usage.cached_tokensPrompt 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_tokensPrompt 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.

MessageShape
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"}]}

FieldNotes
run_idThe run that made the request. Absent for the main run, set to the subagent's own id for a child's turns
session_idThe 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_typeThe 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_sha256SHA-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_bytesThe 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_sha256SHA-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
imagesOne 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 APITool 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 MessagesInside 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 earlierBeside the functionResponse, in the same turn
OpenAI ResponsesIn 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:

capabilityPromptedPlan modeSeveral in one turn
readneveradvertisedrun concurrently
actuatoralways, even when the run auto-approveswithheldone at a time
absentunless the run auto-approves (the default; --safe turns it off)withheldone 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}}

FieldNotes
protocol_versionThe 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_idThe 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
modelThe model the run dispatches to, as result reports it
cwdThe project root the run's tools are confined to. null when the run was given none
toolsThe 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_specsThe 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_kindsThe 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_partsThe 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).

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 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.

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 rpc
jan cli agent rpc-schema # generated request/event schemas on stdout

One client flow:


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. 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 # stdout
jan cli agent schema --out protocol/schema.json # or a file

The document has two root properties:

PropertyContents
outputThe records a run writes to stdout: every StreamEvent, plus the init, result, permission_decision and input_error records the CLI adds around the loop
inputThe 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 # stdout
jan cli agent rpc-schema --out protocol/rpc-schema.json # or a file

PropertyContents
protocol_versionThe envelope version a client asserts in initialize
envelopeThe JSON-RPC 2.0 request frame
requestsOne 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
notificationsThe 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
eventsThe 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 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:


[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-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 flagDescription
--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 list
jan 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.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. 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 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.


# 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 flagDescription
--command <CMD>Command for a stdio server (e.g. npx, uvx). Required for stdio
--arg <ARG>Argument for the command. Repeatable
--env KEY=VALUEEnvironment 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=VALUEHeader for an http/sse server. Repeatable
--activeMark 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 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

FlagDescription
--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-writeOffer write and edit, confined to the project root
--allow-execOffer 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.

See Serving Jan's own tools.

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 spawns
JAN_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 update
jan update --check # report without installing
jan 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

VariableDescription
JAN_API_KEYAPI key for the target provider
<PROVIDER>_API_KEYPer-provider key, e.g. ANTHROPIC_API_KEY
JAN_BASE_URLBase URL for the provider named with --provider. Ignored without it
JAN_CUSTOM_HEADERSExtra request headers for that provider, one Name: Value per line. Ignored without --provider
TOKAMAK_BASE_URLTokamak deployment for sign-in and account calls; /v1 is appended to a bare origin
TOKAMAK_WEB_URLTokamak web app for the sign-in and API-keys pages, when it can't be derived from the API host
JAN_INSTALL_DIRInstall directory used by the install script
JAN_EXPERIMENTAL_ACP1 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.