Sidebar protocol
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.
Compatibility rule
Section titled “Compatibility rule”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.
Finding the base address
Section titled “Finding the base address”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.
Credentials
Section titled “Credentials”| 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.
Events
Section titled “Events”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
fetchand a stream reader rather thanEventSource, 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. : pingarrives every 15 seconds as a heartbeat.event: closedwithdata: {}means the run closed its trace without adoneevent. The connection ends; do not retry until a new run appears.after=<seq>replays every buffered event with a higher sequence number, but only whenrunnames the run being served. Otherwise the stream starts from the new run’s first event. TheLast-Event-IDheader (<run>:<seq>) works the same way.- The buffer holds the last
evidence.trace_replay_eventsevents (default 4096). Older events are only in/artifacts/trace.jsonl. A client that sees a gap inseqsubscribes again with its lastseq. - A different
run_id, in theidfield or aninfoevent, 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.
Artifacts
Section titled “Artifacts”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 isno-store.- The content type comes from the file extension, with no sniffing:
.pngand.jpgas images,.jsonas JSON,.jsonl,.md,.txt,.tailand.logas plain text, anything else as a download. - Every response carries
X-Content-Type-Options: nosniffand a sandboxing Content Security Policy, so nothing served here can run script on the viewer’s origin.
The inbox
Section titled “The inbox”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.
Messages
Section titled “Messages”POST <base>messages {run, key, text?, attachments?: [id], reply_to?, revision?, values?}GET <base>messages?since=<rev>GET <base>messages?state=pending,queuedGET <base>messages/<id>GET <base>runs/<run_id>/messages/<key>runis required and must be the run being served. Otherwise the answer is 409, with the currentrunandepochin the body. Learn the run id from theinfoevent orGET /inbox.keyis 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
deliveryevents, or pollsince=, 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 whoserevis 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,keyand 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, askGET /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. textlonger thanevidence.inbox_message_charsgets 413. An unknown body field gets 400.
Attachments
Section titled “Attachments”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.
Requests and replies
Section titled “Requests and replies”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=openGET <base>requests/<request_id>POST <base>requests {title, help?, fields: [...]} session token onlyPOST <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
fieldslist 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),cancelledorrun_ended. Therequestevent carries the closing.
Control actions
Section titled “Control actions”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.
Inbox state
Section titled “Inbox state”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.
- Replace the sidebar: install your own sidebar beside the bundled one.
- People in the loop: how the run uses messages, questions, stop and handoff.
- HTTP API: every route on the same listener.