Skip to content

AI Agent Sessions ​

Kaimon can spawn and own AI agent sessions – a headless coding agent that you talk to programmatically. Kaimon launches the agent, normalizes its output into a vendor-neutral event model, streams those events on the gate event bus, and tracks each agent's lifecycle and token usage. The natural sibling of a gate REPL session and a managed extension.

The agent can be the claude CLI, a local Ollama or MLX model, or anything that speaks the Agent Client Protocol – see ACP agents for the last of those, whose tool policy works differently.

An agent session is "an AI you can drive": open one in a directory, send it turns, and consume the streamed assistant text, reasoning, and tool calls as it works. You drive it with a small set of MCP tools and consume one event channel per agent.

How It Works ​

  1. ClaudeBackend drives the local claude CLI in headless multi-turn stream-JSON mode (claude -p --input-format stream-json --output-format stream-json), reading events over a pipe.

  2. The backend maps Claude's native stream-JSON events into the ACP update model – Kaimon's vendor-neutral lingua franca (modeled on the Agent Client Protocol update schema). ACPClientBackend and OllamaBackend implement the same handful of methods, so the registry, the event bus, the MCP tools and the TUI are written once and know nothing about which agent is running.

  3. The AgentSessionManager owns the process, runs the status FSM, accumulates per-session cost, and relays every event onto the gate event bus on channel agent:<id> as a {kind, turn, data} envelope.

Authentication

Agents authenticate with the host's own claude login (your subscription) – Kaimon never touches credentials. You must be logged in to the claude CLI. No API keys are required.

Cost

Agent turns run through your logged-in claude CLI, so their usage counts against whatever that CLI is authenticated with – normally your Claude subscription (Pro/Max/Team), exactly like driving Claude Code interactively, and subject to the same plan usage limits. It is not separate Agent-SDK / per-token API billing (unless you've pointed the CLI at an API key yourself). A per-session dollar figure is work in progress: claude's reported total_cost_usd is unreliable on subscription plans, so costUsd is held at 0.0 rather than shown — token usage is tracked and surfaced in agent_status. The default model is the sonnet family alias, which resolves to the CLI's latest Sonnet (pass a pinned id like claude-sonnet-5 for reproducibility). Images returned in tool results are the biggest token consumer (roughly width × height / 750 tokens), so tool-result PNGs are box-downsampled to a max long edge before they reach the agent – agent_image_max_long_edge in ~/.config/kaimon/config.json (default 1568 px, the model's own effective cap; lower it to trade image quality for usage).

Opening an Agent ​

Open an agent with agent_open, giving it a working directory. It returns an agent_id; events then stream on agent:<id>.

jsonc
// agent_open
{ "cwd": "/path/to/project", "model": "sonnet", "permission": "default" }
// → {"agent_id": "9f3a1c20"}

Then send it turns and consume the agent:9f3a1c20 channel:

jsonc
// agent_send
{ "agent_id": "9f3a1c20", "text": "Summarize the failing tests in this repo." }
// → {"turn": 1}

The MCP Tools ​

All are registered as Kaimon MCP tools, callable by your own Claude Code and by extensions via the service endpoint. Returns are JSON strings.

ToolArgumentsReturns
agent_opencwd (req), model, effort, permission, permission_mode, allowed_tools, only, disallowed_tools, mcp_config, system_prompt, id{"agent_id": "<id>"} – spawns & owns the process
agent_sendagent_id (req), text (req){"turn": <n>} – writes a user turn (async); events stream on agent:<id>
agent_runagent_id (req), text (req), timeout{"text": "…"} – sends a turn and blocks until it ends, returning the assistant text
agent_outputagent_id (req), turn, which ("last_message"/"full_turn"/"all"), include_tools, max_chars{agent_id, turn, status, done, text, truncated, dropped_chars, usage} – non-blocking read of an agent's output (partial while still working). Pair with agent_send to dispatch-then-poll instead of blocking on agent_run
agent_statusagent_id (req){status, model, cwd, turn, created_at, last_activity, session_id, transcript, event_log, usage, queues_prompts}
agent_set_modelagent_id (req), model (req){"switched": bool} – repoints a live agent at another model, keeping its conversation. ACP agents only, and only within the same agent; false everywhere else, where the fallback is to close and reopen
agent_list–{"agents": [ …status… ]}
agent_interruptagent_id (req){"interrupted": bool} – best-effort cancel of the in-flight turn
agent_closeagent_id (req){"closed": bool} – kills the process; the slot is retained as :dead for review

agent_open options ​

OptionDefaultDescription
cwd(required)Working directory for the agent. Must exist.
modelsonnetModel alias or id. The sonnet alias tracks the latest Sonnet; pin (e.g. claude-sonnet-5) for reproducibility. A Claude model may carry an inline effort suffix, e.g. claude-sonnet-5/high or sonnet/low. Prefixes select another backend: acp:<agent>[:<model>] (ACP agents), ollama:<tag>, vmlx:<tag>.
effort(CLI default)Claude thinking/effort level (low | medium | high) → claude --effort. Lower = less thinking, faster turns. Overrides a model suffix if both are given.
permissiondefaultPermission preset (see below): default | notebook | specialist | lab | auto | bypass.
permission_mode(from preset)Override the preset's raw claude permission-mode: default | acceptEdits | plan | auto | bypassPermissions.
allowed_tools[]Extra tool allowlist. Composes with the preset.
onlyfalseClaude CLI agents: make allowed_tools the agent's whole tool set. The CLI loads only the built-in tools it names (plus ToolSearch when it names MCP tools) and no MCP server but Kaimon's, and Kaimon hides and refuses every Kaimon tool it does not name. Without it, allowed_tools only spares the agent permission prompts. ACP agents get the same effect from the specialist preset. See Restricting a claude agent to its tools.
disallowed_toolsthe agent_* toolsTools the agent may not call. Defaults to a recursion guard (see below). Pass [] to allow nested agents.
mcp_confignullPath to an --mcp-config JSON pointing the agent at the live Kaimon MCP, so it can call slate.* / ex / extension tools.
system_promptnullInstructions/context appended to the agent's system prompt (applies to every turn).
id(auto)Caller-supplied agent id (e.g. key it to a notebook). Reopening a :dead id reuses the slot.

Permission presets ​

Rather than assembling raw flags, pick one word per spawn. The recursion guard stays on regardless.

PresetPostureEffect
defaultEdits onlyacceptEdits permission mode; no extra tools.
notebookKaimon's tools instead of its ownAllows mcp__kaimon, and denies the agent's native file and shell tools (Bash, Read, Write, Edit, Glob, Grep, NotebookEdit). For an agent working on a notebook, where slate_* sees live cell state and Read sees stale disk.
specialistA named few verbsnotebook without the allowance, so the spawn's own allowed_tools is the entire toolset. A specialist with no allowed_tools may call nothing.
labDrive the labAllows the whole Kaimon MCP server (mcp__kaimon) so the agent can drive slate.* / ex / etc., plus the ACP fs/* callbacks.
autoSelf-governingThe model's permission classifier decides per call.
bypassNo checksAdds --dangerously-skip-permissions. Sandboxed / trusted directories only.

An explicit permission_mode overrides the preset's mode. allowed_tools composes with the preset's allowlist, except under specialist, where it replaces it.

Restricting a claude agent to its tools ​

only = true holds a claude CLI agent to allowed_tools, with these limits:

  • An argument pattern restricts through permission prompts, not through the tool set: Bash(git *) loads all of Bash, so under the auto or bypass presets (or permission_mode = "bypassPermissions") any command runs.

  • Preset allowances are part of the list. notebook and lab allow the whole Kaimon server, so only cannot narrow Kaimon's tools under them; use default or specialist.

  • Kaimon tells the agent's calls apart by a header in the MCP config it generates, so only refuses a caller-supplied mcp_config, and refuses to start an agent that names MCP tools before the Kaimon server's port is known.

A preset's deny half applies at Kaimon's own tools/call door for every backend, so mcp__kaimon entries – the recursion guard included – always hold. Its reach into the agent's own tools depends on the agent; see below.

Recursion guard

By default an owned agent is blocked from calling the agent_* tools (--disallowedTools), so it cannot recursively spawn or kill agents (a fork bomb). The CLI's own subagent spawners (Agent, Task, Workflow) are in the same list, since the guard is about the count, not the toolset – a subagent inherits its parent's restrictions but multiplies the processes. Pass disallowed_tools = [] to deliberately allow nested agents.

ACP agents ​

A model of acp:<agent>[:<model>] drives an agent over the Agent Client Protocol on stdio instead of the claude CLI. The agent's own model ids keep their slashes, which is why the separator is a colon.

<agent>CommandModel selection
opencodeopencode acpGenerated per-session config
claudeclaude-agent-acpOver the wire (session/set_model)
geminigemini --experimental-acpOver the wire

Each session gets its own generated opencode config, which is what lets two agents run under different presets at once. It replaces the user's own, with one thing carried across: the provider block, so a model served locally stays selectable (acp:opencode:ollama/qwen2.5:14b). MCP servers are not carried – a server Kaimon did not attach sends calls with no agent id, so no preset would reach them.

jsonc
// agent_open
{ "cwd": "/path/to/project", "model": "acp:opencode:opencode/minimax-m3",
  "permission": "notebook" }

ACP is a two-way protocol rather than a firehose: the agent calls back mid-turn for permission and to read files, so Kaimon answers as a peer. Two things follow that the Claude path does not have. The model is a session method rather than a spawn flag, so agent_set_model repoints a live agent without losing its conversation. And agent_status reports queues_prompts – whether a turn sent while another is running is queued or destroys the reply in progress, which the agent declares at initialize.

Where an ACP preset is enforced ​

Every Kaimon tool an ACP agent calls is checked against the preset at Kaimon's own door, and any path argument it carries is confined to the agent's cwd. That part holds for every agent. What differs is whether the preset reaches the agent's own Read, Write and Bash, because ACP has no way to express tool policy. There are three routes, and none is universal:

  • A bridge plugin (opencode only) refuses the call before it is made.

  • _meta.claudeCode.options on session/new (an agent that publishes the claudeCode extension, i.e. claude-agent-acp) hands the agent its deny list, so it never offers those tools to the model.

  • session/request_permission works for any agent that asks – but an agent doing its own file I/O does not ask about everything, and opencode never asks at all.

An agent with neither of the first two keeps only the third. Kaimon says so on the event stream at spawn rather than letting the preset read as configured, so a notebook agent whose native tools are not actually bound reports it in its first AgentError. For a preset that holds against native tools, use acp:opencode or acp:claude.

A boundary, not a sandbox

Path confinement is a correctness boundary against a cooperative agent. It resolves symlinks before checking, but the check and the open are separate steps, and a shell tool carries its target inside a command string where no path check can see it – so the presets that permit a shell (default, lab, bypass) are granting exactly that.

The Event Stream ​

Every agent event is published on Kaimon's global event bus – the same one extensions already subscribe to (ipc://<sock_dir>/kaimon-events.sock, topic = channel name). The channel is agent:<agent_id>, and each message is a {kind, turn, data} envelope, JSON-encoded.

jsonc
// streamed assistant text — delta:true is an incremental token chunk to append;
// delta:false (also emitted at block end) is the complete, authoritative copy
{"kind":"assistant_text","turn":1,"data":{"delta":true,"content":{"type":"text","text":"two-"}}}
{"kind":"assistant_text","turn":1,"data":{"delta":false,"content":{"type":"text","text":"two-state paramagnet…"}}}
// reasoning (same delta semantics)
{"kind":"thought","turn":1,"data":{"delta":true,"content":{"type":"text","text":"…"}}}
// a tool the agent invoked (status in_progress)
{"kind":"tool_use","turn":1,"data":{"call":{"toolCallId":"tu1","title":"Read",
  "kind":"read","status":"in_progress","content":[],"locations":[],"rawInput":{…}}}}
// a tool result (status completed|failed) — content blocks incl. base64 images
{"kind":"tool_result","turn":1,"data":{"update":{"toolCallId":"tu1","status":"completed",
  "content":[{"type":"content","content":{"type":"text","text":"ok"}}]}}}
// end of turn — stop reason + usage (costUsd is WIP, held at 0.0)
{"kind":"result","turn":1,"data":{"stopReason":"end_turn",
  "usage":{"inputTokens":100,"outputTokens":50,"cacheReadTokens":10,"cacheCreationTokens":0,"costUsd":0.0}}}
// lifecycle / misc
{"kind":"status","turn":1,"data":{"status":"working"}}   // starting|idle|working|dead
{"kind":"turn_started","turn":1,"data":{}}
{"kind":"plan","turn":1,"data":{"entries":[{"content":"…","priority":"high","status":"pending"}]}}
{"kind":"usage","turn":1,"data":{"usage":{…}}}
{"kind":"permission","turn":1,"data":{"toolCall":{…},"options":[…],"requestId":"…"}}
{"kind":"error","turn":1,"data":{"message":"…","data":null}}
{"kind":"user_text","turn":1,"data":{"content":{"type":"text","text":"…"}}}  // echo

Envelope kind values ​

kindMeaning
assistant_textAssistant message content. Carries delta (see Token streaming).
thoughtReasoning. Carries delta, same as assistant_text.
user_textEcho of the user's own input.
tool_useA tool invocation (status usually in_progress). Under streaming it's emitted twice for one toolCallId: an empty announce when the call begins, then the authoritative copy with full rawInput when it finishes — replace by id (see below).
tool_input_deltaA streamed fragment of a tool call's input JSON: {toolCallId, partialJson}. Fragments concatenate to the full input (not valid JSON until complete). Liveness only — see Token streaming.
tool_resultA status/result delta for a call; content blocks may include base64 images.
planThe agent's execution plan (entries with priority + status).
usageA running token/cost usage update.
turn_startedA turn began.
resultA turn ended – carries stopReason and the turn's usage.
statusSession status transition: starting | idle | working | dead.
errorA backend/process error (parse failure, crash, non-zero exit).
permissionThe agent is asking the user to approve a tool call.

Token streaming ​

assistant_text and thought envelopes carry a delta boolean so text flows token-by-token instead of arriving as one block at the end of a long turn:

  • delta: true – an incremental chunk. Append it to the currently-open block (start a new block if the last one was a different role or is done).

  • delta: false (or absent) – the complete, authoritative block, emitted at block end. If a streamed block of the same role is open, replace its accumulated text with this one and mark it done; otherwise it stands alone. This self-heals any dropped delta – deltas are for liveness, the complete copy is truth.

A turn therefore emits, in order: turn_started → several assistant_text {delta:true} → one assistant_text {delta:false} → any tool_use/tool_result → result. Both copies are sent on purpose; don't treat the delta:false block as a duplicate.

Tool calls stream too. When the agent invokes a tool, the call is announced the instant it begins (a tool_use with status: in_progress and no input yet), then its arguments stream as tool_input_delta fragments — so a slate_add_cell shows its code materializing live rather than appearing all at once. When the block finishes, a second tool_use for the same toolCallId carries the authoritative, fully-parsed input; the first announce had none, so replace the call by toolCallId. So: append partialJson fragments for liveness, then replace with the authoritative tool_use's rawInput. The tool's actual output arrives separately, later, as a tool_result.

Because deltas exist only for liveness, the on-disk event log and the TUI monitor skip delta:true and tool_input_delta chunks – they record the authoritative blocks (delta:false, both tool_uses, the tool_result), so the log and replay stay compact under thousands of token chunks. Consumers that buffer envelopes for reload-replay should do the same.

Back-compatibility

delta is purely additive. A consumer that ignores it sees every complete block exactly as before – streaming is opt-in on the consumer side.

Subscribing ​

If you are a managed extension, prefix-subscribe to all agent channels in your manifest and handle them in on_event:

toml
# extension manifest
event_topics = ["agent:"]   # ZMQ prefix match — all agent channels
julia
function on_event(channel, data, session_name)
    # channel == "agent:<id>", data == the JSON string of {kind,turn,data}
    env = JSON.parse(data)
    # forward env to your UI transport (e.g. SSE to the browser)
end

To subscribe directly (non-extension): SUB to ipc://<sock_dir>/kaimon-events.sock, ZMQ.subscribe(sub, "agent:<id>") (or "agent:" for all), then recv the topic (a String) followed by the payload (a Vector{UInt8}) and deserialize it to a (channel, data, session_name) named tuple. data rides the bus as a JSON string, convenient to forward straight to a browser/SSE without re-encoding. (sock_dir = Kaimon.KaimonGate.sock_dir().)

Lifecycle ​

An agent's status FSM is :starting → :idle ⇄ :working → :dead:

StatusMeaning
:startingProcess spawned, not yet ready.
:idleConnected and waiting for a turn.
:workingA turn is in flight.
:deadThe process has exited (or was closed).

agent_close retains the agent as :dead (greyed out in the TUI) for review rather than removing it immediately. Up to 10 dead agents are kept (oldest dropped first); on-disk logs persist regardless. Reopening a dead id reuses the slot – handy for a notebook reconnecting.

Reaping ​

On Kaimon (re)start, leftover owned-agent processes from a prior instance are killed (tracked in a pid file at ~/.cache/kaimon/agents/pids.json) so claude children are never leaked across restarts. Kaimon shutdown closes all live agents.

Logs and Transcripts ​

Two records are kept per agent:

PathOwnerSchema
~/.cache/kaimon/agents/<id>.events.jsonlKaimonVendor-neutral {ts, kind, turn, data} JSONL — one record per normalized event. Stable; survives restarts.
~/.claude/projects/<munged-cwd>/<sessionId>.jsonlclaudeClaude's own native transcript (vendor-specific).

Both paths are reported by agent_status (event_log and transcript). The Kaimon-owned event log is the one to tail for a stable schema you control; consumers can read it instead of (or alongside) the live bus.

TUI Monitor ​

The Agents tab monitors every owned agent: status, model, token usage, and a live feed of recent events. In the list:

  • [x] closes a live agent / dismisses a dead one.

  • Enter on the detail pane opens a full-screen, scrollable event-history overlay (read from the owned event log); Esc closes it.