Docs
Jan Agent
Protocol Records

Protocol Records

You do not need this page for the ADK quickstarts. The ADK handles the messages for you. Use this reference when building your own client, debugging the channel, or generating types.

Choose the transport first

RPCstream-json
Start commandjan cli agent rpcjan cli agent run --output-format stream-json --input-format stream-json
Process lifetimeOne process, multiple sessions, one active turn at a timeOne process per run
First exchangeinitialize request, then initialized notificationThe runtime emits init when setup succeeds
During workJSON-RPC requests and notifications such as item/tokenRecords with a type, such as token
End of workturn/completed; the process stays availableresult, then process exit
Best forADKs and long-lived applicationsDirect CLI automation

These are different envelopes. Do not send a stream-json abort record to RPC, or parse an RPC notification as a bare stream-json event. For normal application code, use the JavaScript or Python ADK.

Read the generated records

Generate the schema from the same binary your client will run:


jan cli agent rpc-schema --out rpc-schema.json
jan cli agent schema --out stream-schema.json

ArtifactWhat to read
protocol/rpc-schema.json (opens in a new tab)protocol_version, envelope, requests, notifications and events
protocol/schema.json (opens in a new tab)Root input and output schemas, $defs, and x-protocol-version

Both are generated from Rust types and checked for drift in CI. The repository links describe main; your installed binary can be older. RPC's schema covers requests and events, not every response shape. The method tables below document the host-tool responses.

Check runtime support

Use RPC's initialize response for its negotiated protocol and capabilities. For stream-json, use that run's init record. These are not interchangeable capability inventories.

The current ADKs use RPC. If your binary has no rpc command, update the nightly runtime. ACP is a separate protocol for editors, not another name for either of these. See Editors (ACP) for the experimental jan acp server.

See the RPC command reference for startup and session methods, and CLI protocol version for the stream-json contract.

RPC stream events

A turn's events reach the client as notifications named item/<tag>, where <tag> is the event's own type on the stream-json surface: one naming rule for both, and no second list to keep in sync. protocol/rpc-schema.json publishes the resulting notification names in its events section, generated from the same Rust enum that produces them. Look up the record there rather than maintaining a second event list in your client.

A request_provenance event therefore arrives as item/request_provenance, carrying the identity of each outbound provider request (run, session, provider, model, wire API, body and tool-array hashes, image hashes) and none of its content. A subagent's request is reported on its child's stream, so its record arrives nested in item/subagent ({run_id, name, event}) rather than at the top level. See Request provenance for the record's fields.

A turn whose provider request fails ends with item/error ({type: "error", code, message}, the provider's own words where it gave any) and then turn/completed with stopReason: "error" and the same message. A client must read that as the turn's outcome: the assistant text is whatever arrived before the failure, which is usually nothing, and no further records follow.

RPC host tools

A host declares tools on session/start; the model calls them, Jan sends the call to the host, and the host's answer becomes the model's tool message. Request and parameter shapes are in the requests section of protocol/rpc-schema.json.

Declaring tools

session/start accepts these optional fields next to cwd, model and ephemeral:

FieldDefaultMeaning
tools[]Declarations {name, description?, parameters?, capability?}. capability is read or actuator; absent is opaque.
builtinstruefalse advertises only the host tools: no built-ins, MCP, plugin, ask, todo or monitor tools. subagents: true adds back only dispatch_subagent, list_subagents, message_subagent and stop_subagent.
subagentssame as builtinsWhether the model may delegate. With builtins: false, true adds only dispatch_subagent, list_subagents, message_subagent and stop_subagent, and every child is limited to the session's host tools (no shell, files, web or skills). A child's host tool call arrives as an ordinary item/tool_request with run_id set. false removes subagent tools even when built-ins are on.
systemPromptnoneThe whole system prompt, sent exactly as given: no identity, guides, environment, session start (date and branch), todo guidance or recalled project memory. The session's answers are still indexed into project memory for later sessions; ephemeral is the only way to turn that off, and it also stops the thread being saved. The system prompt is exactly yours; Jan may still append runtime notices to the conversation (<SYSTEM> notes such as a finished subagent, cost or token-budget notices, and compaction summaries). Subagents keep their own prompts. Kept across session/model/set and session/fork. A blank string fails with -32602.
permissions"jan""host" means the host's callback is the gate: Jan never sends a permission_request for a host tool. Built-ins are unaffected.

The result is {sessionId, model, tools, toolSpecs}. tools lists the names the next turn advertises (with builtins: false, only the host tools, plus the two subagent tools when subagents: true). toolSpecs echoes the schemas of the host tools that are actually advertised, so a host can compare them with what it sent and see a tool that a deny list or Plan mode withheld. A declaration Jan refuses (reserved name, duplicate, unknown key, non-object parameters) fails the whole call with -32602 and data: {"kind": "invalid_tools"}, and the message names the problem.

Host tools are advertised as host__<name>. Every other tool source has its own prefix (plugin__, MCP server names) and duplicates within the host set are refused, so a host tool can never shadow or be shadowed by another source. The host's own name is what it receives back.

Answering calls

A call arrives as the notification item/tool_request with event: {request_id, tool_name, args, run_id?}. run_id is set when a subagent made the call. Answer it with:


{"jsonrpc":"2.0","id":7,"method":"tool/respond","params":{
"requestId":"host-1",
"content":[{"type":"text","text":"frame captured"},
{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}],
"isError":false,
"details":{"frame":7}}}

content is a string or an array of text / image_url parts, and images are held to the same caps as a user message. The parts are kept, in order, as the tool message in the transcript; where an image goes on the provider request depends on the provider's API (see tool result images). details is for the host's display only: Jan emits it as item/tool_details, never sends it to the model, and does not persist it with the thread. A reply for a request that is no longer pending (already answered, cancelled, or never issued) fails with -32602 and data: {"kind": "not_pending"}. So does a permission/respond whose requestId is not pending in the running turn's session, including one another session was issued.

turn/interrupt withdraws every request the host still holds. For each one Jan sends item/tool_request_cancelled with reason: "interrupted", and all of them arrive before the turn's turn/completed. When stdin closes, pending requests are cancelled the same way with reason: "client_gone", but those records are best effort.

Changing a session between turns

MethodParamsResult
session/tools/get{sessionId}{tools, toolSpecs}
session/tools/set{sessionId, tools}{tools, toolSpecs}. Replaces the host set; refusals are invalid_tools.
session/model/set{sessionId, model}{model}. History, host tools, builtins, subagents, systemPrompt and permissions are kept. A model no configured provider can serve fails here with -32602 rather than being substituted at the next turn; the check is servability, so it does not fetch (or refresh) the credential the model's provider needs - the next turn resolves that, and reports a credential failure as its own.
session/reset{sessionId}{tools, toolSpecs}. History is cleared; model and tools are kept.

These four methods are refused while that session's turn is running: -32001 with data: {"retryable": true, "kind": "turn_active"}. Retry after turn/completed. (session/fork and session/archive of a busy session, and a second turn/start, keep their plain retryable -32001.) An unknown sessionId is -32602. session/fork copies the host tools, builtins, subagents, systemPrompt and permissions, but pending requests are never shared between sessions. session/list entries include hostTools, the number of declared host tools.

provider/model is explicit selection: the provider is taken from the name, so a provider that accepts ids it does not list (a gateway, a proxy) is used as asked rather than overridden. A bare model id that no provider lists is refused as above.

Every mutating method is answered. A client that gets no answer, for example because the process died, should treat the session as gone and close it. This fail-closed handling belongs to the ADK or client, not the wire protocol.

Direct stream-json examples

Prefer the ADK unless you need to own the pipes yourself. The runnable examples in examples/adk (opens in a new tab) show that lower-level alternative. CI runs all four against a real runtime and checks these excerpts against their source. They are not the RPC ADK examples above.

Read a streamed answer without the ADK

The complete scripts handle startup and cleanup. Their reading loops distinguish answer text from the terminal result:


for await (const line of createInterface({ input: run.stdout })) {
const record = JSON.parse(line)
if (record.type === 'init') {
console.error(`[session ${record.session_id} on ${record.model}]`)
} else if (record.type === 'token') {
process.stdout.write(record.text)
} else if (record.type === 'result') {
result = record
}
}


for line in run.stdout:
record = json.loads(line)
if record["type"] == "init":
print(f"[session {record['session_id']} on {record['model']}]", file=sys.stderr)
elif record["type"] == "token":
print(record["text"], end="", flush=True)
elif record["type"] == "result":
result = record

Answer a host tool call without the ADK

The complete scripts declare a weather fixture and handle permissions. These dispatch blocks show the extra work a direct client owns: looking up a handler, reporting errors and matching the request id. The ADK performs that dispatch for you.


case 'tool_request': {
// `tool_name` is the name declared above, without `host__`.
let content
let isError = false
try {
const handler = handlers[record.tool_name]
if (!handler) throw new Error(`no handler for ${record.tool_name}`)
content = JSON.stringify(handler(record.args))
} catch (error) {
// The model sees this as the tool's error.
content = `${error.name}: ${error.message}`
isError = true
}
console.error(`\n[${record.tool_name}(${JSON.stringify(record.args)}) -> ${content}]`)
send({ type: 'tool_result', request_id: record.request_id, content, is_error: isError })
break
}


elif kind == "tool_request":
# `tool_name` is the name declared above, without `host__`.
handler = HANDLERS.get(record["tool_name"])
try:
if handler is None:
raise KeyError(f"no handler for {record['tool_name']}")
content, is_error = json.dumps(handler(record["args"])), False
except Exception as error: # the model sees this as the tool's error
content, is_error = f"{type(error).__name__}: {error}", True
print(f"\n[{record['tool_name']}({json.dumps(record['args'])}) -> {content}]", file=sys.stderr)
send({
"type": "tool_result",
"request_id": record["request_id"],
"content": content,
"is_error": is_error,
})