Skip to content
Draft, pre-release documentation

The sidebar in the Kasm viewer learns what the runtime is doing from a small set of HTTP routes on the observe port: a stream of events, the run’s files, and an inbox for messages between people and the run. This page is the wire contract, version 1, for anyone building a sidebar or a dashboard that follows a session. Replace the sidebar says how a sidebar gets into the viewer.

None of this needs a model. A run with no model produces the same event types except iteration, which only the runtime’s own agentic loop emits.

Version 1 is additive: new event types, new fields, new routes and new request field types can appear in any release, and a client ignores what it does not recognise. An existing field never changes meaning or type. A breaking change would be published as version 2 with a migration note.

Inside a Kasm session the viewer page is /desktop/<kasm_id>/vnc/vnc.html and the runtime’s port map is its sibling, /desktop/<kasm_id>/mcp/. A sidebar derives the base from its own URL:

/desktop/<kasm_id>/vnc/<anything> -> /desktop/<kasm_id>/mcp/

Every route below is relative to that base. Outside Kasm, the base is wherever KASM_SESSION_RUNTIME_PORT (default 9434) is reachable. The routes are served by kasm-session-runtime mcp when KASM_RUNTIME_SESSION_TOKEN is set, and by kasm-session-runtime run when KASM_RUNTIME_OBSERVE=1.

Routes Credential
every GET: /events, /artifacts/, /messages, /requests, /inbox, /attachments/<id> none by default, relying on Kasm’s session cookie; with KASM_RUNTIME_OBSERVE_TOKEN_REQUIRED=true (which KASM_RUNTIME_OBSERVE_INPUT=true forces), the session or the messaging token
POST /messages, POST /attachments, POST /actions (stop, finish_handoff), GET /runs/<run>/messages/<key> the messaging token (KASM_RUNTIME_INBOX_TOKEN) or the session token
POST /requests, POST /requests/<id>/cancel, POST /actions (ack) the session token only

The credential goes in the X-Kasm-Runtime-Session-Token header, or as a token query parameter where a header cannot be set (an <img>, a download link). The bundled sidebar reads one value from its page URL, ?kasm_runtime_token=, and sends it on every request. A deployment puts the messaging token there, or sends the sidebar’s writes through a backend that holds it. A sidebar never holds the session token.

With neither token configured, a gated read is refused with 503 and the inbox routes are absent (404).

The sender recorded on a message comes from the credential, operator for the messaging token and orchestrator for the session token. A body that carries a sender field is refused.

GET <base>events?after=<seq>&run=<run_id>

The stream is server-sent events, one per line of the run’s trace.jsonl:

: run <run_id>
id: <run_id>:<seq>
event: <type>
data: <one trace.jsonl line as JSON>
  • Read the stream with fetch and a stream reader rather than EventSource, so you can set the header and tell a 502 from Kasm’s proxy (the runtime is not up yet) from a dropped stream. The bundled sidebar polls every 1.5 seconds until the first live stream, and backs off (1, 2, 4 seconds, up to 10) only after a live stream drops.
  • : ping arrives every 15 seconds as a heartbeat.
  • event: closed with data: {} means the run closed its trace without a done event. The connection ends; do not retry until a new run appears.
  • after=<seq> replays every buffered event with a higher sequence number, but only when run names the run being served. Otherwise the stream starts from the new run’s first event. The Last-Event-ID header (<run>:<seq>) works the same way.
  • The buffer holds the last evidence.trace_replay_events events (default 4096). Older events are only in /artifacts/trace.jsonl. A client that sees a gap in seq subscribes again with its last seq.
  • A different run_id, in the id field or an info event, means a new run has started in the same session. Reset everything you hold.

Each event’s data has seq, type, ts and exactly one payload field, named after the type (except delivery, whose payload is message, and done, which has none).

Type Payload When
info info at open and whenever more is known; each is a complete snapshot, later fields winning. Fields: run_id, mode (run or mcp), app, version, kasm_id, display, goal, agentic, max_iterations, plan_steps, cdp, vision, tool_set, operator_config, input_observation
status status a phase change: phase, detail; with done also verdict, outcome, model_verdict, summary, reasons, notes
iteration iteration one model call in the agentic loop: n, content, tool_calls, usage, model_ms
step step one transcript row, the same object as report.json’s steps[]: n, action, backend, rung, expect, result, duration_ms, post, url
screenshot screenshot a saved capture: file (fetch it from /artifacts/), label, step, width, height, bytes
compact compact the agentic loop compacted its context
escalation escalation the escalation record after its pause: reason, paused_s, before, after, ended_by
log log one runtime log line: level (info, warn, error), msg
message message an inbox message, when it is received
delivery message the same message at each state change; replace your copy by id
request request a question for a person, when created and when closed
action action a control action when received and, for stop, again when the loop has exited
transfer transfer a finished file transfer over /v1/files/ or /v1/dirs/: n, at, action, ok, result. Never the content
input input one key or button event the session received, only with KASM_RUNTIME_OBSERVE_INPUT=true: cursor, t, kind, origin, and key or button fields
done none the last event of the run

The phases a status event carries are boot, launch, judge, steps, plan, agentic and done. A kasm-session-runtime mcp session reports steps with the detail serving MCP, then done when it ends. Show any phase you do not recognise as text.

step.result is capped at evidence.tool_result_chars. Inbox activity also appears as step rows: message:<id> when a message enters the agent’s context, and tool:ask_human(...) when the agent asks a question.

An input event for a key pressed while the runtime itself was typing carries private: true and no key fields, whatever its origin, so the runtime’s own typing (which may be a credential) never reaches the trace. A key event from the runtime has no key fields either.

GET <base>artifacts/<file>

Regular files from the run’s output directory, read-only: report.json, trace.jsonl, screenshots/*, and anything else written there. There is no directory listing; dotfiles, directories and paths that leave the directory (symlinks included) get 404.

  • screenshots/* is served as immutable and cacheable; everything else is no-store.
  • The content type comes from the file extension, with no sniffing: .png and .jpg as images, .json as JSON, .jsonl, .md, .txt, .tail and .log as plain text, anything else as a download.
  • Every response carries X-Content-Type-Options: nosniff and a sandboxing Content Security Policy, so nothing served here can run script on the viewer’s origin.

The inbox carries messages from people to the run, questions from the run to people, and control actions. People in the loop explains how a run uses it. This section is the wire format.

POST <base>messages {run, key, text?, attachments?: [id], reply_to?, revision?, values?}
GET <base>messages?since=<rev>
GET <base>messages?state=pending,queued
GET <base>messages/<id>
GET <base>runs/<run_id>/messages/<key>
  • run is required and must be the run being served. Otherwise the answer is 409, with the current run and epoch in the body. Learn the run id from the info event or GET /inbox.
  • key is an idempotency key the client generates, unique per run and sender. Generate it before the first attempt. A repeat with the same content returns the stored message with 200; with different content, 409.
  • 202 means the message is stored; the agent may not have read it yet. Follow its delivery events, or poll since=, to see its state.
State Meaning
pending stored and not yet in the agent’s context
queued in the agent’s context
dispatched a model request carrying it was attempted
responded that request was answered
unknown the request ended without an answer that could be classified
undelivered the run ended before any request carried it; delivery.undelivered_reason says why
  • since=<rev> returns every message whose rev is higher, including older messages whose state changed. state= is a snapshot by state, for reconciling after a reload.
  • If a response is lost, retry with the same run, key and content. 200 means it had been accepted, 202 that it has been now, and 409 with a different run that the session has moved on. In that last case, ask GET /runs/<that run>/messages/<key> with the same credential: 200 returns the record, 404 means that run never held it, 503 means its history cannot be read.
  • text longer than evidence.inbox_message_chars gets 413. An unknown body field gets 400.
POST <base>attachments multipart/form-data, one part named "file" -> 201 {id, mime, name, size, sha256}
GET <base>attachments/<id>[?inline=1]

Upload first, then list the returned id in a message’s attachments. Downloads keep the uploaded Content-Type, with nosniff, Content-Security-Policy: sandbox and Content-Disposition: attachment. ?inline=1 is honoured only for image/png, image/jpeg, image/gif and image/webp, so a sidebar can show a thumbnail and nothing uploaded can execute on the viewer’s origin. An upload over evidence.inbox_attachment_bytes gets 413 and nothing is kept.

A request is a typed question the run asks a person. The agentic loop’s ask_human tool creates one; an outside consumer creates one with POST /requests.

GET <base>requests?state=open
GET <base>requests/<request_id>
POST <base>requests {title, help?, fields: [...]} session token only
POST <base>requests/<request_id>/cancel session token only
{"run": "…", "request_id": "q1", "schema_version": 1, "revision": 1,
"title": "Which browser?", "help": "…",
"fields": [{"id": "browser", "type": "string", "required": true,
"enum": ["chrome", "firefox"], "label": "Browser", "hint": "radio"}],
"deadline": "2026-09-16T14:02:00Z", "state": "open"}
Field type Constraints
string max_length, enum, pattern
integer, number minimum, maximum
boolean none
string[] enum (required), min_items, max_items
attachment accept, max_bytes

label, help and hint are for display only; validation uses id, type and the constraints. A client that does not understand a field type should tell the person the request needs a richer interface, and must not guess at a value. The bundled sidebar renders string (with or without enum), integer, number and boolean.

A reply is a message with reply_to, the request’s revision, and values keyed by field id:

{"run": "…", "key": "…", "reply_to": "q1", "revision": 1, "values": {"browser": "firefox"}}
  • The server validates the reply. A failure is 422 with a fields list of {field, error} and leaves the request open. A value that redaction would turn into something invalid is refused the same way.
  • One valid reply closes the request (closed_by: reply). A second reply, or a reply to a closed or unknown request, is 409 and is not stored. A retried reply with the same key returns 200 with the stored reply.
  • A request closes by reply, timeout (the agent stopped waiting), cancelled or run_ended. The request event carries the closing.
POST <base>actions {run, key, action, args?}
Action Credential Valid when Effect
stop messaging or session token the agentic loop is running no further model requests; the tool call in flight finishes and the loop exits. The action is accepted at once and stopped once the loop has exited
finish_handoff messaging or session token an escalation pause is running ends the pause now; the escalation record says ended_by: operator. It does not resume the run
ack session token only an outside consumer holds the run (mcp mode) reports a message’s state: args: {ids, state, detail?}

An action in the wrong state gets 409 with the state found, and the refusal is recorded as an action too. key follows the same idempotency rule as messages.

GET <base>inbox
{"run": "…", "epoch": "…", "consumer": "loop", "rev": 12,
"counts": {"pending": 0, "queued": 1, "responded": 3}, "open_requests": ["q2"]}

It gives the run id and epoch to put on every write, the revision to poll since= from, and what is open. Fields that would be empty or false are left out. stopping: true and stopping_tool appear while a stop waits for the tool in flight, and closed: true once the inbox has closed. journal_error appears only when a write to the inbox file failed, and means the file is not the whole record. consumer is loop for a run with a goal and external under kasm-session-runtime mcp, where the outside caller reads the messages.