Server API
The local server started by kiki web exposes a REST API under /api and a WebSocket event stream at /api/ws. GET /api/sessions keeps the flat GUI-compatible list contract, while GET /api/sessions/query provides the domain-grouped advanced query contract. This page is the protocol reference for both. For how to start the server and its command-line options, see the kiki command reference; for an end-to-end walkthrough, see Local server and API.
The complete request/response schema of every endpoint is owned by the server's live specification documents: GET /openapi.json (OpenAPI) and GET /asyncapi.json (AsyncAPI). Both require authentication.
WARNING
The REST and WebSocket APIs described on this page are experimental: interface stability is not guaranteed, and endpoints, fields, and event types may change in any release. When integrating, rely on the /openapi.json and /asyncapi.json documents served by your version.
Conventions
Address
The default address is http://127.0.0.1:58627. When the port is taken, the server retries with the next port (up to 100 times); use --port / --host to change the bind. Multiple instances can coexist under the same home directory; running instances register under ~/.kiki/server/instances/.
Authentication
All /api/* paths (including /openapi.json and /asyncapi.json) require the bearer token, except:
OPTIONSpreflight requestsGET /api/healthz(liveness probe)- Static web assets (non-
/api/paths)
Trusted local clients use the private server.local-owner capability. REST carries it in Authorization: Bearer <token>; local WebSocket upgrades accept that header or kimi-code.bearer.<token>. Remote GUI peers instead need an enabled inbound gate, the current owner credential and a per-source grant (X-Kiki-Connection-Grant on HTTP). They use /api/klient/events, not the legacy /api/ws endpoint. Connection management and forwarding are local-owner-only; four thread-bridge data endpoints use independent bridge credentials. See Authentication and kiki connections for setup and rotation.
Failed authentication returns HTTP 401 with envelope code 40101. On non-loopback binds, a source that fails authentication 10 times within 60 seconds is banned for 60 seconds, during which every request gets HTTP 429 (code 42901).
Response envelope
Every JSON response is wrapped in a uniform envelope:
{
"code": 0,
"msg": "success",
"data": {},
"request_id": "01JZX4A6E7M8V0R3Q0N2K2M5Q9"
}code: the business outcome;0means success. See the error-code bands below.data: the payload on success. Note that some "error" envelopes also carry a non-nulldata— for example, resolving an already-resolved approval returns40902withdata.resolvedset tofalse— so clients should checkcodefirst, thendata.request_id: a ULID for this request. Clients may supply one via theX-Request-Idheader; invalid values are regenerated by the server.
The HTTP status is almost always 200; the business outcome lives in code. Exceptions:
| Situation | HTTP status |
|---|---|
| Authentication failure / rate limit | 401 / 429 |
| Provider created, provider catalog imported | 201 |
| Provider deleted | 204 |
| Binary/streaming endpoints | 206 (Range) / 304 (ETag unchanged) where supported — capabilities differ per endpoint, see Binary and streaming endpoints |
GET /api/files/{file_id} download errors | real 404 / 500 (still carrying an envelope body) |
The 201 responses still carry the standard envelope (code 0) — only the status line follows the REST convention for resource creation. A 204 response has no body by definition, so a successful delete is reported by the status code itself.
Error codes
Error codes are grouped by band:
| Band | Meaning | Examples |
|---|---|---|
0 | Success | |
400xx | Bad request | 40001 validation failed (details lists each field), 40003 provider is OAuth-managed |
401xx | Auth and readiness | 40101 unauthorized, 40110 no provider configured, 40113 model not resolved |
404xx | Not found | 40401 session, 40408 MCP server, 40409 file path |
409xx | State conflict | 40901 session busy, 40902 approval already resolved, 40922 page conditions mismatch page_token |
410xx | Expired | 41001 approval timed out, 41002 question timed out, 41003 temporary file expired |
413xx | Size or boundary exceeded | 41302 file read over 10 MB, 41304 path escapes the session directory |
429xx | Rate limited | 42901 auth-failure ban, 42902 too many fs watches |
500xx | Server internal error | 50001 uncaught exception, 50003 persistence failure |
6xxxx / 7xxxx / 8xxxx | Tool runtime / LLM provider / MCP passthrough errors; msg carries the upstream text |
Pagination
List endpoints come in two styles:
- Cursor style:
before_id/after_id(mutually exclusive) pluspage_size(1–100), responding with{ items, has_more }. Used by the session list, message list, transcript, and others. page_token: an opaque token (bound to a fingerprint of the query conditions), used byPOST /api/searchandGET /api/sessions/query. Changing any query condition mid-pagination invalidates the token: the session query returns40922, while search returns40001.GET /api/sessions/queryalso offers a statelesspagepage-number mode as an alternative.
REST endpoints
Endpoints are grouped by resource below. A :{action} suffix in a path is the action convention — POST to path:action on a single resource for non-CRUD operations (such as :fork and :archive on a session).
Server and metadata
| Method and path | Description |
|---|---|
GET /api/healthz | Liveness probe; auth-exempt |
GET /api/meta | Server version, capability map, server_id, experimental flags |
POST /api/shutdown | Graceful shutdown (replies 200 first); mounted only on loopback binds |
Mailbox storage upgrade
A home with legacy or interrupted mailbox storage starts in maintenance mode before the ordinary engine, plugins, or sessions are bootstrapped. Authenticated GET /api/meta returns storage_mode: "maintenance", storage_status_path: "/api/storage", and the usual server_id and server_home_id; unavailable capabilities are false. It uses the existing server address, authentication, host checks, and origin policy. Session endpoints return HTTP 503 until normal startup.
| Method and path | Request | Response data |
|---|---|---|
GET /api/storage | No body | StorageMigrationView; reads status without starting migration or changing authority |
POST /api/storage | { "action": "migrate" }, cancel, rollback, start, or retry_cleanup | Updated StorageMigrationView; launches the requested operation |
The schemas are exported by @kiki/protocol: storageMigrationViewSchema and storageMigrationActionSchema. A view contains schemaVersion: 1, mode, status, actions, transition, and optional operationFailure. Status supplies authority, phase, state, progress, source and backup paths, optional writer, failure, and warning, and canCancel / canRollback. These status fields also appear in kiki storage --json. A mailbox.checkpoint_warning means the SQLite mailbox is usable but historical upgrade progress needs repair before rollback; it does not mark migration as failed or offer another migrate.
Drive controls from actions, not from a guessed combination of phase and state: a durable phase may finish while its worker and locks are still closing. migrate resumes preserved progress; waiting leaves the old writer in control, so stop that writer and retry when migrate is offered. cancel stops a cancellable import or normal-start attempt. rollback is offered only while the original can be restored without losing new SQLite writes. A malformed action returns envelope code 40001; an unavailable action returns 40901. Missing credentials return HTTP 401.
After migration, start explicitly requests normal startup. Continue polling the same address: preparation longer than 30 seconds remains starting, with cancel available rather than an automatic timeout. A failed or cancelled start leaves the upgrade intact and reports transition.state: "failed" with the original startup failure. While the attempt is closing, cleanupPending is true and actions is empty; another start waits for its cleanup. If cleanup fails, transition.cleanupFailure explains it and the only action is retry_cleanup; this retries unfinished cleanup without starting the engine again. Once cleanup succeeds, cleanupPending becomes false and actions offers start and any available rollback.
Switch to normal session loading only when GET /api/meta reports storage_mode: "normal" with the same server_id and server_home_id. The home, identity, and port stay unchanged; clients do not need to launch another daemon. An interrupted rollback also returns to maintenance and offers rollback so it can finish before another migration or normal startup. In normal mode, GET /api/storage remains readable and actions is empty.
Space presets
GET /api/homes/presets returns { items: [{ id, name, color?, description }] }. Only the kiki baseline preset is currently bundled. Create a space from the main space's server with POST /api/homes:
{ "path": "C:/example/space", "preset": "kiki" }The path must be an absolute directory distinct from the main home. Missing directories are created; existing folders, including nonempty ones, keep their contents. Optional name and color override the preset's display defaults for a new space. If the folder already contains a valid space inherited from this main home, creation registers it with its original identity, name, and settings without rewriting home.toml; repeating the request for the same registered space returns it again. Creation returns { id, name, color?, preset?, path }; GET /api/homes lists the effective values. A well-formed but unregistered preset returns code 40427 (space.preset_not_found); malformed input, invalid space metadata, spaces belonging to another main home, and child-origin creation requests return 40001.
POST /api/homes:inspect accepts { "path": "C:/example/space" } and returns { state }, where state is missing, empty, nonempty, or space. Inspection never changes the folder. The GUI uses it to show a non-blocking notice for existing content or an existing space before submission.
A space's home.toml stays at schema = 1 and gains an optional preset key. Existing homes are not rewritten. Omitting preset uses Kiki defaults; an unknown preset already on disk retains its identity and user values, falls back to Kiki defaults, and reports a configuration diagnostic. Defaults are bundled read-only, not copied into config.toml: schema defaults < preset < inherited base config < this home's config < environment (memory overrides remain highest). Credentials and models retain the existing home inheritance rules. The baseline supplies the display name Kiki and UI defaults; it does not change bot settings or install another application.
GET /api/config exposes space_ui: { defaultSkin, landingPage, plugins }. A partial POST /api/config patch uses space_ui: { default_skin?, landing_page?, plugins? }; landing pages are /new or /bots. The plugin lists are currently empty and do not install plugins. These UI defaults are a client contract: clients should apply them only when no user preference exists. Configuration origins include preset, and removing a home override reveals the lower layer again.
Desktop integrations can call the native create_space_shortcut command with { homeId: "main" } or a registered h-… id, exposed as host.createSpaceShortcut(homeId). Windows returns { homeId, path } for a desktop .lnk pointing to the current executable with --home "<absolute space path>". The existing startup and second-launch handling select that space; no second binary is installed. The command never replaces an existing shortcut. Rejections carry { code, message }: unsupported_platform, invalid_space, desktop_unavailable, executable_unavailable, shortcut_exists, or shortcut_failed. macOS and Linux creation is not supported. Shortcuts depend on the executable and home staying at those paths; remove the old shortcut and recreate it after moving either.
Login and usage
| Method and path | Description |
|---|---|
GET /api/auth | Auth readiness snapshot |
POST /api/oauth/login | Start the OAuth device-code login flow |
GET /api/oauth/login | Poll the login flow state |
DELETE /api/oauth/login | Cancel a pending login flow |
POST /api/oauth/logout | Log out the managed provider |
GET /api/oauth/usage | Plan usage and limits |
GET /api/oauth/userinfo | Account profile |
POST /api/usage-export/destinations/{id}/auth/begin | Start a VibeCafe sign-in for a destination |
POST /api/usage-export/auth/{id}/poll | Poll that sign-in |
POST /api/usage-export/auth/{id}/cancel | Cancel that sign-in |
POST /api/usage-export/destinations/{id}/disable | Pause that destination without signing out |
For the official vibecafe.ai destination, send {} to POST /api/usage-export/destinations/{id}/auth/begin. Omitted storage defaults to auto: Kiki saves the approved credential in the system keyring, or in a permission-protected private file if the keyring write fails. The file is not encrypted at rest. There is no address, client id, or key to paste; the service is fixed to https://vibecafe.ai. Existing explicit keyring requests remain keyring-only; explicit private-file requests still require acknowledge_file_storage: true. Webhook and script credential storage choices are unchanged.
The response carries flow_id, state, user_code, verification_uri, expires_at, and poll_after_ms. Poll with POST /api/usage-export/auth/{id}/poll at the returned interval, or cancel with POST /api/usage-export/auth/{id}/cancel. The flow moves from pending to connected, cancelled, denied, expired, or error. If automatic storage cannot save to either backend, it returns error with error_category: "vibe-auth-storage-failed"; restore access to the server's credential directory or keyring, then begin a new sign-in.
connected means the credential was saved, not that sending is enabled. The destination returns to draft with enabled: false and no export consent; preview the payload before enabling it. credential_storage reports the actual keyring or private-file backend and is reused after restart. Custom-address destinations use manual credentials; beginning device sign-in there returns vibe-auth-official-only.
POST /api/usage-export/destinations/{id}/disable pauses a destination that is already signed in. It sets enabled to false, state to "disabled", and next_at to null, and it stops a send already in flight. The saved account and credential_storage stay, and so does the queue. The page keeps showing that account. Pause, and Disable sync on an official vibecafe.ai destination, both call it. Deleting the stored credential is a separate remove.
Provider and external-service quotas
GET /api/usage/provider-quotas lists connected sources and cached official quotas without querying remote services. To query one source explicitly, the local owner sends POST /api/usage/provider-quotas/refresh with { "source_id": "provider:example" }. Disable quota collection with PUT /api/usage/provider-quotas/enabled and { "source_id": "provider:example", "enabled": false }; enabling does not query or log in, and refreshing an off source never enables it. The typed client exposes these operations as klient.rest.providerQuotas.snapshot(), .refresh(sourceId) and .setEnabled(sourceId, enabled).
The response uses schema version 1 with independent sources and meters. Each meter supplies its label, unit, scope, optional model group and reset window. Unknown numbers are null, not zero; percentage quotas are not absolute token counts, money uses the main currency unit, and key/team/account balances must not be added together. Source states distinguish unknown, ready, stale, error, auth_required, off and unsupported. Keep stale values visibly dated and use auth.action to open the existing login or credential settings; no credentials belong in quota requests. Queries are manual, single-flight per source, with a 60-second retry floor and a five-minute cache lifetime; a passed reset also makes the cached result stale. There is no all-account poller. Unsupported services and executor-owned credentials remain unsupported rather than estimating a vendor balance from local consumption.
Config
| Method and path | Description |
|---|---|
GET /api/config | Read the global config (secret fields redacted) |
POST /api/config | Merge-patch the config; broadcasts event.config.changed |
request_compression and responses_web_socket are system sections on both calls. Omit a section to leave it unchanged. JSON null removes it. A compression object needs encoding and targets, with optional min_bytes and dictionary, or encoding set to "off" and no other fields. dictionary applies only to zstd. A WebSocket object needs targets, or off: true and no targets. An incomplete object is rejected and the saved file stays unchanged.
Session-title prompt reads, read-only defaults and field-level save/delete patches are documented under session_title.
The hooks value accepts a legacy command-rule array or a declarative object with schemaVersion: 2. Both reads and saves retain the complete value, including rules, legacy, enabled, disabled, and files. Nested JSON keys use camelCase (textFile, agentRoles, everyCompletedSteps, counterScope, partitionBy); TOML uses snake_case. Send the complete hooks array or object when editing it; omit hooks to leave it unchanged, or send [] to clear it. null and unsupported rule shapes return validation code 40001 without changing the saved file. Reading or saving configuration does not execute hook commands. See Hooks for supported events and actions.
GET /api/sessions/{session_id}/agents/{agent_id}/hooks is a separate read-only inspection view. Active agents return their effective rules, source statuses, diagnostics, and cadence counts. Closed sessions use the saved agent binding and cadence records with the current rule sources, without resuming the agent or executing hooks. Invalid workspace sources remain visible in diagnostics while valid global rules are retained. This endpoint does not save configuration.
Models and providers
| Method and path | Description |
|---|---|
GET /api/models | List configured model aliases, including saved and effective request formats |
POST /api/models | Create a model bound to an existing provider (201) |
GET /api/models/{model_id} | Read a model, its effective settings, and revision |
PATCH /api/models/{model_id} | Change only the supplied model fields |
POST /api/models/{model_id}:set_default | Set the global default model |
GET /api/providers | List providers |
POST /api/providers | Create a provider (201) |
GET /api/providers/{provider_id} | Read a provider (reveals the stored key) |
PATCH /api/providers/{provider_id} | Change only the supplied provider fields |
GET /api/providers and GET /api/providers/{provider_id} include request_compression and responses_web_socket when that provider saved them. A partial compression object, encoding: "off", off: true, and the saved targets order come back as stored. The read does not fill those fields from the system section. POST /api/providers and PATCH /api/providers/{provider_id} accept the same objects. JSON null on PATCH clears that provider field. Omitted fields stay unchanged. | DELETE /api/providers/{provider_id} | Delete a provider (204) | | POST /api/providers/{provider_id}:refresh | Refresh one provider's model metadata | | POST /api/providers:{action} | Collection actions: refresh / refresh_oauth / import_catalog / import_registry | | GET /api/catalog/providers | Browse the models.dev directory (server-proxied) | | GET /api/catalog/providers/{catalog_id} | Read one directory entry |
Directory reads return the last fetched catalog or bundled snapshot immediately while one background update runs. Successful fetches are fresh for ten minutes; a failed background update leaves the local directory readable and postpones another automatic attempt for ten minutes. Without a local catalog, the read waits for the upstream fetch and reports an error if it fails. Explicit import_catalog waits for any required catalog update before validating and saving the selected entry; if upstream is unavailable, it can use the same cached or bundled entry. Directory browsing never fetches model lists from your configured providers; those requests require an explicit refresh action.
Model creation accepts an optional protocol: openai, openai_responses, anthropic, or google-genai. PATCH accepts the same values; omitted protocol leaves the model unchanged, and "protocol": null clears the local override. Include base_revision from the model read to reject concurrent changes without writing. Invalid protocol values return config.invalid (business code 40001) without changing configuration.
Model reads expose the saved protocol and resolved effective_protocol separately. An absent saved value inherits the provider's default format; an absent effective value in the list means no usable requester was materialized. Changing a model's protocol does not write the provider connection, credentials, request identity, or default pointers. See model request format overrides for configuration examples and endpoint compatibility.
The single-model response also separates the stored base_url override from read-only effective_base_url. The latter resolves the model override, provider URL, provider environment and vendor default in that order, and is omitted if none resolves. Create and PATCH accept only stored fields; do not copy effective_base_url into a write.
Executor catalog and checks
The executor catalog lists registered engines without waiting for executable discovery or authentication checks. Reading it does not launch an engine or sign in.
| Method and path | Description |
|---|---|
GET /api/executors | Registered engines and the latest check for each. This request does not probe. A result older than 60 seconds has check_stale: true |
GET /api/executors/{id} | One engine's capabilities, connection settings, and latest check. This request does not probe |
POST /api/executors/{id}/check | Check only that engine's executable sources and its declared authentication hints |
GET /api/executors/installations | Pinned adapter releases and any selected source-owned adapter. This request does not install. An item with no installation omits active |
POST /api/executors/{id}/install | Explicitly update one selected adapter. The request has no body fields |
POST /api/executors/{id}/rollback | Restore that adapter's previous installation. The request has no body fields |
Catalog status is ready, unavailable, or unknown. Native is always ready. An external engine with no check yet is unknown. check_status keeps the latest explicit check as ready, warning, or unavailable, and checked_at is that time. While the launch configuration is unchanged, check_stale is false for 60 seconds and true after that. The list still returns the previous check_status instead of falling back to unknown. After the engine revision changes, both fields disappear and that engine is unchecked again. POST updates only the named engine. The others keep the results they already have. One engine timing out or failing does not mark the rest ready. GET does not start a probe.
Binary readiness and authentication are stored separately. connection.login_status: "unknown" means the login state has not been read. Catalog status: "ready" means the program requirement passed. When preflight is warning and the program requirement passed, catalog status is ready and check_status stays warning. New profile choices and bare execution entries include an external engine only when show_in_profile_list is true. A missing field leaves it on the settings page. false stays off after refresh, another check, and another import. externals_visible = false blocks every external engine from new choices. A bare execution entry also needs a ready program and either a confirmed login or the engine's declared API-key environment. A profile or session already bound to a hidden or unavailable engine keeps that identity. Settings still lists every engine. Sending a prompt still runs the real program, source, and authentication checks. The previous check in the display catalog does not replace the launch check.
POST /api/executors/{id}/install and POST /api/executors/{id}/rollback use the executor id you select. That id is not a fixed value. An id that is not an installable adapter, including a path lookup, returns not found and does not change files.
A source-owned update runs only when that executor's selected source is one node-script whose file name is agy-cli-acp.mjs, and the path is absolute after ~ and ${KIKI_HOME} expand. More than one selected script is not this update.
Install replaces three files beside that script: agy-cli-acp.mjs, agy-hook-registration.mjs, and agy-permission-hook.mjs. Other files there stay, including custom scripts and audit/ records. The call does not rewrite the executor's env, model, permissions, or supports_mcp. supports_mcp keeps its existing value. The status JSON has id, release, active, previous, and phase. It does not include supports_mcp. For this bundle, release.source is bundled:external-harness, release.entry is agy-cli-acp.mjs, and active.version is 0.4.1.
The replaced files are read the next time that executor process starts. This request does not restart a process that is already running.
Rollback restores the previous copy of those three files when one was saved, and leaves custom files in place. A second rollback swaps those two copies again. Install saves a snapshot of the current three files before it replaces them. A failed write puts the original bytes back, deletes a file that was not there before, and the call fails. If the installation record cannot be saved, install restores that earlier copy and the call fails. If the rollback record cannot be saved, rollback restores the active copy and the call fails. When that restore fails too, the response is validation code 40001 with the restore diagnosis, the stored installation record is left as it was, and the call does not report success.
GET /api/executors/installations can also list pinned releases such as claude-acp and codex-acp with phase: idle and no active field. Listing them does not install them.
Local executor sessions
Claude ACP, Codex ACP, and Codex app-server expose a separate local-history catalog. Source IDs are not Kiki session IDs; the GET routes never import them into the Kiki session index.
| Method and path | Description |
|---|---|
GET /api/executors/{id}/local-sessions?limit=100 | Bounded directory; limit 1–200, with resume_enabled and per-item resume.supported / resume.reason |
GET /api/executors/{id}/local-sessions/{local_session_id} | Bounded transcript preview with partial and warnings |
POST /api/executors/{id}/local-sessions/{local_session_id}/resume | Attach the source to a Kiki session, or return its existing attachment |
Continuation is enabled by default; set KIKI_EXPERIMENTAL_LOCAL_SESSION_RESUME=false on the server to disable new attachments. POST requires { "source_home": "…" } copied from the selected summary; optional profile, model, and thinking choose the initial binding. An explicit profile must use the selected executor. The response is { session_id, executor_id, created }; open session_id, and submit an ordinary prompt to resume the vendor session. Duplicate requests, including after a server restart, return created: false without changing the existing binding. For a Codex source already attached through another executor, executor_id identifies that existing executor.
A new attachment uses the local source's non-empty title as its initial Kiki title. If absent, it uses last_prompt with whitespace normalized and truncated to 80 Unicode characters. If both are absent, the session remains untitled. Existing attachments, including user-renamed titles, are not changed by subsequent resume requests.
Attachment does not copy the vendor transcript or send a prompt. The first prompt performs ACP resume/load or Codex thread/resume; unsupported engines, unavailable references, changed binding fingerprints, and changed source homes fail instead of silently creating a fresh vendor conversation. resume.supported reflects the known adapter capability and the latest runtime observation, not an authenticated connection check. See the klient contract for SDK methods and errors.
Sessions
| Method and path | Description |
|---|---|
POST /api/sessions | Create a session; omit both workspace_id and metadata.cwd to allocate a new workspace |
GET /api/sessions | List sessions; cursor pagination with busy, archived_only and title/workspace search q |
GET /api/sessions/{session_id} | Read one session |
GET /api/sessions/{session_id}/profile | Read the session profile |
POST /api/sessions/{session_id}/profile | Update title, metadata, agent config |
POST /api/sessions/{session_id}:{action} | Session actions: fork / compact / undo / abort / btw / archive / restore |
POST /api/sessions/{session_id}:delete-archived | Permanently delete an archived conversation and its attached archived family |
POST /api/sessions:delete-archived | Permanently delete all archived conversations in the connected home |
GET /api/sessions/{session_id}/children | List child sessions |
POST /api/sessions/{session_id}/children | Create a child session (fork with a tag) |
GET /api/sessions/{session_id}/status | Realtime status rollup |
GET /api/sessions/{session_id}/goal | Current goal snapshot (null when none) |
GET /api/sessions/{session_id}/warnings | Session-level warnings |
POST /api/sessions/{session_id}/export | Export the session with diagnostics (zip stream, not enveloped) |
GET /api/sessions/{session_id}/snapshot | Full snapshot for client rebuilds (with as_of_seq and epoch) |
When the session is not live in this process, the snapshot is read from the saved main-agent wire and session metadata. The request does not resume the session or its workspace. messages is the newest 100 entries of the current logical context after compaction, undo, and edit, oldest first. has_more is true when older logical entries exist, and message_count is that logical length. as_of_seq and epoch are the session journal watermark. Permission mode and plan mode come from the saved runtime records; model and effort come from the saved agent binding. Context-token totals and breakdowns are omitted when this process has not measured them. in_flight_turn, pending_approvals, and pending_questions describe only activity in this process, so a session that is active in another process is not reported as active here.
Session reads and event.session.work_changed expose executing separately from busy. busy includes unfinished work, queued prompts and terminal task results awaiting delivery. executing reports current local execution or finite process/media work; it excludes those queues, undelivered terminal results, resident services and agent task rows without an active executor. A cold session reports executing: false for this process. Older servers omit the field; omission means unknown. A historical subagent row with status: "running" is not current execution evidence.
Snapshot subagents roster rows carry an optional created_at UTC ISO timestamp for the agent's immutable creation time. A newly created agent has it before its first run starts, including while queued; resuming the same agent, restoring its scope, restarting the server, or switching its model does not change it. A fork that creates a new agent records a new creation time. The separate optional started_at identifies the run described by the row and may change on a later run.
Model, effort and executor fields prefer the current committed agent metadata over its spawn snapshot, with spawn values used only when current values are unavailable. Effort provenance and executor protocol stay with their selected source; a current binding alone does not prove which model served a request.
An agent whose saved metadata has no creation time omits created_at; treat it as unknown, not as the session creation time, run start, attachment time, or a value inferred from its ID. Creation timestamps use the host's wall clock, not a dispatch or turn identity: equal timestamps, clock changes, or missing comparison boundaries do not establish which dispatch created an agent.
For automatic allocation, send {} to POST /api/sessions. The server creates a distinct directory under $KIKI_HOME/workspaces/, registers it, and returns its workspace_id and metadata.cwd in the session response. Supplying an existing workspace_id or metadata.cwd keeps the existing targeting behavior; an unknown workspace_id is still rejected.
For retry-safe creation, generate a UUID once and send it as the optional top-level creation_id. Keep that ID and the same request body when retrying after a timeout or disconnect. Concurrent matching requests share the creation; completed requests replay the saved result, including after a server restart. Saved failures are also replayed without repeating creation side effects. The resulting session ID is session_<creation_id>, with the UUID normalized to lowercase.
Creation identities belong to the connected home: clients must keep separate pending requests for each home and connection. Omitting creation_id retains ordinary creation without retry deduplication. Cancelling a client wait does not delete a session the server has created.
| Creation error code | Recovery |
|---|---|
40948 | The creation identity conflicts. details.reason is body_mismatch when the same creation_id carries a different body, or session_id_exists when its mapped session ID is already occupied outside this creation. Keep the pending identity and draft. A collision is not a successful replay; use a new ID only for a deliberately separate creation. |
50006 | The creation outcome is unconfirmed. details contains { creation_id, session_id, evidence }. Diagnostic values include session_present_without_completion, session_absent, completion_receipt_write_failed, session_read_error, and receipt_read_error; clients must also preserve unknown diagnostic values. Keep the draft and creation identity, and inspect the exact session ID. This code never confirms that starting a replacement is safe; the server does not rerun creation or its hooks. |
Set agent_config.execution at creation to select a harness independently of a profile:
{ "agent_config": { "execution": { "executor": "claude-acp" } } }The selection is { executor, profile?, profile_file?, overrides? }. Choose either a registered profile name or a profile_file Markdown path; they are mutually exclusive, and the selected profile must use the same executor. Explicit files do not require main: true and are not registered or copied into a profile directory. Omit both fields for direct external execution. overrides accepts model, thinking, permission_mode, kiki_context, allow_kiki_subagents, native_agent and inject_kiki_mcp. With the same executor and profile name or file path, omitted overrides keep the session's saved overrides; null removes an override and restores the lower-priority defaults, while [] and false explicitly disable their respective capabilities. For native_agent, engine-default keeps the vendor's default agent and does not send a custom selection. A newly chosen value must already be advertised. Missing discovery keeps an existing choice when the executor, that agent, and the descriptor revision still match; a discovery list that omits the value is rejected and the current binding stays. inject_kiki_mcp: true opens the bridge without granting tools the binding already denies; false closes it. Legacy top-level model/profile/thinking requests remain supported.
Before choosing a file, POST /api/agent-profiles/file-preview accepts { path, cwd?, workspace_id? } and returns { profile } with the usual named-profile metadata, including its canonical source_file and executor. Supply at most one of cwd and workspace_id; a relative path requires one, while an absolute or ~/ path can be previewed without either. Paths are read on the connected server, not the client machine. Missing or invalid files return 40001 with path diagnostics, without changing a binding. The typed HTTP client exposes this as klient.rest.agents.previewFile(body, options?). Use the returned source_file as execution.profile_file at session creation or with the next prompt.
Session reads return the committed agent_config.execution binding as { version: 1, selection, effective, sources, generation }. Sources are session, profile, harness-settings or harness-default; an absent effective model or effort means Kiki has not selected one, not that the harness has none. A reported vendor model is an observation, not a new override. Idle updates can also use POST /api/sessions/{session_id}/profile with agent_config.execution; while running, submit the selection with the next prompt instead.
On an idle agent, thinking: null in agent_config clears only the session thinking override, so the lower-priority effort applies again: the profile, then the harness setting, then the engine default when Kiki has not pinned an effort. Leaving model out keeps the saved model. An empty thinking string is rejected; omitting thinking keeps the current effort. While the agent is running, that profile update is refused. A prompt can carry the same thinking: null; Kiki applies it when that prompt launches, and an omitted model stays in place.
POST /api/sessions/{session_id}:archive
An empty body keeps the single-session behavior. Send { "include_attached": true, "exclude_session_ids": ["independent-thread-id"] } to archive the attached family. The server reads persisted metadata across all workspaces, not the client's loaded page. created_by_session_id records a created thread; parent_session_id counts only with child_session_kind: "child", so ordinary forks are not included. Each excluded thread cuts its whole subtree; excluding the requested root does not exclude that root.
Family requests return data: { archived, outcomes }. archived: true confirms all target sessions; each outcome is { id, ok: true } or { id, ok: false, reason, message }. Partial failure returns archived: false even when the envelope code is 0; keep the successful items and retry the unfinished ones. Discovery failure returns an error before any archive mutation. Refresh the list after either result. Older servers without outcomes cannot confirm a family archive. Archive does not delete history or worktrees; :restore restores only the requested session. The existing batch archive endpoint still acts only on its supplied IDs.
Archived conversation management
Use GET /api/sessions?archived_only=true with the existing page_size and before_id cursor parameters to read archives. q accepts up to 500 characters and matches a case-insensitive substring of the title, workspace path or workspace ID, across pages; it does not search message contents. Reading an archived conversation's messages does not restore it.
POST /api/sessions/{session_id}:delete-archived accepts {} or { "exclude_session_ids": ["independent-thread-id"] }. It discovers the same attached family as session archive, excludes whole specified subtrees, and deletes only archived members. The requested root must still be archived; restoring a target before its deletion keeps it. POST /api/sessions:delete-archived accepts {} and discovers all archived conversations in the connected home before deleting; search, workspace filters and the loaded page do not narrow its scope. Independently displayed archived threads are included in this all-archives operation.
Both calls permanently remove saved conversation data and return data: { deleted_ids, failed }. Each failed item has { id, title?, message }; an envelope code of 0 is not complete success when failed is nonempty. Refresh the archive list after success, partial failure or a request error, and use the fresh state before retrying. Discovery failure occurs before deletion. Clients must obtain confirmation before sending either request; cancelling that confirmation must not send a delete request.
POST /api/sessions/{session_id}:compact
The body is optional. It accepts instruction (what to preserve) and strategy, which is summarize or relay — the two renewal strategies the server implements. A session that was idle when the request arrives starts compressing immediately; one with a model call or tool result still landing in its history is queued, and Kiki processes it at the next step boundary once that work has finished, without waiting for the whole turn.
{ "accepted": true, "status": "queued", "source": "manual" }accepted: true means the request was taken, not that the history was compressed. status is queued or running at that moment; the compression is done when a terminal compaction.* event arrives, not when this response does. Asking again while a manual compaction is queued or running returns accepted: false and does not start a second one — send the request again only after a terminal event, and then only if the first attempt failed.
Accepting the request does not promise the compression succeeds. A run that reaches a point with no safe earlier history to compact against ends in compaction.cancelled with a reason, rather than quietly leaving the history alone, and a queued manual request is not cleared by a later automatic compaction finishing. An older server may return an empty data; treat every field in the example above as optional when it is absent.
Messages and transcript
| Method and path | Description |
|---|---|
GET /api/sessions/{session_id}/messages | Page messages (before_id / after_id / role) |
GET /api/sessions/{session_id}/messages/{message_id} | Read one message |
POST /api/sessions/{session_id}/messages/{message_id}:edit | Replace a user message and rerun from it |
POST /api/sessions/{session_id}/messages/{message_id}:regenerate | Rerun the user turn behind the latest final assistant message |
GET /api/sessions/{session_id}/transcript | Turn-paged transcript (requires agent_id and transcript_coverage_version=2); global state rides along unpaginated |
GET /api/sessions/{session_id}/transcript/ops | Op-batch catch-up (requires agent_id, since_seq, and transcript_coverage_version=2); complete: false means a full refresh is needed |
GET /api/sessions/{session_id}/transcript/user-messages | Turn-opening user inputs, unpaginated |
GET /api/sessions/{session_id}/transcript/plan | ExitPlanMode plan content, path, and review outcome |
GET /api/sessions/{session_id}/transcript/detail | One canonical entity (agent_id, kind, id) |
POST /api/klient/session-view/{session_id}/transcript/content | Continue one bounded field from a contentRefs entry |
Editing and regenerating require an idle session and expected_cursor: { seq, epoch } from the current session view. An edit replaces the complete content array, including attachments; regeneration reuses the original user input behind the selected final assistant response. Preflight rejection leaves conversation history unchanged. An accepted rewrite retains user_message_id and receives a new prompt_id.
For response-loss recovery, send an optional operation_id and keep that ID and the entire request body unchanged when retrying the same action. A matching accepted retry returns the original receipt without another run, even after an uploaded source file has expired. Use a new ID for a different body or a new action. Reusing an ID with different input returns 40938; a stale cursor returns 40937, so refresh the session view before starting a new action. If the response is uncertain, retry manually rather than automatically submitting another action.
Both transcript-page and catch-up requests must send transcript_coverage_version=2. Successful responses echo data.transcript_coverage_version as the number 2; a missing or unsupported request version returns envelope code 40001 with an upgrade message, without returning a transcript. When a newer client reads a server that does not confirm coverage, it treats the history as unverified rather than complete.
When an older-page request is still locating its anchor in saved history, it can return empty items, has_more: true, and read.readiness: "preparing", with read.source set to cold or derived. Continue with the returned next_cursor as before_item, even when the cursor is unchanged. Neither an empty page nor a repeated cursor means the beginning has been reached. Once a page returns content, continue from its new cursor; that content alone does not establish full coverage, so retain the response's coverage and read status.
GET /api/sessions/{session_id}/transcript/detail reads one entity. Query agent_id is a plain agent id, kind is task, attachment, prompt, or tool, and id is that entity's id. For kind=tool, lookup.status is found, preparing, or not_found. found includes turnId, stepId, and frame. That frame, and a returned task, attachment, or prompt body, can carry contentRefs; continue those with the POST below. preparing means the call is not yet located in the session's source history: retry the same request. read.source is then derived and read.readiness is preparing. not_found means the located history has no such call.
A session already covered in memory returns the live tool call directly, with read.source live and readiness ready. A located cold session reports cold and ready, and replays the body from that source history.
A transcript page keeps large fields as contentRefs. Each reference carries source, revision, path, kind (text, array, or object), offset, and total. Continue it with POST /api/klient/session-view/{session_id}/transcript/content and JSON { "agentId", "ref" }. Send the reference unchanged. The HTTP status stays 200 and the envelope carries the result. code: 0 returns value and, when more of the field remains, next. Submit that next unchanged as the next ref, until next is absent. The same ref again returns the same part; "range": true does not advance offset. On a text ref it only limits the slice that already starts at offset (about 4096 characters, inside the field budget), and a snapshot ref ignores range. A page is budgeted at 64 KiB, and each field slice aims at half of that, 32 KiB.
While the source history is still being located, the same call returns code: 40923 and msg: "history_canonical_preparing"; retry that body. When the field no longer matches the reference, it returns code: 40922 and msg: "Content changed; reload its preview before continuing." Reload the transcript preview and continue from the new reference. An unknown reference returns code: 40401 and msg: "content unavailable". Other routes also use 40922 and 40923; match this msg, not the number alone. An invalid body returns code: 40001 and msg: "invalid content reference".
The transcript response's agents descriptors use camelCase: { agentId, type?, parentAgentId?, delegator?, label?, createdAt?, disposedAt? }. Their optional createdAt carries the same immutable UTC ISO creation time and unknown-time rules as snapshot subagents[].created_at, for both live and closed sessions. It is not the transcript turn's startedAt or the snapshot run's started_at.
Prompts
| Method and path | Description |
|---|---|
GET /api/sessions/{session_id}/prompts | Active and queued prompts |
POST /api/sessions/{session_id}/prompts | Submit a prompt (content-part array, optional model / permission-mode overrides) |
POST /api/sessions/{session_id}/prompts:steer | Send selected queued prompts now |
POST /api/sessions/{session_id}/prompts/{prompt_id}:abort | Abort one queued, launching, running, or steered prompt; ?queued_only=true removes only a not-yet-started queue item |
POST /api/sessions/{session_id}/prompts/{prompt_id}:steer | Send one queued prompt now |
GET /api/sessions/{session_id}/prompts/{prompt_id}?admission=durable | Read a saved request, including its complete content, without restoring the agent |
POST /api/sessions/{session_id}/prompts/{prompt_id}:resume?admission=durable | Retry a held or failed saved request |
A saved session opts in with admission: "durable" and a stable prompt_id of 1–256 characters. Saving while that session is still restoring is one use; the gate is the saved session, not an in-progress restore. Any other admission value is rejected before a body is stored. A session that does not exist, and a temporary session that is live but not yet saved, are rejected before storage and do not produce an acceptance receipt.
The server then saves the complete request and uploaded attachments and returns status: "queued", admission: "restoring". That pair means the message is persisted and waiting for recovery, not that the agent has accepted it. A successful HTTP envelope confirms only the save.
Invalid request shapes, empty messages, missing targets and unavailable uploads fail before that receipt. Model, profile and execution checks run during recovery. Omitting admission keeps the synchronous launch or block response and does not write this durable copy. ?receipt=true returns a compact receipt without echoing the full content.
Read GET /api/sessions/{session_id}/prompts?admission=durable to receive ingress items. Follow ingress_next_cursor as cursor until absent, even if a page has no items for the selected agent. Pages scan at most 64 saved requests and return a text preview of at most 1024 characters with admission_content_truncated: true. A saved request with no text previews as [Attachment]. Use the point-read route for the original. Both reads leave cold agents unloaded. For a child agent, include the same agent_id in the submission body and every query or action.
admission: "admitted" means the agent accepted the request; status reports its saved receipt state, not its current queue position or model consumption. For immediate delivery, optional delivery_state: "steered" means the core assigned the message to the target turn; "queued" means it was awaiting delivery when that receipt was recorded, including a pending model-switch boundary. Neither assignment nor admission confirms insertion into a model request; use the delivered transcript frame and subsequent request for that. A newer canonical started, steered or terminal prompt takes precedence over an older queued receipt.
Optional delivery_reason explains the immediate-delivery decision on submit receipts and durable reads. A single-ID :steer response also carries delivery_state and delivery_reason; steered: true means the prompt joined the turn, while steered: false means it stayed queued or had already left the queue. A known prompt that is no longer selectable returns prompt_no_longer_pending rather than a 404; an unknown ID still returns 40402. Multi-ID steer acknowledgements omit the single-prompt fields. With :abort?queued_only=true, aborted: true confirms an unstarted prompt was removed and hidden from the queue and timeline; aborted: false means it was not removed, so refresh its current state. A started turn and its history remain intact. Ordinary :abort still stops a running prompt.
delivery_reason | Meaning |
|---|---|
target_changed | The active turn or prompt changed before delivery; the message was left queued. |
steer_rejected | The core refused insertion into the active turn, such as for incompatible execution controls. |
executor_deferred | The external executor did not deliver this message into its active run; it remains queued for a later turn. This does not distinguish an unsupported operation from a declined one. |
model_switch_boundary | Delivery is waiting for the selected model-switch boundary. |
no_active_turn | No active turn was available; the selected message follows the new-turn path. |
external_retry | A known external submission was replayed without repeating its immediate-delivery action. |
prompt_no_longer_pending | The prompt was no longer selectable in the pending queue. This alone proves neither delivery nor that it is still queued; read its canonical state. |
failed includes admission_error and keeps the original content and attachments. Restarted or uncertain submissions become held until an explicit :resume; the server checks the original receipt before retrying, and a failed receipt lookup does not authorize another launch. Use :abort to cancel. aborted: false with admission held means cancellation still needs reconciliation; cancelled confirms it. To edit a saved request, cancel it and submit a new ID.
If an external executor's immediate-delivery result is unknown, its pending queue stays under recovery hold even after the active turn ends. :resume keeps the request held with an explanatory error instead of repeating that external action. Check the executor's result before explicitly sending or cancelling the retained prompt.
To retry a submission whose response was lost, keep its optional prompt_id and complete request body unchanged. Matching retries return the original accepted receipt without resubmitting the prompt, including main-agent, attachment, and bundled-skill submissions. Changed input needs a new prompt_id; reusing an accepted ID for different input returns 40938. If an older accepted request has no replayable receipt, inspect its queue or history rather than automatically sending it again.
A prompt can carry the same execution selection in its top-level execution field. It is captured with that prompt and applied when the prompt launches, not when it enters the queue. The current turn keeps its committed binding. A changed execution generation starts a fresh remote session without resuming or forking the old one or sending a handoff of the old Kiki conversation; visible Kiki history is retained. Cold recovery reuses the committed generation and its own saved remote reference.
The top-level profile, model and thinking selections are also captured with the prompt. Add model_switch_mode: "direct" | "compact" | "fresh" with model to carry a confirmed context choice on that same message: keep the current context, summarize it, or rebuild a fresh context window. This choice cannot also change profile or execution. Prompt list items expose saved settings in optional runtime_controls, including camelCase modelSwitchMode. They survive queue recovery and do not create a separate control-only queue item. A saved selection or accepted receipt is not proof of an actual request on the selected model.
To send a new message now, include delivery_mode: "steer" in the submission; omit it for ordinary sending. With durable admission, the server saves this intent and continues delivery after the restoring response, even if the client disconnects. It targets the turn active when the request arrived. If that turn ends and another turn starts before admission, the message stays queued for its own turn rather than joining the replacement. If no turn is active, it follows the existing new-turn path. Profile, execution or runtime-control changes that require a separate turn remain queued.
Sending now adds ordinary selected prompts to the active turn at a safe step boundary. A native prompt carrying a model, effort or context choice applies it after the current model response and tool results have landed, then consumes the same message once. Cancellation or summary failure before commit leaves the binding and context unchanged and does not send the message on the old model. Unsupported external context rebuilds fail instead of silently becoming direct switches. A main agent's foreground child wait can move into the background; the child is not cancelled.
Use after_model_switch only for a message intentionally dependent on an existing explicit switch operation. Do not combine it with a new model, effort, profile, execution or context choice: the server rejects mixed selections instead of silently replacing them with the old dependency's binding. Standalone manual compaction reports its own compaction.started, compaction.completed or compaction.cancelled progress; it neither accepts nor executes a pending message's model choice.
For an explicit switch operation, an accepted send is not a completed switch. If switching fails or its durable completion is uncertain, the original message remains queued and the switch exposes its recoverable state. Retry the same operation to finish the accepted delivery; a cancelled switch is not revived. Prompts with an unrelated execution selection still require their own turn. After a restart, sending now bypasses the recovery hold only for the selected prompts; other restored prompts still wait for confirmation. Aborting a steered prompt cancels the turn it joined, not a later turn. Ordinary :abort returns 40402 for an unknown or already settled prompt; use ?queued_only=true when removing a queue row.
Approvals and questions
| Method and path | Description |
|---|---|
GET /api/sessions/{session_id}/approvals | List approval requests (filter with status=pending) |
POST /api/sessions/{session_id}/approvals/{approval_id} | Resolve an approval |
GET /api/sessions/{session_id}/questions | List questions |
POST /api/sessions/{session_id}/questions/{question_id} | Answer a question |
POST /api/sessions/{session_id}/questions/{question_id}:dismiss | Dismiss a question |
Background tasks
| Method and path | Description |
|---|---|
GET /api/sessions/{session_id}/tasks | List background tasks |
GET /api/sessions/{session_id}/tasks/{task_id} | Read a task (optional output preview) |
POST /api/sessions/{session_id}/tasks/{task_id}:cancel | Cancel a task |
GET /api/cron | List scheduled tasks across workspaces |
POST /api/cron | Create a scheduled prompt for session_id |
GET /api/cron/{task_id} | Read the complete scheduled prompt |
PATCH /api/cron/{task_id} | Edit a schedule or rebind within its workspace |
POST /api/cron/{task_id}:pause / :resume / :run | Pause, resume, or trigger a schedule |
DELETE /api/cron/{task_id} | Delete a schedule |
Task and cron lists accept page_size (1–100, default 100) and offset (default 0). Responses include items, has_more, and next_offset when more results remain. The task list also accepts status; the cron list accepts session_id. Offset pages may shift if tasks change between requests.
Cron create accepts session_id, cron, prompt, and optional recurring, paused, and delivery_mode. Update accepts any nonempty subset except paused; use the pause/resume action for that field. delivery_mode is idle (default, wait for current work then run ahead of ordinary messages), queue (normal message order), or steer (next safe step without cancelling the current request). Repeated idle fires of one waiting job merge with a total trigger count. Task responses include the effective delivery_mode; omitted modes on older tasks default to idle on their next fire, without changing already queued deliveries. See Scheduled tasks for timing and recovery semantics.
Skills, tools, and MCP
| Method and path | Description |
|---|---|
GET /api/sessions/{session_id}/skills | Per-session skill catalog |
GET /api/workspaces/{workspace_id}/skills | Session-less skill catalog for a workspace |
POST /api/sessions/{session_id}/skills/{skill_name}:activate | Activate a skill (starts a turn) |
GET /api/tools | List tools of the effective agent |
GET /api/mcp/runtime/servers | List live MCP connection status for the effective agent |
POST /api/mcp/runtime/servers/{mcp_server_id}:restart | Restart a live MCP server |
GET /api/mcp/servers | List MCP configuration entries, including plugin and project layers |
POST /api/mcp/servers | Add a user-level MCP configuration entry |
GET /api/mcp/servers/{name} | Read one MCP configuration entry |
PUT /api/mcp/servers/{name} | Replace a user-level MCP configuration entry |
DELETE /api/mcp/servers/{name} | Remove a user-level MCP configuration entry |
POST /api/mcp/servers:test | Test an MCP configuration without persisting it |
POST /api/mcp/servers:inspect | Inspect MCP locators and OAuth candidates |
GET /api/mcp/auth-statuses | Read MCP OAuth status for the registry |
POST /api/mcp/auth:begin / :complete / :cancel / :reset | Manage an MCP OAuth flow |
Skill activation accepts an optional string user_input in the request body alongside args and attachments. For a user slash command, send the complete original message, including whitespace and line breaks. The server saves it as userInput in the activation origin, separately from the expanded skill instructions; the GUI timeline shows the original message and loaded skill document as separate blocks without submitting a second message.
Older clients can omit user_input; the server then derives slash text from the skill name and args. Existing history without userInput keeps its previous skill-document display.
To recover a lost activation response, choose an optional prompt_id before the first request and keep the complete skill name, args, user_input, and attachments unchanged on a manual retry. A matching accepted request returns the original { activated: true, skill_name } response without activating the skill again or rereading attachment uploads. Changing the payload under that ID returns 40938; a new activation needs a new ID. Omitting prompt_id preserves the unkeyed activation behavior and cannot identify a lost response for safe replay.
SSH hosts
These endpoints manage SSH hosts available to sessions, not a directory of remote Kiki installations. Host reads accept an optional workspace_id to select workspace overrides.
| Method and path | Description |
|---|---|
GET /api/ssh/config-sync | Read the effective SSH-config sync preference and its source |
PUT /api/ssh/config-sync | Save { "enabled": true } or { "enabled": false } and read it back |
GET /api/ssh/hosts/{id}:host-keys | Read local public host-key records for the saved host's resolved hostname and port |
Sync reads and saves return { enabled, source }: home is this Kiki home's saved value, base is an inherited value, and default is the unconfigured default (true). This preference is independent of the host count. A failed read is an error, not an enabled default. Klient exposes rest.ssh.configSync() and rest.ssh.setConfigSync(enabled).
rest.ssh.hostKeys(id, workspaceId?) returns the resolved hostname, port, and matching label, plus state, records, and source files. recorded means a local identity record exists; it does not mean the remote key has just been verified. unrecorded means no matching record was found in readable or missing files. unavailable means the read is incomplete or a matching record cannot be interpreted. Records include the SHA256 fingerprint when readable, algorithm, file and line, host pattern, and status (recorded, revoked, unsupported, or invalid). Unsupported markers such as @cert-authority, invalid public keys, and file errors carry a reason.
Kiki resolves UserKnownHostsFile boundaries from OpenSSH's configuration and path expansion, including multiple files and quoted paths containing spaces. If the boundaries cannot be resolved unambiguously, the request fails rather than guessing. UserKnownHostsFile none returns an empty files list and does not write a default file; unknown keys still require approval on each connection. This API neither connects to the host nor changes trust records, and cannot report a remote key as unchanged or changed. First-key approval and changed-key rejection still occur through the existing connection flow.
Browser connections
These endpoints manage saved browser connections, not the sessions that use them. Reading and saving never starts a browser or visits a site. See Browser control for the settings page.
| Method and path | Description |
|---|---|
GET /api/browser/connections | List saved connections and the new-session default; each row carries its current runtime status |
PUT /api/browser/connections/{id} | Create or replace one connection and read it back |
DELETE /api/browser/connections/{id} | Remove one connection |
PUT /api/browser/default | Save { "browser": "<id>" }, or omit the field to clear the default |
GET /api/browser/connections/{id}:status | Read runtime status from memory and configuration only |
GET /api/browser/connections/{id}:tabs | List the connected browser's target tabs; it never attaches or launches |
GET /api/browser/connections/{id}:catalog | List the execution backend's tools (add ?includeSchema=true for input schemas); it may start that backend's MCP process but never launches Chromium |
POST /api/browser/connections/{id}:check | Run the check that fits the connection style |
POST /api/browser/connections/{id}:connect | :disconnect | Start or attach the connection, or release it |
PUT takes the same discriminated body as the GUI. A body whose type is agent-browser-profile needs name and accepts profilePath, executablePath, headed and driverPath; one whose type is agent-browser-cdp needs name and endpoint — either { "action": "set", "value": "<CDP http/ws URL>" } or { "action": "keep" } to leave the stored address unchanged. Both accept enabled, which defaults to true. A new CDP connection without set is rejected. A stored CDP address reads back masked; revealing it is an explicit POST /api/secrets:reveal whose ref has kind: "browser_endpoint". A body whose type is ego-lite needs name and accepts optional cliPath, profileId, and serverName. It has no endpoint.
A kimi-webbridge body needs name, accepts enabled and optional driverPath, and rejects endpoint. It uses the Kimi HTTP daemon at http://127.0.0.1:10086 on the server; it does not start the CDP driver. A running extension does not register a Kiki connection: if BrowserConnections or the REST API reports browser.not_found for this id, save a kimi-webbridge connection in Browser control or config.toml, then check it again. Legacy saved CDP entries whose id is exactly kimi-webbridge use this HTTP route by default without rewriting the saved type; see webbridge_preset_route. Other CDP ids keep their CDP route.
A webbridge-command body needs name, accepts enabled (default true), and rejects both endpoint and driverPath. It sends extension commands to the fixed local /command endpoint without starting agent-browser. Its catalog reports contextIsolation: "extension-command-session". Each caller has a separate command session; tab identities are wb:<decimal id>, not DevTools target IDs. Page actions, tab selection and close apply only to the tab that caller's last navigate opened. Kiki does not find a different tab by URL, including when two tabs share a URL. Window and frame methods are unavailable. Disconnect releases Kiki's binding without closing tabs; the explicit agent_browser_end_session page operation closes that caller's command session.
The existing kimi-webbridge HTTP bridge's catalog reports contextIsolation: "main-frame-only": window creation and non-main frames are unsupported. Tab selection uses the exact id returned by the tab list and verifies the selected id; switching to another tab is rejected when duplicate URLs prevent an unambiguous selection. A saved connection whose local HTTP status is unavailable, whose daemon reports stopped, or whose extension reports disconnected returns state: "disconnected" with failure.code: "browser.disconnected" and a cause-specific message. A later healthy :check clears this failure; if the connection is no longer attached, it returns idle, so connect before listing tabs. When a command was sent but its response is lost or unreadable, status is unconfirmed with failure.reason: "outcome_unknown". The command is not replayed. :check only checks bridge health; if it returns idle after an unconfirmed command, reconnect, select the tab, and observe it before deciding the next action. An explicit negative acknowledgement is a finished failed result instead.
Execution actions fail closed: without the native_browser experimental flag :connect returns envelope code 40001 with details.code: "browser.disabled" and details.reason: "feature_disabled", while a switched-off connection returns details.reason: "connection_disabled". Klient exposes the same surface as rest.browser.list(), upsert(id, input), remove(id), setDefault(browser?), status(id), tabs(id), catalog(id, { includeSchema }), check(id), connect(id), and disconnect(id).
Browser setup presets
Presets are kimi-webbridge, independent-browser, codex-browser, and ego-lite. kimi-webbridge and independent-browser manage local components, while codex-browser lists matching endpoint names as unverified candidates (candidates, up to 20 entries) and saves an explicitly selected pipe. The name prefix is shared with desktop app tools, so discovery does not verify an extension connection. An empty list alone does not establish that the extension is disabled. Kiki does not install the external app. kimi-webbridge connects and opens one fixed local bridge and does not take a candidate list. After the extension probe succeeds, it creates an endpoint-free webbridge-command connection if that id is free and returns connectionId: "webbridge-command" plus connection runtime state. If another type already occupies the id, it returns reason: "connection_conflict" without replacing that row or any existing kimi-webbridge connection. Codex pipes and independent-browser profiles are separate connection styles; they do not create extra Kimi WebBridge instances.
ego-lite uses controlSurface: "external-app" and sourceUrl https://lite.ego.app/download?auto=1. Status reads share one background detection. While it runs, GET returns state: "preparing", reason: "ego_detecting", and a detect step in running; the other presets remain available. Poll again to read the outcome. Discovery and any automatic CLI check share a five-second deadline; a timeout returns failed with reason: "ego_detection_failed" and its error. Completed results stay fresh for 30 seconds from completion. :cancel stops the active detection, and disabling the source stops it without changing browser permissions.
An existing saved Ego connection or a recognized Citro installation can be checked automatically with nodejs; an unrelated executable found only on PATH requires an explicit check. ready is reported only after a check prints kiki-ego-ready. Other reasons are ego_installed, ego_onboarding, and ego_choose_cli. :prepare rejects this preset. :connect runs the check and, when it succeeds and the id is absent, saves connection id ego-lite without changing the allowed set or the default browser.
| Method and path | Description |
|---|---|
GET /api/browser/setup | List presets with their step states and available actions |
GET /api/browser/setup/{preset} | Read a preset's status |
POST /api/browser/setup/{preset}:prepare | Install components with { "consent": true } |
POST /api/browser/setup/{preset}:connect | Connect a prepared preset or save a selected candidate pipe |
POST /api/browser/setup/{preset}:open | Open or activate the independent browser; for WebBridge, connect the bridge |
POST /api/browser/setup/{preset}:update | Update components from verified downloads; retain the previous installation on failure |
POST /api/browser/setup/{preset}:shortcut | Create an independent-browser shortcut on the Windows server's desktop |
POST /api/browser/setup/{preset}:cancel | Cancel preparation |
:connect accepts optional connectionId (stable connection ID), browserId (the selected pipe identifier, e.g. codex-browser-use-...), name, and setDefault; :open accepts optional connectionId; :shortcut accepts optional connectionId and shortcut name. :update takes an empty object. All actions return a setup status: inspect state, reason, error, steps, actions, candidates, and saved browserId, not just HTTP success. Saving a candidate returns state: "needs_user_action" with reason: "approval_unproven": the target is configured, but its browser handshake has not been verified. Saving does not open the pipe or consume a permission request. The preset becomes connected only after the browser control service confirms the handshake; connection failures retain their error. Real permission requests still go to the current permission consumer. A disabled connection returns connection_disabled without opening another profile. A failed window activation returns state: "failed".
Updates replace files on disk without switching an already-running browser or daemon. reason: "disk_updated" reports the disk change; "disk_updated_running_unchanged" also identifies an unchanged running WebBridge daemon. Neither means that a running process has upgraded.
Klient exposes these operations as rest.browser.setupPresets(), setupStatus(preset), prepare(preset, { consent }), connectPreset(preset, input?), openPreset(preset, input?), updatePreset(preset, input?), createShortcut(preset, input?), and cancelSetup(preset).
Terminals
PTY terminal endpoints; mounted only on loopback binds.
| Method and path | Description |
|---|---|
GET /api/sessions/{session_id}/terminals | List terminals |
POST /api/sessions/{session_id}/terminals | Create a terminal |
GET /api/sessions/{session_id}/terminals/{terminal_id} | Read a terminal (including scrollback) |
POST /api/sessions/{session_id}/terminals/{terminal_id}:close | Close a terminal |
Workspaces
Use the catalog view for workspace selection and metadata changes; request one workspace's details when you need its session count or Git state.
| Method and path | Description |
|---|---|
GET /api/workspaces?view=catalog | List persisted registration metadata without session counting, Git checks or legacy discovery |
GET /api/workspaces | List full workspace records, waiting for discovery and every workspace's session count and Git check |
GET /api/workspaces/{workspace_id} | Read one registered workspace with its session count and Git state, without global discovery |
POST /api/workspaces | Register a workspace (idempotent on the root path); return the full record |
PATCH /api/workspaces/{workspace_id}?view=catalog | Rename and/or pin; return only the saved metadata |
PATCH /api/workspaces/{workspace_id} | Rename and/or pin; return the full record, including session count and Git state |
DELETE /api/workspaces/{workspace_id} | Unregister (keeps on-disk content) |
GET /api/workspaces/{workspace_id}/trust | Read the trust state without starting a workspace runtime |
POST /api/workspaces/{workspace_id}/trust | Grant trust |
POST /api/workspaces/{workspace_id}/untrust | Revoke trust |
Both list variants return { items }. Catalog entries contain only id, root, name, created_at, last_opened_at and pinned; absent session_count and isGit mean unmeasured, not zero and false. The catalog reads already persisted registrations; use the default full list when you explicitly need legacy workspace discovery. Keep these response shapes in separate client caches.
Both PATCH variants accept { name?, pinned? }, with at least one field required. The catalog variant does not wait for discovery, counts or Git before returning the saved entry. Single-workspace details count sessions, including archived sessions, under that root's registered IDs and legacy directory aliases, and check Git only for that root. These measured details can still take time or fail; load them independently of workspace selection and metadata editing. Unknown registered IDs on the detail or catalog PATCH routes return envelope code 40410.
Memory
{scope} is one of global, workspace, persona or persona_workspace. The three non-global scopes name their owner in the query string: workspace_id for a workspace, persona_id for a persona, and both for a persona's per-workspace notes. An unknown workspace_id is rejected.
| Method and path | Description |
|---|---|
GET /api/memory/settings | Read the memory section, with effective_enabled, optional advisor and advisor_status; optional workspace_id resolves workspace consent |
PATCH /api/memory/settings | Change enabled, approval, budget or sparse advisor fields; accepts the same optional workspace_id |
POST /api/memory/advisor/probe | Test the saved selector using synthetic content; body: { confirm_destination } |
GET /api/memory/advisor/status | Read a live agent's status with session_id and optional agent_id (default main); returns null for a cold or absent agent |
GET /api/memory/workspaces/{workspace_id}/settings | Read one workspace's switch |
PATCH /api/memory/workspaces/{workspace_id}/settings | Set it to true / false, or null to follow the global setting |
GET /api/memory/{scope} | Search or list entries; see below for filters and pagination |
GET /api/memory/{scope}/{id} | Read one entry with its target, applicability and complete |
PUT /api/memory/{scope}/{id} | Write an entry; use {id} as new to create |
DELETE /api/memory/{scope}/{id} | Delete an entry (expected_revision in the query string) |
GET /api/memory/{scope}/inbox | List proposals awaiting review |
GET /api/memory/{scope}/journal | Change history, optionally narrowed to one id |
POST /api/memory/{scope}/undo | Undo one operation by operation_id |
GET /api/memory/authority | Read the persisted backend, mode, epoch and migration progress |
POST /api/memory/authority/retry | Retry the persisted startup migration using its operation ID |
POST /api/memory/authority/migrate | Compatibility endpoint for one bounded migration batch |
POST /api/memory/authority/cancel | Cancel the matching paused maintenance batch |
POST /api/memory/authority/recover-readonly | Save a governed recovery package and enter read-only mode |
POST /api/memory/authority/resume-editing | Resume SQLite editing with the current recovery ID and epoch |
POST /api/memory/{scope}/forget-preview | Expand exact IDs into the complete source-lineage target set |
POST /api/memory/{scope}/forget | Irreversibly forget the confirmed target set |
GET /api/memory/{scope}/{id}/provenance | Read original sources, human adjudications and related IDs |
GET /api/memory/{scope}/{id}/revisions/{revision} | Read an exact historical revision; URL-encode the revision |
GET /api/memory/{scope}/{id}/export | Export one complete revision-bound Markdown document and its entry |
POST /api/memory/{scope}/import-preview | Compare a Markdown document with the current governed entry |
POST /api/memory/{scope}/import | Apply an explicitly confirmed preview token |
An advisor selector is { protocol: "chat_json" | "system_one" | "pair_score", model, endpoint? }, where model is a configured alias and a non-chat endpoint must share the origin of that model's effective_base_url. mode is off, rank or select; pair_score supports rank only. { "advisor": { "mode": "off" } } preserves the selector and other preferences. advisor: null removes the block; selector: null clears only the selector. Updates apply without restarting.
For automatic recommendations, read the settings with workspace_id, inspect advisor_status.destination, then save that exact value in advisor.egress_ack as { workspace_id, destination }, preserving any confirmations for other workspaces. The confirmation covers context sent to that provider and origin; see Memory Advisor for the content sent. The probe requires an explicit matching confirm_destination, reads no saved entries, and works while automatic advice is off. It returns { ok, status, duration_ms, input_tokens?, output_tokens?, usage_known }.
Status carries state, optional reason, destination and model, plus logical_calls, real requests, retries, recommended_count, directory enumeration, unjudged_count and displaced_ids. Settings status describes configuration readiness; /advisor/status reads runtime outcomes without starting or resuming an agent.
GET /api/memory/{scope} returns bounded pages as { items, mode, next_cursor, coverage }, including when no pagination parameters are supplied. mode defaults to list with no query and to search with one; page_size is 1–20. Without explicit statuses, list reads active and pending, search reads active, and include_inactive=true widens either to all four. statuses is a comma-separated list of active, pending, superseded and archived. Existing clients must consume next_cursor rather than treating the first page as the whole namespace.
Continue until next_cursor is null and coverage.exhausted is true. An empty preparation page with a cursor is not an empty namespace. coverage.complete and warnings report unreadable, invalid or oversized records; those records do not hide the other readable entries. Scan budgets are per call, not a permanent inventory limit. The file backend ranks and orders each bounded source chunk; SQLite pages share one committed generation.
Keep the same {scope} path and owning workspace_id / persona_id, and send cursor alone, without the original filters or page size. Mixing them returns 40944. File-backend source changes or a store restart invalidate a cursor; restart and reconcile by scope plus ID. SQLite continuation retains its committed view across later writes, while rechecking scope access and forget barriers. GET /api/memory/{scope}/inbox returns the same page shape for pending only and accepts page_size or a pending-list cursor; cursors for other statuses or search mode are rejected.
PUT /api/memory/{scope}/{id} takes the same action vocabulary as MemoryWrite: action (create, update, supersede, archive), type, title, body, reason, expected_revision and pinned. Writes through REST are recorded as coming from you, whatever source the agent would have used. The response is { entry, operationId, outcome, warnings? }, where outcome is applied, pending or unchanged; operationId is null for unchanged and for a proposal identical to one already waiting, and those two cases have nothing to undo. For update, supersede and archive, expected_revision is required — a PUT built on a stale revision is refused instead of overwriting a newer one.
Two metadata fields are optional and behave differently on omission:
basis—{ kind, note, refs? }, withkindone ofhuman,observed,derived,unknown. It is an object or absent, nevernull. Omitting it on anupdatekeeps the current basis only whiletype,titleandbodyare unchanged; rewrite any of those without a new basis and the entry drops to{ kind: 'unknown' }with awarningsentry saying the attribution was not refreshed.validity—{ check, until? }, andnullis meaningful: it clears a recorded check. Omitting the key preserves the current value on anupdateand leaves a new entry without one. An entry with no recorded validity is not a permanently valid one.
covered_by is accepted for archive only, as { id, expected_revision } of the retained active entry in the same scope. The stored entry records the same dependency as { id, revision } — the revision you sent — and it is re-checked when the retirement is applied, so an archive against a replacement that has since changed fails rather than dropping a rule that is still needed.
GET /api/memory/{scope}/{id} returns the full entry plus scope, a target of { scope, id, expected_revision }, an applicability of expired, recheck or unrecorded, and complete: true. The target is an addressing object, not a request body: scope and id belong in the path, the owning workspace or persona in the query string, and expected_revision is the one field the PUT body takes from it — alongside the action, type, title, body and reason for the change you are making. Read an entry before writing to it. A PUT whose expected_revision no longer matches returns the 40944 code, and the response's details carries the domain's own code and recovery while the numeric envelope code stays the one existing clients already handle.
Markdown export and import
These three routes require a completed SQLite migration and the same scope ownership query parameters as ordinary entry reads. They remain available for explicit management while agent memory access is off. Read-only recovery permits export but refuses preview and import.
GET /api/memory/{scope}/{id}/export returns { markdown, entry } in the normal envelope. markdown is a complete kiki-memory-v1 document: JSON or YAML frontmatter followed by the full body. Preserve format, scope, id, revision, origin: { id, revision } and source coordinates when editing an existing entry. A genuinely new document uses id: "new", revision: null, origin: null and status: "active". The file also carries type, title, status, pinned, optional basis and validity. In this complete-document format, validity: null or omitting validity clears the check, unlike a partial PUT. The body limit remains 1,500 characters.
Send { markdown, source_locator, reason } to POST /api/memory/{scope}/import-preview. Use a stable source-file locator; it is recorded as provenance, not fetched by the server. The entire document is limited to 65,536 UTF-16 units, the locator to 2,000 characters and the reason to 500. Unknown request fields are rejected. The response uses camelCase fields:
incoming,current: the complete parsed document and current governed entry, orcurrent: nullfor a new target. Review both before confirming.action:create,update,unchangedorretained.unchangedneeds no write;retainedresolves a previously admitted source to its current entry rather than replaying old content.canApply,issue: onlycanApply: truepermits confirmation. A blocked preview can includeissue: { code, recovery }, for example for a stale revision, duplicate title or inactive target.previewToken,expiresAt: present for a confirmable preview. The token binds the complete reviewed input to this server process and authority epoch for ten minutes.
To confirm, send { preview_token: previewToken, operation_id: UUID, confirm_import: true } to POST /api/memory/{scope}/import with the same scope owner. Do not resend or reconstruct the edited document. Success returns the normal { entry, operationId, outcome, warnings? } write result. The confirmed import is recorded as your write while preserving the original Markdown source.
The server rechecks the target revision and source barriers when applying. A concurrent edit or forget cannot be overwritten; revision conflicts return 40944 with domain details. An expired, altered or pre-restart token returns 40001; preview the original file again after a token failure or authority change. After a revision conflict, re-export the entry if it still exists, reconcile the document and preview again. If it was forgotten, stop importing that old source. Changing an old file's ID, title or locator does not revive its corrected or forgotten source. Import cannot approve pending proposals, restore retired entries or move entries between scopes; use the ordinary review or restoration operation first.
Backend migration and recovery
App startup automatically runs the existing SQLite migration pipeline, including for new homes and when memory.enabled=false. Normal Memory store preparation, reads and writes share that startup operation. Completed homes reuse SQLite without importing again; read-only recovery stays read-only. Storage upgrade does not enable agent memory access or authorize advisor egress.
GET authority reports backend, mode, epoch and the persisted migration. During migration, migration.id is the operation ID, namespaces names the discovered scopes, index counts completed namespaces, and optional after is the last committed source key in the current namespace. Optional phase is importing, validating or failed; issues contains { scope, key, issue }, and failure supplies { code, message }. Invalid sources preserve the original files and block ordinary memory access instead of falling back to an older copy. Repair the reported sources, then send { operation_id: migration.id } to authority/retry, or restart to resume the same operation. After an uncertain response, read status before retrying. Completion returns backend: "sqlite", mode: "active", migrationId; the same-ID retry also reconciles an already completed operation.
The compatibility authority/migrate endpoint still accepts { operation_id: UUID, max_pages?: 1..64 } for a bounded maintenance batch; the default is eight pages. The old older_writers_stopped: true field is optional and is not sent by startup. authority/cancel accepts { operation_id } for a paused batch, not a running automatic operation; subsequent normal preparation starts the automatic upgrade again. Upgrade other installations sharing the home: the marker and write barrier govern compatible writers, not pre-protocol binaries.
Send { confirm_readonly: true } to authority/recover-readonly to save the current governed SQLite database, its legacy-shaped current-view export and a manifest under store/memory-recovery-v1/<recoveryId>. The response includes recoveryId, scope, generation, namespaces and mode: "readonly". Reads continue from SQLite and mutations are refused. To resume normal editing, read authority and send { recovery_id: recoveryId, expected_epoch: epoch } to authority/resume-editing. A changed recovery ID or epoch is rejected. The full database preserves post-cutover operations, corrections, proposals and forget barriers; the Markdown export is only the compatible current-view representation.
Irreversible forget and historical reads
After SQLite migration, send { ids: ["m_example"] } to {scope}/forget-preview. Its { generation, targets } response expands the complete source lineage; each target contains id, expectedRevision and its current entry, or entry: null for a recoverably deleted object. Show this exact set before confirmation. Submit { operation_id: UUID, confirm_irreversible: true, targets: [{ id, expected_revision }] } to {scope}/forget, copying each preview revision into expected_revision. A changed revision or incomplete lineage is refused. This operation cannot be undone and does not delete conversation history, external source files or independently held backups.
Exact historical reads return the entry with historical: true, not current guidance. Forgotten revisions return the same not-found outcome as forgotten current entries. Before restoring an older backup, retain the latest applicable adjudications and forget barriers rather than reintroducing its retired content.
Session leases and peer threads
A session lease is a periodically renewed "online token" a client holds: POST /api/leases creates or renews one (default validity 60 seconds) and responds with lease_id and expires_at. A live lease can hold resources that need cleanup (such as PTY terminals and file watches) and keeps the shared daemon from exiting as idle — see --idle-exit under kiki serve.
| Method and path | Description |
|---|---|
POST /api/leases | Create or renew a lease (body carries an optional lease_id); returns lease_id and expires_at |
The peer-thread endpoints support cross-session collaboration: a thread is addressed by the { host_id, workspace_id, session_id } triple for another session on the same local host, whose completed turns you can read and to which you can deliver messages. The tool-side counterparts are ThreadList / ThreadRead / ThreadSend / ThreadWait; for the concepts, see Agents and Sub-Agents.
| Method and path | Description |
|---|---|
GET /api/threads | List addressable threads, filterable by workspace_id; cursor pagination |
POST /api/threads:read | Read a thread's completed turns with their recorded origin, optionally continuing from a cursor; preparation can return an incomplete page with an opaque continuation |
POST /api/threads:send | Durably accept a message for another thread (requires a non-empty idempotency_key; a key maps to a single message) |
POST /api/threads:wait | Long-poll for activity on up to 8 threads, timeout_ms capped at 60 seconds |
GET /api/workspaces/{workspace_id}/thread-communication | Read the persisted override and effective thread-communication state for the workspace |
PUT /api/workspaces/{workspace_id}/thread-communication | Persist the workspace's enable/disable override |
DELETE /api/workspaces/{workspace_id}/thread-communication | Clear the override |
ThreadRead continuation
A POST /api/threads:read response can contain turns: [], incomplete: "history_preparing", and an opaque next_cursor while the local history projection is preparing. This is an incomplete preparation page, not proof that the thread has no conversation. Repeat the request with next_cursor, keeping the same thread and bridge scope. The preparation cursor binds its projection generation and fixed source watermark while retaining the original older-turn boundary; once preparation completes, ended-turn pages keep the existing default limit of 20, 1–100 range, and chronological order.
Local turn input and output fields of at most 4,096 UTF-16 units remain inline. Longer fields return a preview and a field-specific entry in content_refs. Send that entry as content_ref with the same thread to receive view.segment; keep the preview and append each segment's value, following next until it is absent. Each segment contains at most 16,384 UTF-16 units, and its reference starts after the text already returned. Reading segments does not resume the thread.
Existing read cursors remain accepted for compatibility. A history rewrite explicitly invalidates affected cursors, as does a generation change; when the server reports an invalid cursor, restart from the first page rather than treating the thread as empty.
Communication history
GET /api/threads/messages reads accepted cross-thread messages without resuming sessions. It excludes ordinary user input, external REST/Klient sends, and parent–subagent exchanges. Reads remain available for archived sessions and when new peer sends are disabled.
| Query parameter | Meaning |
|---|---|
workspace_id | Match either surviving endpoint in this workspace; omit for all workspaces |
session_id | Read this session's sent and received messages |
peer_session_id | Narrow to one pair; requires session_id |
limit | Page size, default 50, range 1–100 |
cursor | Opaque continuation; repeat the same workspace/session/pair filters |
The standard envelope's data is { items, next_cursor?, incomplete?, history? }. Each item has message_id, source: { kind: "thread", thread: { ref, title?, deleted, archived } }, target: { ref, title?, deleted, archived }, content, accepted_at (Unix milliseconds), target_seq, delivery, and optional reason. References use the host/workspace/session triple. Pages are newest-first, with stable message-ID ordering for equal timestamps, and a scan-budget page can be empty while still carrying next_cursor — continue until the cursor is absent.
Room wake receipts use source: { kind: "room", room_id } and are per recipient: they carry that recipient's since catch-up rather than individual room log messages, match only that recipient's session and workspace, and are excluded by pair filters. Follow the room reference to GET /api/rooms/{id}/log for the complete discussion; room logs have their own forward afterId cursor and are not merged into this stream. Changed filters return 40931 (thread.cursor_invalid), and a missing selected session returns 40421 (thread.not_found).
delivery is pending, delivered, or undeliverable. Delivered means the input was handed to the target prompt, not that the target finished answering. A rejected send before mailbox acceptance, such as an unknown target, creates no communication record. The contract guarantees that message_id equals both the recipient main-agent prompt id and its user-message id, including steered delivery and idempotent retries. Navigate only delivered records in the target session with ?block=user-<message_id>; target_seq is a mailbox sequence, not a transcript turn number. A deleted endpoint has deleted: true; the record stays visible from the surviving side and disappears from this view when neither side survives.
History reads serve existing indexed records without waiting for a full historical repair. While coverage is unproven, incomplete: "history_preparing" distinguishes preparation from an empty history; optional history reports generation, state (complete, preparing, or error), processedMessages, completedShards, totalShards, the pending scope (room or all), and an optional failure detail. Retry from the first page while preparing — an absent cursor is not proof of completeness.
Cursors pin the coverage generation, so repaired older records or a completed repair invalidate an incomplete cursor with 40931 and you must refresh from the first page; cursors from before an upgrade need a fresh first page too. Cancelling a read does not stop the shared repair, and a failed repair leaves existing records readable and never reports complete coverage.
For undeliverable records, reason_code is a stable localization key and reason_detail is the original diagnostic text. The legacy reason remains an alias of reason_detail. Other delivery states omit these fields. Older stored failures without a code return delivery_failed; clients talking to older servers should use the same fallback instead of translating diagnostic text.
reason_code | Failure category |
|---|---|
thread_not_found | Target thread was removed or is missing |
thread_archived | Target thread is archived |
communication_disabled | Thread communication is disabled |
cross_host | Cross-host delivery is unsupported |
prompt_rejected | Target prompt or request was rejected |
session_unavailable | Target session could not be opened or is closed |
workspace_unavailable | Target workspace is unavailable |
executor_unavailable | Target executor failed or is unavailable |
cancelled | Delivery was cancelled |
delivery_failed | Other or uncoded delivery failure |
Klient provides klient.rest.threads.messages(query, options) with these snake_case wire shapes. klient.global.threads.messages({ workspaceId, sessionId, peerSessionId, cursor, limit }) provides the same read using camelCase shapes over HTTP, IPC, and memory transports.
Peer messages are no longer evicted by the mailbox's former 512-message limit. The mailbox storage upgrade backfills records still present in the old mailbox before switching authority; messages already evicted by older versions are not reconstructed from session wires. Close older writers and complete that upgrade before using the home with Kiki 0.5.x.
Room conversation entries
GET /api/rooms/items returns room summaries independently of the paginated session list. Each entry contains kind: "room", id, title, workspace, ISO createdAt / updatedAt, lastSeq, memberCount, busy, needsYou, pendingInteraction (none, approval, or question), failed, pinned, and archived. Combine these entries with loaded sessions by activity time. workspace preserves the room's own workspace root path, not a member's workspace ID. Clients resolve it against registered workspace roots before grouping or filtering; unmatched roots stay ungrouped rather than borrowing a member's workspace. Pinned entries sort first. The endpoint includes archived rooms so clients can apply the same archive filters as threads.
Use PATCH /api/rooms/{id} with name, pinned, or archived; false unpins or restores. Archiving cancels the room's queued wakes and stops its active room work without archiving member threads. DELETE /api/rooms/{id} removes the room and its log projections; missing rooms and invalid patches return 40001.
Unread marks are local to the client and space, like thread marks. lastSeq advances for messages, wake failures, and budget exhaustion, not renaming or pinning. GET /api/rooms/{id}/log returns lastSeq for the entries actually fetched, so opening a partially fetched log must not mark unseen pages read. The GUI uses /rooms/{id} links, with /r/{id} as a short route alias.
Klient exposes rest.rooms.listItems() and global.rooms.listItems(). klient.events.on("room.changed", handler) carries { roomId, room, entry?, deleted? }; the existing WebSocket event is event.room.changed with room_id. A deletion has deleted: true and includes the final room document for invalidation, not an existing-room claim.
File system
In-session file operations go through POST /api/sessions/{session_id}/fs:{action} with JSON bodies; actions are list / read / list_many / stat / stat_many / mkdir / search / grep / git_status / diff / open / open-in / reveal. In addition:
| Method and path | Description |
|---|---|
POST /api/workspace/fs:search | Session-less workspace search (the body carries the workspace reference) |
GET /api/sessions/{session_id}/fs/{path}:download | Download a session file (binary, see below) |
GET /api/fs:browse | List host directories (folder picker) |
GET /api/fs:home | The user's home directory and recent workspaces |
GET /api/fs:content | User-directed host-file preview by absolute path, including sensitive files (bearer token required; not an agent tool permission endpoint) |
POST /api/fs:mkdir | Create a directory by absolute path |
POST /api/fs:media-preview | Create a temporary host audio/video playback URL (authenticated loopback connection) |
DELETE /api/fs:media-preview/{preview_id} | Revoke a host-media playback URL (bearer token required) |
POST /api/sessions/{session_id}/media-playback | Create a temporary playback URL for a canonical session audio or video file (authenticated loopback connection) |
DELETE /api/sessions/media-playback/{preview_id} | Revoke a session playback URL (bearer token required) |
GET /media-preview/{preview_id} | Stream the granted host file or canonical session file without a bearer header; supports Range |
For host audio/video, send { "path": "<absolute file path>" } to POST /api/fs:media-preview. The response envelope contains preview_id, url, mime, size, and expires_at (Unix milliseconds). Pass url directly to the media element; possession of this URL permits reading that file until it expires after 30 minutes, is revoked, or the server stops. Close the preview with DELETE /api/fs:media-preview/{preview_id}. These routes are registered only when server authentication is enabled; creation requires a loopback client and host name, and at most 64 grants may be open at once. Host creation takes an absolute path, not a session media ID. For a historical attachment, send { "file_id": "<canonical file id>" } to POST /api/sessions/{session_id}/media-playback. The response uses the same fields, and GET /media-preview/{preview_id} streams that canonical file. A host path on that request is rejected, and a missing canonical file is not read from the host filesystem. Revoke it with DELETE /api/sessions/media-playback/{preview_id}. An unsatisfiable Range receives 416. The same loopback, 30-minute, and 64-grant limits apply to host and session grants together. These media-preview routes own that shared table: a grant expires after 30 minutes, DELETE removes it, and server stop drops it. A persisted tool result stored as tool-result.bin and application/octet-stream is playable only when the first 512 bytes identify audio or video; the stored bytes and the authenticated download's generic binary type stay unchanged. A live session lease is released once when playback finishes, the client disconnects, or the stream fails, and a failed release is reported rather than discarded. Supported codecs still depend on the browser or WebView. The authenticated GET /api/sessions/{session_id}/media/{file_id} download remains for other reads.
The session fs:{action} workspace API accepts workspace-relative paths only and rejects absolute paths and symlink escapes. This GUI boundary does not restrict the separate agent Read tool's explicit absolute paths. /api/fs:content is for an authenticated user's preview, not an agent file tool: do not expose the bearer token or disable authentication on an untrusted network.
Both session and workspace fs:search return one bounded page. Send query, limit, and optional include_globs, exclude_globs, and follow_gitignore; to continue, repeat the same conditions with the returned next_cursor as cursor. Each response contains items, sort_scope: "page", and truncated, which is true exactly when next_cursor is present. An empty page with a cursor still has work remaining. Fetch another page only when more results are requested; an absent cursor means the search is exhausted.
Pages are sorted by score descending, then path; an empty query lists the root directory with directories first, then path. Page order is not a global best-match ranking. Cancel the request when its query changes or its picker closes; cancellation stops subsequent traversal after any in-flight file-system call finishes. A single wide directory still requires a full directory read and sort. If a continuation returns 40001 because its conditions or traversed directories changed, retain the displayed candidates and restart without a cursor.
File uploads
| Method and path | Description |
|---|---|
POST /api/files | Multipart upload (file field, optional name and expires_in_sec); returns file metadata |
GET /api/files/{file_id} | Download (binary; errors use real HTTP statuses) |
DELETE /api/files/{file_id} | Delete |
IM account and channel bindings
Two-way messaging binds each input or output channel to a session. A Persona is optional; changing or clearing it does not move the session's bindings.
| Method and path | Description |
|---|---|
GET /api/im/sessions | Snapshot of policy, instances, discoveries, bindings, strangers and voice fallbacks |
GET /api/im/sessions/catalog | Personas and sessions available as binding targets |
PUT /api/im/sessions/policy | Set allowed, default_enabled, and optional allow_channel_borrowing (default false) |
POST /api/im/sessions/instances/{id}/listen | Set { listening: true | false }; retry a stopped listener with true |
POST /api/im/sessions/discoveries/{id}/confirm | Bind to target; optional rebind, input_enabled, output_enabled, busy, reply, mobile_interactions, also_home |
PATCH /api/im/sessions/bindings/{id} | Update this binding's target, direction switches or options |
DELETE /api/im/sessions/bindings/{id} | Remove this IM binding |
GET /api/im/sessions/targets/{session_id}/channels | Read output channels and candidates with their actual media capabilities |
PUT /api/im/sessions/targets/{session_id}/notify/{channel_id} | Bind a notification output or change its session switch with { enabled: boolean } |
DELETE /api/im/sessions/targets/{session_id}/notify/{channel_id} | Remove only this session's notification binding |
GET /api/sessions/{session_id}/optional-capabilities | Read authoritative capability rows, including send_message |
POST /api/sessions/{session_id}/optional-capabilities | Set { id: "send_message", choice: "on" | "off" | "inherit" }; applies at the next step without restarting |
A target is { kind: "session", session_id }, or the compatible { kind: "persona", persona_id } shortcut to that Persona's home session. PATCH also accepts legacy session_id, but not together with target; also_home belongs only to confirmation. Reconfirming preserves existing enabled, session_enabled, input_enabled and output_enabled unless explicitly changed. session_enabled: null follows policy.default_enabled; a direction set to null removes that direction override. Both directions still require the binding and session allowance to be enabled.
For groups, confirmation and PATCH accept binding_scope: "sender" | "topic" | "chat"; omission matches only the original sender. topic_id: "0" means there is no topic: choose "chat" for the whole group or "sender" for the original sender; "topic" returns business code 40001 / topic_scope_unavailable. Choosing "topic" or "chat" outside a group returns 40001 / group_binding_scope_required. A stored no-topic "topic" binding still matches only its original sender; it does not widen to the group.
PATCH changes a binding in place. If broadening its scope overlaps another enabled input binding, send rebind: true; a rejected or failed save keeps the previous binding and pending replies. Explicitly enabling a direction the connection does not support returns 40001 / input_unsupported or output_unsupported. These errors are business codes in an HTTP 200 envelope; identity conflicts use business code 40949. Topic/chat scopes share a session across members while preserving real sender IDs and isolating their pending interactions.
New topic/chat group bindings default to group_policy: { wake: "mention_or_reply" }. Confirmation and PATCH accept group_policy: { wake: "always" | "mention_or_reply" | "manual", prefixes?: string[] }: always wakes on every message, mention_or_reply wakes on a real mention, a reply to a known self message, or a literal configured prefix, and manual records without waking. Existing sender-scoped bindings with no policy keep every-message wake.
Mention/reply detection requires adapter metadata. Built-in Telegram provides real message IDs, timestamps, sender/chat names, entity-based mentions and reply IDs; update IDs remain separate event deduplication keys. Other transports without those fields can use prefixes or always instead.
Choose a group binding's scope and wake rule in Notifications & messages. To change them through REST, use confirmation or PATCH /api/im/sessions/bindings/{id}. For a transport without mention/reply metadata, set { "group_policy": { "wake": "mention_or_reply", "prefixes": ["Kiki:"] } } to use a prefix, or { "group_policy": { "wake": "always" } } to wake on every message.
An enabled inbound group binding keeps a bounded, persistent received timeline per account, chat and topic. Quiet messages do not start a model turn. A triggered prompt includes recent unconsumed background and up to four quoted ancestors as untrusted external data; accepted submission advances only its captured background watermark, without deleting the timeline or losing newer arrivals. The main agent can use ReadMessageHistory for explicit local searches or optional platform backfill. Disabling input stops both collection and reads for that binding.
An input identity belongs to one session at a time. Moving a binding to another session without rebind: true returns business code 40949, msg: "im_identity_already_bound", and details containing existing_binding_id, previous_session_id, target_session_id and variant_key. Show these identities for confirmation, then repeat the request with rebind: true; cancelling sends no mutation. Old interaction tickets cannot act on the new session, and old reply watchers expire on transfer. The mobile :target command lists targets but requires confirmation in Kiki to transfer them. A legacy binding with a missing session stays visible as pending_reanchor: true, retaining its old identity and Persona provenance without routing until explicitly reanchored.
The output response contains internal_enabled: true and channels. Each channel has id, kind (internal, im, notify), enabled, capabilities, and optional bound; bound: false is a candidate, not a session binding. internal stays enabled; the legacy internal-switch endpoint rejects { enabled: false }. Klient exposes rest.imSessions.channelViews, setNotificationChannel and removeNotificationChannel.
Binding channels does not grant SendMessage. Ordinary reply threads default to off; [optionalCapabilities.defaults] send_message = true changes the global default, while a session choice of on works even when the default is off. Persona message mode retains its implicit-on behavior; explicit session off and hard tool-policy denies take precedence. Turning the tool off neither unbinds channels nor hides ordinary final replies.
With the tool admitted, omit channel/channels to send only to enabled bound outputs, including internal; explicit IDs select one or more outputs. Borrowing an unbound channel requires allow_channel_borrowing: true and an enabled, authorized target. to: "user", to: "@Bot" and Room routing retain their recipient meaning; channel IDs cannot be combined with Room or @Bot routing. Per-channel receipts distinguish delivered, partial, unsupported, failed and unknown, and mark borrowed targets. Notification outputs currently carry text only. Inspect partial or unknown receipts instead of resending automatically; an accepted provider response does not prove a person received the message.
An instance's listening records the requested state. Its adapter is missing, running, shared or stopped; optional connection is starting, reconnecting, auth_expired or stopped. Normal running omits connection. For auth_expired, renew authorization through setup; for a stopped connection, retry listening explicitly. Optional connection_detail contains a finite code (transport_error, http_error, api_error, invalid_response, auth_expired, loop_failed) and optional numeric http_status, ret, errcode, failure_count, retry_in_ms. A successful reconnect clears that detail; an absent detail does not identify a failure cause.
Channel identity uses chat_id, sender_id, and topic_id ("0" for no topic). Optional chat_kind is private or group; do not infer it from a title when absent. The current WeChat connection supports private contacts only. Confirming a group returns business code: 40001, msg: "unsupported_wechat_group", preserving the discovery without creating a persona home or changing a binding.
If an older Feishu thread binding has no reply context, send a new message from the same sender in that thread to restore delivery of still-pending approvals and questions. Unsupported WeChat group bindings remain unsent; disable or remove them rather than retrying them as private chats. A blocked binding does not prevent delivery to other channels.
Global search and misc
| Method and path | Description |
|---|---|
POST /api/search | Cross-session full-text search; mode is terms (default) or literal (exact substring); page_token pagination |
POST /api/search/retry | Restart a search indexer that stopped after memory pressure; keeps the published index |
GET /api/connections | List live WebSocket connections |
GET /api/usage | Aggregate usage across sessions with bounded filters and pagination |
GET /api/usage/pricing / PUT /api/usage/pricing | Inspect model price sources or patch user price overrides, see below |
POST /api/external-delegation/seats / GET /api/external-delegation/seats | Create or list external-delegation seats |
POST /api/external-delegation/seats:resolve | Resolve an existing seat by { workspace, principal }; returns the seat with its delegation token, or null, without creating a seat, session, or workspace |
DELETE /api/external-delegation/seats/{seat_id} | Revoke an external-delegation seat |
POST /api/sessions/{session_id}/external-delegation/{procedure} | Call an admitted external-delegation procedure |
GET /api/sessions/query | Next-generation session list, see below |
POST /api/sessions:archive | Batch-archive sessions, see below |
POST /api/sessions:restore | Batch-restore archived sessions, see below |
/api/debug/* | Reflection debug RPC; mounted only with --debug-endpoints on loopback, not a stable protocol |
POST /api/search accepts query and defaults to user/assistant text plus session titles. Set include_tool_output: true to include tool results and Shell output; thinking, tool names and tool inputs remain excluded. role can select user, assistant, tool or title, with role: "tool" explicitly selecting tool results. The same content scope applies to live sessions and existing indexes. Changing it invalidates the old page_token; start again without a token.
POST /api/search/retry takes no body. When the indexer has stopped after repeated memory pressure, call it to clear the backoff and restart the stopped writer. This applies to the default SQLite backend and to MiniDB. Documents already in the published index stay searchable; the call does not delete or rebuild that index. Success uses the normal envelope with data.retried set to true. Klient exposes rest.search.retry().
Usage source grouping
GET /api/usage supports dimension=agent|model|project|session|provider|profile. The native provider and profile dimensions group each record by its recorded source, including profile changes within one agent and provider changes behind one model alias. Profile means the execution profile, not a persona or a main/subagent role.
Repeat model, provider, profile, agent.id, or workspace.id to select multiple values: ?dimension=profile&profile=explore&profile=general. Values within a filter are ORed; different filters are ANDed. Profile matching is exact and case-sensitive against the record's profileName, not the agent's current profile. The response echoes normalized values in query.profiles; older servers may omit this additive field.
Source groups use opaque keys such as provider:"example-provider" or profile:"explore"; missing sources have distinct provider:null or profile:null keys and a null attribution field. They retain their recorded usage and are never inferred from a model or agent ID. Use group.provider or group.profile_name, not the group key, as a filter value. There is no filter sentinel for missing sources; the literal value unknown selects only records that actually carry that name.
For a selected period or comparison, send range=custom&start_at=A&end_at=B with epoch-millisecond bounds [A,B). Summary, trend groups, and each session's usage apply the same record filters and exact window; session totals do not include unrelated profiles or the rest of a time bucket. Cost and token unknown flags remain separate from their recorded numeric subtotals. Page tokens bind the filters, data revision and current prices; changing any of them returns business code 40922. Discard the token and restart from the first page. The GUI does this automatically.
Full usage rescan
On the usage page's History tab, Rescan all usage scans every indexed session, including archived sessions. It runs only when requested and can take a while. POST /api/usage/rescan starts the task without a body; another POST while it is running returns the same task's current progress. Poll GET /api/usage/rescan about once per second. Both use the normal success envelope with data containing state (idle, running, completed, or failed), scanned_sessions, total_sessions, scanned_records, started_at, finished_at, and error. Times are epoch milliseconds or null; error is null unless the task failed. scanned_records counts newly read wire records, including non-usage records; unchanged checkpoints need no wire reads.
The rescan bypasses ordinary session, record, and time scan budgets and saves incremental source checkpoints; regular queries keep reading committed data while it runs. A regular query has a 10-second deadline, a 500-session cold-recovery limit, and a budget of 200,000 facts to import or recover, including unknown and invalid facts. Non-usage wire records do not consume that fact budget, and querying already indexed facts does not consume it again. Deleted-session and ephemeral ledgers also resume under regular query budgets. A completed rescan does not supply missing provider usage or model prices. Task progress is process-local and resets to idle on restart; committed facts, source coverage and import progress survive. Cancellation of the rescan is not supported.
History and agent totals share lightweight usage facts saved as conversation records are committed. Valid existing usage checkpoints migrate automatically. Without a usable checkpoint, the first query recovers only the uncovered source prefix; reliability.complete=false means the displayed subtotal is not yet complete. A later query continues from committed progress, and cancelling a history request stops recovery when no other caller still needs it. Restarting with valid facts or rebuilding their query index does not require reading conversation bodies again.
Usage pricing
GET /api/usage/pricing returns configured model IDs, saved overrides, and any IDs requested with repeated model query parameters. Each items entry contains model, the optional configured pricing_model (or null), matched_key, source (override, litellm-cache, vendored, or unknown), and prices (or null). Model IDs behind routing prefixes are matched against canonical catalog names; prices are estimates, not the proxy's invoice. The server refreshes its LiteLLM cache under <home>/model-pricing; the vendored MIT-licensed snapshot is the offline fallback.
PUT /api/usage/pricing takes a sparse patch; omitted keys stay unchanged, and null removes an override. All rates are per token, not per million tokens. Divide a quoted per-million rate by 1,000,000 before submitting it. Input and output rates are required; cache-read and cache-write rates may be omitted when unknown, but a request using an unpriced category remains a partial estimate.
{"overrides":{"proxy/model":{"input_cost_per_token":0.000002,"output_cost_per_token":0.00001,"cache_read_input_token_cost":0.0000002,"cache_creation_input_token_cost":0.0000025,"currency":"USD"}}}These example rates are illustrative, not a published price for proxy/model. Rates must be finite and nonnegative, and currency is a three-letter uppercase code. Non-USD overrides are stored and returned without currency conversion; they do not contribute to cost_usd_estimated. Unknown models are never assigned an invented price. Overrides are atomically stored in <home>/model-pricing/overrides.toml and survive restart. Validation errors return envelope code 40001; storage failures return 50001. The HTTP client methods are klient.rest.usagePricing.get(models?) and .set({ overrides }).
Source facts and coverage are persisted in the dedicated Usage SQLite read model. Unchanged agent inventories, generations and wire size/mtime reuse recorded usage without reading wire boundaries; appends or changed fingerprints trigger incremental recovery or a replacement generation. Sessions with no agents contribute complete zero usage and do not occupy a usage detail row. Estimated cost preserves the subtotal of fully priced records when another record lacks a required price; it does not price only the known token categories of an otherwise unpriced record.
GET /api/sessions/query
A next-generation session query for list views — filtering, sorting, and field groups all travel in query parameters:
| Parameter | Description |
|---|---|
workspace.id | Filter by workspace; repeatable |
activity.status | Filter by activity status: running / approval / question / failed / idle; repeatable |
meta.updated_after | Only sessions updated after this time (epoch milliseconds) |
meta.updated_before | Only sessions updated before this time (epoch milliseconds) |
meta.archived | true / false (default) / all |
sort | meta.updated_at_desc (default) / meta.updated_at_asc / meta.created_at_desc |
include | Comma-separated extra field groups; currently only git (branch and PR info, deduplicated per directory and cached for 60 seconds) |
fields | Comma-separated item projection; currently only id,archived, trimming each item to { id, archived } (select-all-matching flows). Not combinable with include=git (40001) |
page_size | 1–100, default 50; up to 10000 with the id,archived projection |
page_token | Pagination token from the previous page |
page | Stateless 1-based page number; mutually exclusive with page_token (40001 when combined) |
Every response item carries the workspace, meta, and activity groups, plus git when include=git — or just { id, archived } under fields=id,archived. Every page additionally carries total, the size of the filtered set. The page token binds the first page's query conditions (including the projection); changing them mid-pagination returns 40922. page mode is a stateless alternative for jumping to arbitrary pages: every request is an independent snapshot, no token is minted, and next_page_token is always null.
POST /api/sessions:archive and POST /api/sessions:restore
Batch archive/restore for session-management views. The body is { "ids": ["session_..."] } — non-empty, at most 5000 unique ids (duplicates collapse). Live sessions go through the full lifecycle; cold sessions are patched on disk without being loaded.
Only a body validation failure fails the whole request (40001). Otherwise the response is per-item: data.results keeps the input order with { id, ok } or { id, ok: false, error } (an unknown id reports 40401 in its own item), plus succeeded / failed counts.
{
"code": 0,
"msg": "success",
"data": {
"results": [
{ "id": "session_a", "ok": true },
{ "id": "session_b", "ok": false, "error": { "code": 40401, "message": "session session_b does not exist" } }
],
"succeeded": 1,
"failed": 1
},
"request_id": "01JZX4A6E7M8V0R3Q0N2K2M5Q9"
}WebSocket protocol
Connect
The only endpoint is ws://<host>:<port>/api/ws; authentication happens at the upgrade request (see Authentication above). Once connected, the server immediately sends server_hello:
{
"type": "server_hello",
"timestamp": "2026-01-01T00:00:00.000Z",
"payload": {
"ws_connection_id": "conn_01JZX4...",
"protocol_version": 2,
"max_event_buffer_size": 1000,
"capabilities": { "event_batching": false, "compression": false }
}
}Note that the server never sends heartbeats and never disconnects an idle connection — keepalive and reconnection are the client's job.
Control frames
Clients send JSON frames { "type", "id"?, "payload" }; every request frame gets an acknowledgement { "type": "ack", "id", "code", "msg", "payload" }, where code 0 means success.
| Frame | payload | Description |
|---|---|---|
subscribe | { session_ids, cursors?, agent_filter? } | Subscribe to session events; with cursors (per-session {seq, epoch}) the server replays missed durable events |
unsubscribe | { session_ids } | Drop session subscriptions |
subscribe_v2 | { session_id, transcript, transcript_since?, transcript_coverage_version: 2 } | Subscribe to transcript streams (the only transcript channel); transcript sets per-agent grades |
unsubscribe_v2 | { session_id, agent_ids? } | Detach transcript streams; omitting agent_ids means the whole session |
watch_fs_add / watch_fs_remove | { session_id, paths, recursive? } | Subscribe to / unsubscribe from file-change notifications (event.fs.changed) |
client_hello | { client_id } | Handshake frame; the remaining fields are legacy compatibility |
Events
Event frames look like { "type", "seq", "epoch"?, "volatile"?, "offset"?, "session_id"?, "timestamp", "payload" }, where type is the event type itself. Two delivery scopes:
- Global events: sent to every established connection, no subscription needed —
session.meta.updated,event.session.created,event.session.work_changed,event.session.status_changed,event.workspace.*,event.config.*. - Session events: sent only to connections subscribed to that session, subject to
agent_filter. Main families:
| Family | Main events |
|---|---|
| Turns | turn.started, turn.ended, turn.step.started / completed / interrupted / retrying |
| Streaming text | assistant.delta, thinking.delta (carry offset for alignment) |
| Tool calls | tool.call.started, tool.call.delta, tool.progress, tool.result |
| Interactions | event.approval.requested / resolved, event.question.requested / answered / dismissed |
| Subagents | subagent.spawned / started / suspended / completed / failed |
| Background | task.started / terminated, shell.started / output / completed |
| Misc | compaction.*, skill.activated, goal.updated, prompt.*, error, warning |
The compaction.* family separates who asked from what happened. compaction.started carries trigger as manual or auto and an optional phase of queued or running; an event without phase means running. compaction.completed carries the optional trigger and a result; compaction.cancelled carries the optional trigger and a reason when it failed rather than being cancelled. Judge completion from these events, not from the HTTP response of a compact request — and treat a trigger that is absent as unknown rather than assuming a manual run finished. A queued manual compaction is not settled by an automatic one completing.
Events also split into durable and volatile: durable events carry a strictly increasing seq, are journaled, and can be replayed; volatile events (the *.delta family, tool.progress, shell.*, and similar) are marked volatile: true and never replayed. When consuming a volatile text stream, compare offset (the cumulative character offset within the turn) against your locally accumulated text: below the local length means a duplicate frame; above means a gap that needs snapshot recovery.
Reconnect and recovery
After reconnecting, pass each session's last applied {seq, epoch} in subscribe's cursors; the server replays the gap. If you fall more than the buffer (1000 events) behind, or the cursor is no longer valid, you get resync_required instead. In that case, call GET /api/sessions/{session_id}/snapshot for a full snapshot (with as_of_seq and epoch), then subscribe again with the fresh cursor.
Transcript protocol
subscribe_v2's transcript field sets a per-agent grade: off / turn / block / delta, where the "*" key sets the default and a higher grade pushes finer detail. If any grade is not off, send the numeric transcript_coverage_version: 2 in the payload; a missing or unsupported version is rejected before any transcript frame is sent, and an off-only subscription does not need it.
An agent with a non-off grade receives transcript.reset (a baseline snapshot; history pages in over REST) and transcript.ops (incremental op batches with a per-agent strictly increasing seq). That agent's legacy events are suppressed on the connection and carried by transcript frames instead. After a disconnect, resume with transcript_since; when the server's op journal cannot cover the gap — REST catch-up returns complete: false — do a full refresh. The REST counterparts are GET .../transcript (turn-paged) and GET .../transcript/ops?since_seq= (op-batch catch-up).
Binary and streaming endpoints
The following endpoints stream binary bodies instead of a JSON payload. Their HTTP capabilities differ per endpoint:
| Method and path | Description | Range (206) | ETag / 304 |
|---|---|---|---|
GET /api/files/{file_id} | Download an uploaded file | Yes | No (sends an etag header but ignores If-None-Match) |
GET /api/sessions/{session_id}/fs/{path}:download | Download a session workspace file | Yes | Yes |
GET /api/sessions/{session_id}/media/{file_id} | Read session media, including saved tool-result blobs | Yes | Yes |
GET /api/fs:content | User-directed host-file preview by absolute path, including sensitive files (bearer token required; not an agent tool permission endpoint) | Yes | Yes |
POST /api/sessions/{session_id}/export | Export the session with diagnostics (zip stream) | No | No |
GET /media-preview/{preview_id} | Stream a temporary host or session playback grant | Yes; unsatisfiable Range is 416 | No (private, no-store) |
Indexed sessions serve canonical media and staged uploads without restoring the session. Canonical bytes and filenames take precedence over a staged-upload copy. Admitted browser origins can read ETag, Content-Disposition, and Content-Range for revalidation, filenames, and range receipts; this does not change origin admission or authentication.
Error semantics differ as well: GET /api/files/{file_id} and session media downloads answer lookup and storage failures with real 404 / 500 statuses (file parameter validation still uses the HTTP 200 envelope). A missing, revoked, or expired /media-preview/{preview_id} grant returns HTTP 404 with a JSON error envelope. Workspace downloads, raw host-file previews, and exports use the standard response envelope for failures — clients must keep checking its code.
Next steps
- Local server and API — startup, authentication, and the end-to-end calling flow
- kiki command — all
kiki webcommand-line options