# Omnigent Server API Four namespaces: agent management (`/api/agents`), conversations (`/v1/conversations`), sessions (`/v1/sessions`), and session resources (`/v1/sessions/{session_id}/resources`). ## Compatibility Reference | Namespace | Compatible with | Reference implementation | |---|---|---| | Agent Management (`/api/agents`) | Omnigent (ours) | No external reference — this is our own API. | | Conversations (`/v1/conversations`) | Omnigent (ours) | No external reference. | | Sessions (`/v1/sessions`) | Omnigent (ours) | No external reference. Session-first API for long-running agent interactions. See "Sessions API" section below. | | Session file resources (`/v1/sessions/{session_id}/resources/files`) | Omnigent (ours) | No external reference. Files are scoped to their owning session. | --- ## Agent Management ### Create Agent ``` POST /api/agents Content-Type: multipart/form-data Parts: bundle: required — must contain config.yaml with a unique name and optional description. The name becomes the "model" for inference requests. The server validates the bundle on upload: extracts it to a temporary directory, parses config.yaml, and runs the spec validator. Name and description are derived from the spec — no separate form fields. 201 Created { "id": "ag_abc123", "object": "agent", "name": "my-agent", "description": "...", "created_at": 1774118382 } 409 Conflict — name already exists 400 Bad Request — invalid bundle (corrupt tarball, missing config.yaml, spec validation failure, missing name, path traversal, etc.) ``` ### List Agents ``` GET /api/agents Query parameters: limit (integer, optional, default: 20, max: 1000) Number of agents to return. after (string, optional) Cursor for forward pagination. Pass the `last_id` from a previous response to get the next page. before (string, optional) Cursor for backward pagination. Pass the `first_id` from a previous response to get the previous page. order (string, optional, default: "desc") Sort order by `created_at`. Either "asc" or "desc". 200 OK { "object": "list", "data": [ {"id": "ag_abc123", "object": "agent", "name": "my-agent", ...}, {"id": "ag_def456", "object": "agent", "name": "other-agent", ...} ], "first_id": "ag_abc123", "last_id": "ag_def456", "has_more": false } ``` Items in `data` have the same shape as the create/get response. ### Get Agent ``` GET /api/agents/{id} 200 OK — same shape as create response 404 Not Found ``` ### Delete Agent ``` DELETE /api/agents/{id} 200 OK {"id": "ag_abc123", "object": "agent.deleted", "deleted": true} 404 Not Found ``` Cancels all in-flight responses for this agent before deleting. --- ## Session File Resources Upload files that can be referenced by `file_id` in `input_image` and `input_file` content types. Files are immutable once uploaded and are scoped to their owning session. The legacy global `/v1/files` endpoints are removed; clients must use the session resource namespace. ### Upload File ``` POST /v1/sessions/{session_id}/resources/files Content-Type: multipart/form-data Parts: file: required 201 Created { "id": "file_abc123", "object": "session.resource", "type": "file", "session_id": "conv_abc123", "name": "report.pdf", "metadata": { "filename": "report.pdf", "bytes": 214961, "created_at": 1774118382 } } 400 Bad Request — missing file 404 Not Found — session not found ``` ### List Files ``` GET /v1/sessions/{session_id}/resources/files Query parameters: limit (integer, optional, default: 20, max: 1000) after (string, optional) before (string, optional) order (string, optional, default: "desc") Sort order by `created_at`. Either "asc" or "desc". 200 OK { "object": "list", "data": [ {"id": "file_abc123", "object": "session.resource", "type": "file", ...}, {"id": "file_def456", "object": "session.resource", "type": "file", ...} ], "first_id": "file_abc123", "last_id": "file_def456", "has_more": false } ``` ### Get File ``` GET /v1/sessions/{session_id}/resources/files/{id} 200 OK — same shape as upload response 404 Not Found — session or file not found, including files owned by another session ``` ### Delete File ``` DELETE /v1/sessions/{session_id}/resources/files/{id} 200 OK {"id": "file_abc123", "object": "session.resource.deleted", "deleted": true} 404 Not Found ``` ### Get File Content ``` GET /v1/sessions/{session_id}/resources/files/{id}/content 200 OK Content-Type: 404 Not Found ``` --- ## Conversations Conversations are created automatically. When a response has no `previous_response_id`, the server creates a new conversation and assigns the response to it. When a response has a `previous_response_id` pointing to the **latest** response in a conversation, it joins that conversation. When `previous_response_id` points to a **non-latest** response (a fork), the server creates a new conversation. Items up to and including the fork point are copied into the new conversation with new response IDs, and the new response is added there. The original conversation is unchanged. Each conversation is always a linear thread — no branching. Response IDs are globally unique, so `previous_response_id` is never ambiguous across conversations. Clients may optionally pass a conversation ID when creating responses (must be paired with `previous_response_id`). Conversation APIs are primarily for **retrieval** — listing past conversations, loading message history, and finding the latest response ID to continue from. ### List Conversations ``` GET /v1/conversations Query parameters: limit (integer, optional, default: 20, max: 1000) Number of conversations to return. after (string, optional) Cursor for forward pagination. Pass the `last_id` from a previous response. before (string, optional) Cursor for backward pagination. Pass the `first_id` from a previous response. order (string, optional, default: "desc") Sort order. Either "asc" or "desc". sort_by (string, optional, default: "created_at") Column to sort on. Either "created_at" or "updated_at". 200 OK { "object": "list", "data": [ {"id": "conv_abc123", "object": "conversation", "title": null, "created_at": ..., "updated_at": ...}, {"id": "conv_def456", "object": "conversation", "title": "Weather chat", "created_at": ..., "updated_at": ...} ], "first_id": "conv_abc123", "last_id": "conv_def456", "has_more": false } ``` Results ordered by `sort_by` column descending (newest first) by default. ### Get Conversation ``` GET /v1/conversations/{id} 200 OK { "id": "conv_abc123", "object": "conversation", "title": null, "created_at": 1774118382, "updated_at": 1774118400 } 404 Not Found ``` ### List Conversation Items ``` GET /v1/conversations/{id}/items Query parameters: limit (integer, optional, default: 20, max: 1000) after (string, optional) before (string, optional) order (string, optional, default: "asc") Sort order by position in conversation. Either "asc" (chronological) or "desc". 200 OK { "object": "list", "data": [ {"id": "msg_aaa", "response_id": "resp_001", "type": "message", "role": "user", "status": "completed", "content": [{"type": "input_text", "text": "What's the weather?"}]}, {"id": "msg_bbb", "response_id": "resp_001", "model": "my-agent", "type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": "It's sunny in SF.", "annotations": []}]}, {"id": "msg_ccc", "response_id": "resp_002", "type": "message", "role": "user", "status": "completed", "content": [{"type": "input_text", "text": "And tomorrow?"}]}, {"id": "fc_ddd", "response_id": "resp_002", "model": "my-agent", "type": "function_call", "status": "completed", "name": "get_weather", "arguments": "{\"location\": \"SF\", \"date\": \"tomorrow\"}", "call_id": "call_001"}, {"id": "fco_eee", "response_id": "resp_002", "type": "function_call_output", "status": "completed", "call_id": "call_001", "output": "{\"forecast\": \"rain\", \"high\": 58}"}, {"id": "msg_fff", "response_id": "resp_002", "model": "my-agent", "type": "message", "role": "assistant", "status": "completed", "content": [{"type": "output_text", "text": "Rain expected, high of 58°F.", "annotations": []}]} ], "first_id": "msg_aaa", "last_id": "msg_fff", "has_more": false } 404 Not Found — conversation doesn't exist ``` Items include all input and output messages, function calls, and function call outputs accumulated across all responses in this conversation. Each item carries a `response_id` linking it to the response that produced it. Model-produced items (assistant messages, function calls, reasoning) include a `model` field identifying the agent. User messages and function call outputs do not have `model` — the agent is always recoverable from `response_id` if needed. To continue a conversation, pass the `response_id` from the last item as `previous_response_id`. ### Update Conversation ``` PATCH /v1/conversations/{id} Content-Type: application/json {"title": "Weather chat"} 200 OK { "id": "conv_abc123", "object": "conversation", "title": "Weather chat", "created_at": 1774118382, "updated_at": 1774118400 } 404 Not Found 400 Bad Request — invalid field ``` Currently only `title` (string | null) is updatable. ### Delete Conversation ``` DELETE /v1/conversations/{id} 200 OK {"id": "conv_abc123", "object": "conversation.deleted", "deleted": true} 404 Not Found ``` Deletes the conversation and all associated tasks. Cancels any in-flight tasks in the conversation before deleting. --- ## Sessions API (`/v1/sessions`) A session-first API for long-running agent interactions. The session is the primary resource. Tasks (responses) are internal implementation details. A session is a thin layer on top of a conversation: same backing store, same items, same live SSE publisher. The session API exposes four endpoints (create, get, post-event, stream) and a small set of session-scoped events on the wire (see "Stream Events" below). ### Compatibility Reference | Namespace | Compatible with | Notes | |---|---|---| | Sessions (`/v1/sessions`) | Omnigent (ours) | No external reference. Purpose-built for agent-native workflows: live tail, queued input, interrupt. | ### Session Lifecycle States A session is always in one of these states: ``` idle -> running -> idle -> waiting -> running -> idle -> failed ``` - **idle**: No agent loop running. The session is ready to accept new events. This is the initial state when no `initial_items` are posted on creation, and the terminal state after a turn finishes. - **running**: An agent loop is actively processing. The session's SSE stream is emitting events. New events can still be posted — they queue behind the current turn and are consumed at the next iteration checkpoint. - **waiting**: The agent loop has parked the current turn waiting for an external signal (background tools, sub-agent completion, client tool result). Surfaced as a `session.status` event with `status: "waiting"`. The loop resumes (back to `running`) when the signal arrives. - **failed**: An unrecoverable error occurred during processing. The session cannot accept new events. `action_required` is intentionally NOT a session status — it lives at the task layer (see runtime). The session schema's `status` field is a `Literal["idle", "running", "waiting", "failed"]` and the route layer rejects any other value with a 500 (fail loud). ### Session Object The `SessionResponse` Pydantic model (defined in `server/schemas.py`) is returned by JSON `POST /v1/sessions`, `GET /v1/sessions/{id}`, and `PATCH /v1/sessions/{id}`: ```json { "id": "conv_abc123", "agent_id": "ag_abc123", "status": "running", "created_at": 1234567890, "runner_id": "runner_abc123", "items": [ {"id": "msg_aaa", "type": "message", "role": "user", "status": "completed", "content": [{"type": "input_text", "text": "Plan my trip"}]} ] } ``` Fields: id (string, required) Unique session identifier; also the underlying conversation ID. Prefixed with `conv_`. agent_id (string, required) Durable identifier of the bound agent (e.g. `"ag_abc123"`). Stable across renames of the agent. The session is bound by ID, not name - name lookups are not supported. agent_name (string, optional) Human-readable display name of the bound agent (e.g. `"claude-native-ui"`). For switch-created session-scoped clones this is the spec's clean name, not the clone row's `"… (switch ag_…)"` disambiguation name. Changes when the session is switched to a different agent in place (`POST /v1/sessions/{id}/switch-agent`), so attached clients can refresh their displayed agent label. `null` when the server cannot resolve the agent row. status (string, required) Current lifecycle state: `"idle"`, `"running"`, `"waiting"`, or `"failed"`. created_at (integer, required) Unix timestamp (seconds) when the session was created. runner_id (string or null) Mutable runner binding for this session. `null` until the client binds a registered runner with `PATCH /v1/sessions/{id}`. external_session_id (string or null) Runtime-native session id this conversation wraps (e.g. Claude Code's session uuid for `omnigent claude` sessions). Populated by the wrapper bridge from the underlying runtime. `null` for regular AP-only conversations. Generic across runtimes — at most one external session per conversation. model_override (string or null) Per-session LLM model override, e.g. `"claude-opus-4-7"`. `null` means no override is active and the bound agent's spec model applies. Persisted on `conversations.model_override`; set via `PATCH /v1/sessions/{id}` (also the path the REPL's `/model` command uses) so the web picker and the TUI stay in sync. cost_control_mode_override (string or null) Per-session cost-control switch: `"on"` activates the spec's configured cost-control mode, `"off"` disables cost control for this session, `null` defers to the spec default. Persisted on `conversations.cost_control_mode_override`; set at create or via `PATCH /v1/sessions/{id}` (the web "Cost Optimized" toggle) and read by the cost-control advisor pipeline at turn start. git_branch (string or null) Git branch checked out in the session's worktree, e.g. `"feature/login"`. Set only when the session was created with a server-created git worktree (the `git` block of create); `null` otherwise. A non-null value drives the "delete local branch" cleanup option on session delete. See `designs/SESSION_GIT_WORKTREE.md`. items (array, default `[]`) Committed conversation items in chronological order: user messages, assistant messages, function calls, function-call outputs, reasoning. Each item shape matches the `GET /v1/conversations/{id}/items` schema. pending_elicitations (array, default `[]`) Outstanding `response.elicitation_request` event payloads at snapshot build time. Replayed by the client as ApprovalCard blocks on cold load, since the live SSE stream has no replay buffer. pending_inputs (array, default `[]`) Un-consumed web-composer user messages on native-terminal (claude-native / codex-native) sessions at snapshot build time, each `{pending_id, content}`. Native sessions don't persist a web message at POST time (the transcript forwarder is the single writer), so the Omnigent server holds these in-memory and replays them here — the client re-hydrates the optimistic "queued message" bubble so it survives navigation / an SSE rebind. Drained when the message round-trips back (the matching `session.input.consumed` carries `cleared_pending_id`). Empty for non-native sessions, which already carry the message in `items`. todos (array, default `[]`) Current Claude Code todo list for `omnigent claude` sessions. Each item: `{content: string, status: "pending"|"in_progress"|"completed", activeForm: string}` where `activeForm` is the gerund form of the current activity (e.g. `"Running tests"`). Sourced from the server's in-memory todo cache (updated by `external_session_todos` events). Empty for non-claude-native sessions or before the first turn creates todos. terminal_pending (boolean, default `false`) `true` while the runner is auto-creating the terminal for a terminal-first session (claude-native / codex-native), so the Web UI shows a spinner on the Terminal pill instead of a silent greyed-out button. Cleared to `false` once the terminal lands or auto-create fails; from then on the client relies purely on whether a terminal resource exists. Set by two paths: (1) directly by the Omnigent server at session creation for host-launched terminal-first sessions, so the spinner appears immediately before the runner even starts; (2) by the relay when the runner's `session.terminal_pending` events arrive, covering non-host-launched sessions. The in-memory cache is read at snapshot time so a client connecting mid-spin-up still sees the spinner. ### Create Session Two request shapes are accepted. #### Create From Uploaded Agent Bundle ``` POST /v1/sessions Content-Type: multipart/form-data metadata: JSON string part, e.g. {"title": "debug auth flow"} bundle: agent .tar.gz file part ``` Request parts: metadata (JSON string, required) Session metadata. Shape matches `SessionCreateMetadata`: `{title?: string | null, labels?: object, reasoning_effort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | null, workspace?: string | null, terminal_launch_args?: string[] | null}`. `terminal_launch_args` carries pass-through CLI args for a native terminal wrapper, e.g. `["--permission-mode", "bypassPermissions"]` (same field as the JSON create path below). Unknown fields fail with 400. bundle (file, required) Agent tarball. The server validates it exactly like `POST /api/agents`: extract the bundle, load `config.yaml`, and require a spec `name`. 201 Created: ```json {"session_id": "conv_abc123"} ``` The server stores the bundle, then creates the `conversations` row and the session-scoped `agents` row in one database transaction. The new agent row has `agents.kind` set to `'session'`, and `conversations.agent_id` points at that agent. If the database agent write fails, the conversation row rolls back. If multipart or bundle parsing fails, no database row is written. 400 Bad Request - invalid metadata or invalid agent bundle 409 Conflict - agent write violates a uniqueness constraint 422 Unprocessable Entity - required multipart part is missing #### Create From Existing Agent ``` POST /v1/sessions Content-Type: application/json { "agent_id": "ag_abc123", "initial_items": [], "title": "debug auth flow", "labels": {"env": "test"}, "host_id": "host_abc123", "workspace": "/Users/alice/myrepo", "git": {"branch_name": "feature/login", "base_branch": "main"}, "terminal_launch_args": ["--permission-mode", "bypassPermissions"] } ``` This preserves the existing sessions API contract for clients that already uploaded or registered an agent. The response is the full `SessionResponse` shape with `id` set to the new conversation id. host_type (string, "external" | "managed", default "external") How the session's host is obtained. `"external"` (the default, and the pre-existing behavior): the session runs on caller-managed compute — a host registered via `omnigent host` (pass `host_id`) or a caller-managed runner (no `host_id`). `"managed"`: the SERVER provisions a sandbox host from its `sandbox:` config (see `omnigent/server/managed_hosts.py`), starts `omnigent host` inside it, binds the session to it, and launches the runner there. With `"managed"`, `host_id` and `workspace` must NOT be set (422) — the server chooses both. Provisioning runs in the BACKGROUND: the create returns immediately with `host_id` / `workspace` null, and they appear on `GET /v1/sessions/{id}` once the sandbox host registers. A message POSTed before the launch settles waits for it (and reports the launch failure as 503 if it failed) instead of failing with "no runner bound". 400 when the server has no `sandbox:` config or the configured provider lacks managed support. Deleting a managed session terminates its sandbox (best-effort), including a session deleted mid-provision. host_id (string or null) Host to launch the runner on. When set, `workspace` is required and validated against the agent's `os_env.cwd` boundary on that host (see `designs/SESSION_WORKSPACE_SELECTION.md`). workspace (string or null) Absolute path on the host. Required when `host_id` is set. When `git` is also set, this is the source repository directory; the created worktree becomes the stored workspace. git (object or null) Optional git worktree options. When present (requires `host_id`), the server creates a worktree for a new branch on the host and starts the runner in it. Shape: `{branch_name: string, base_branch?: string | null}`. `branch_name` is validated against git ref-format rules. See `designs/SESSION_GIT_WORKTREE.md`. terminal_launch_args (array of strings or null) Optional pass-through CLI args for a native terminal wrapper (claude / codex), e.g. `["--permission-mode", "bypassPermissions"]` (the web UI's permission-mode selector). Set at create time so the runner has them on the session row before it auto-launches the terminal. The flat-list shape is the security boundary — no key for a caller to smuggle launch wiring (bridge dir, Omnigent URL, auth), which stay runner-owned. Bounds (count / length) are validated server-side; a malformed list returns 400. `null` for non-native sessions. Settable later via `PATCH /v1/sessions/{id}` (last-write-wins). See `designs/NATIVE_RUNNER_SERVER_LAUNCH.md`. 400 Bad Request - invalid terminal_launch_args (count / length bounds) 404 Not Found - no agent with that id 422 Unprocessable Entity - invalid request body ### List Sessions ``` GET /v1/sessions 200 OK — paginated list whose `data` entries match `SessionResponse` minus `items` and snapshot-only fields. ``` Supports cursor pagination and filters such as `search_query` and `include_archived`. The `kind` filter scopes which conversation kinds are listed: `default` (the default) returns only top-level user-initiated sessions, `sub_agent` returns only sub-agent child sessions, and `any` returns both. `any` powers the new-session agent picker's discovery of agents that are only bound to sub-agent sessions. When liveness is wired, each list item includes two orthogonal signals (matching `GET /health` for the same session id and the `WS /v1/sessions/updates` list item fields): runner_online (boolean) Strict runner liveness — `true` iff a runner tunnel is currently registered. The sole reachability signal (a dead runner on a live host reads `false` here, not `true`). host_online (boolean | null) Whether the session's host tunnel is live (status online and fresh within the host liveness TTL). `null` when the session has no host binding. Used to distinguish "runner asleep, host alive — send a message to relaunch" from "host offline — reconnect/fork"; never folded into reachability. ### Get Session (Snapshot) ``` GET /v1/sessions/{session_id}[?include_items=true&include_liveness=true&refresh_state=false] 200 OK — body matches the `SessionResponse` shape above. 404 Not Found — no session with that id ``` Returns the current snapshot: identity, lifecycle status, all committed items, and any queued (unconsumed) inputs. Combined with the live stream, this is the reconnect contract — see "Reconnect Contract" below. include_items (query param, boolean, default `true`) When `false`, the committed-items read is skipped and `items` is `[]` (an empty list, not an absent field). For callers that hydrate the transcript through the paginated `GET /sessions/{id}/items` endpoint (the web chat surface), the snapshot's copy is redundant and the items read is the most expensive step of the snapshot build. include_liveness (query param, boolean, default `true`) When `false`, the runner/host liveness lookup is skipped and `runner_online`/`host_online` stay unset. For callers that source liveness from the `/health` poll and the live stream (the web chat surface), the snapshot's copy is redundant. refresh_state (query param, boolean, default `false`) When `true`, runner-derived snapshot overlays (for example skills and Codex-native model options) are refreshed from the bound runner instead of served from AP-process memory. Browser reload/bind requests use this so a page refresh pierces stale capability caches after a server-side bug fix. When runner liveness is wired (and not skipped via `include_liveness=false`), the snapshot includes: runner_online (boolean) Whether the session's bound runner/host is reachable. This is session-scoped (authorized by access to the session), and matches `GET /health?session_id=...` for the same id. ### Delete Session ``` DELETE /v1/sessions/{session_id}[?delete_branch=true] 200 OK — {"id": "conv_abc123", "deleted": true} 404 Not Found — no session with that id 403 Forbidden — caller is not the session owner ``` Requires owner-level access. Tears down runner-side resources, session files, and the conversation row. delete_branch (query param, boolean, default `false`) Opt-in git cleanup. When `true` and the session has a server-created worktree (`git_branch` set), the host removes the worktree directory and deletes its branch (`git worktree remove --force` then `git branch -D`). Ignored for sessions with no worktree. Best-effort: a cleanup failure does not block the delete. See `designs/SESSION_GIT_WORKTREE.md`. ### Bind Session Runner ``` PATCH /v1/sessions/{session_id} Content-Type: application/json { "runner_id": "runner_abc123", "title": "debug auth flow", "labels": {"env": "test"}, "reasoning_effort": "high", "model_override": "claude-opus-4-7", "collaboration_mode": "plan", "external_session_id": "a1b2c3d4-1234-5678-9abc-def012345678" } ``` Request body: runner_id (string, optional) Runner id that is already registered with the server's tunnel registry. Empty strings fail with 400. title (string or null, optional) Session title update. `null` leaves the title unchanged. labels (object, optional) Labels to merge into the existing session labels. reasoning_effort (string or null, optional) Per-session reasoning-effort hint. Accepted metadata values are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. Provider-specific support is validated when a turn executes. Clear values follow the existing sessions API semantics. model_override (string or null, optional) Per-session LLM model identifier the workflow should use on subsequent turns instead of the agent spec's `llm.model`, e.g. `"claude-opus-4-7"`. The server does not enumerate valid models — the executor validates at turn start. Clear aliases `"default"`, `"off"`, and `"reset"` remove the override (matching the REPL `/model` command); empty / whitespace-only strings fail with 400 rather than silently clearing. Leaves the column unchanged when omitted or `null`. collaboration_mode (string, optional) Codex-native collaboration-mode string. `"plan"` enters Plan mode; `"default"` returns to Default mode. Only valid for sessions whose wrapper label is `codex-native-ui`. Explicit toggles are forwarded to the live runner as `plan_mode_change` before the label is persisted; if no live runner or loaded Codex bridge can apply the change, the request fails and the stored mode is left unchanged. cost_control_mode_override (string or null, optional) Per-session cost-control switch: `"on"` activates the spec's configured cost-control mode, `"off"` disables cost control for this session. Explicit `null` clears the override back to the spec default; omitting the field leaves the stored value unchanged (`"off"` is a real value here, so field presence — not a clear alias — is the clear signal). Any other value fails with 400 `invalid_input`. external_session_id (string, optional) Runtime-native session id this conversation wraps (e.g. Claude Code's session uuid). Idempotent on same-value writes; the server rejects attempts to overwrite an already-set different value with 400 `invalid_input`. Wrapper bridges should write the value once when they first observe it from the underlying runtime. 200 OK - body matches the `SessionResponse` shape above, with `runner_id` set to the newly bound value when `runner_id` was present. 400 Bad Request - runner is not currently registered; `collaboration_mode` is used on a non-Codex-native session; or `external_session_id` would overwrite a different existing value 404 Not Found - no session with that id This is the mutable affinity primitive for Alpha. The same endpoint serves create-bind, resume-bind, and recover-bind: the client starts a runner, waits for registration, then PATCHes the session to the new runner id. The write replaces any previous value in `conversations.runner_id`; no history table is maintained. ### Codex-specific APIs Codex-native session routes, including the Codex Goal subresource, are documented in [codex-API.md](codex-API.md). ### Post Event ``` POST /v1/sessions/{session_id}/events Content-Type: application/json { "type": "message", "data": { "role": "user", "content": [{"type": "input_text", "text": "What about hotels?"}] } } Request body matches `SessionEventInput`: type (string, required) Event/input discriminator. Recognized values: - "message" — a user message - "function_call_output" — a client-side tool result - "function_call" — a queued tool call (rare; mostly emitted by the runtime, not clients) - "reasoning" — a queued reasoning item - "tool_result" — alias surfaced by some clients; see `ITEM_TYPE_TO_DATA_CLS` for the canonical set - "interrupt" — preempt the running loop (see below) - "compact" — explicit context compaction. Forwarded to the bound runner; for claude-native sessions the runner injects `/compact` into the terminal (Claude Code compacts its own context) and the Omnigent server skips its own compaction. For in-process harnesses the runner 204 no-ops and the Omnigent server runs the compaction itself (summarises history, persists a `compaction` item). Payload: `{}`. - "stop_session" — terminate the live session without deleting the conversation (owner-only; requires LEVEL_OWNER). Forwarded harness-agnostically to the bound runner, which hard-kills the external process for harnesses that have one (claude-native kills its tmux pane) and 204s for in-process harnesses; for a host-launched session the host's runner subprocess is stopped too, so its tunnel drops and `runner_online` flips false. Stop is non-sticky: no persistent marker is written, so the next message auto-relaunches a runner on a live host (there is no separate "resume" event). The conversation transcript is preserved. Returns `{queued: false}`. - "external_conversation_item" — internal terminal-observed item envelope; appends/broadcasts without starting a duplicate task - "external_output_text_delta" — internal terminal-observed assistant text delta; publishes a transient `response.output_text.delta` SSE event without persisting an item or starting a task. Payload: `{delta: string}`. The final completed message still arrives through `external_conversation_item`. - "external_session_status" — internal terminal-observed status edge; publishes a `session.status` event with data `{status: "running" | "waiting" | "idle" | "failed"}` - "external_session_usage" — internal terminal-observed token-usage update; persists `context_tokens` / `context_window` on conversation labels and publishes a `session.usage` event. Payload: at least one of `{context_tokens: int, context_window: int}` (native harnesses may also send cumulative `cumulative_cost_usd` / `cumulative_input_tokens` / `cumulative_output_tokens` / `model`). The published `session.usage` event additionally carries `total_cost_usd` (cumulative session spend, USD) when the session is priced; it is omitted when unpriced. The same value is seeded on the session snapshot as `total_cost_usd` (`null` when unpriced). - "external_reasoning_effort_change" — internal terminal-observed thinking-level update from a native forwarder. Persists `reasoning_effort` and publishes a `session.reasoning_effort` event. Payload: `{reasoning_effort: string | null}`; `null` clears to the model default. - "external_codex_collaboration_mode_change" — internal Codex app-server collaboration-mode update. Persists the mode kind as label `omnigent.codex_native.collaboration_mode`. Payload: `{mode: "default" | "plan"}`. - "external_compaction_status" — internal terminal-observed compaction edge from the claude-native forwarder (Claude Code's `PreCompact` and post-compaction `SessionStart source=compact` hooks). Republishes as `response.compaction.in_progress` / `.completed` / `.failed` so the web UI brackets Claude's own terminal compaction with its spinner. Payload: `{status: "in_progress" | "completed" | "failed"}`. - "external_session_todos" — internal terminal-observed todo-list update from the claude-native forwarder. Caches the list in memory (used by the snapshot `todos` field) and publishes a `session.todos` SSE event. Payload: `{todos: [{content: str, status: "pending"|"in_progress"|"completed", activeForm: string}]}`. Malformed items are silently dropped before caching/broadcasting. The route validates `type` against the conversation entity's item discriminator map plus the documented control/internal event types. Unknown values fail loud with 400 — they are NOT silently enqueued. data (object, required) Type-specific payload. For `"message"`, `{role, content: [...]}`. For `"interrupt"`, typically `{}`. The route also validates `data` against the item-type's Pydantic data class for non-interrupt types (400 on schema mismatch). 202 Accepted {"queued": true} # regular queued item events {"queued": false} # "interrupt" and status/control bypasses {"queued": false, "item_id": "item_..."} # "external_conversation_item" {"queued": true, "pending_id": "pending_..."} # native-terminal "message" (see below) 400 Bad Request — unknown `type`, or `data` fails the per-type schema 404 Not Found — no session with that id 422 Unprocessable Entity — request body fails Pydantic validation ``` **Native-terminal `message` events return `pending_id`.** On claude-native / codex-native sessions a web-composer `message` is NOT persisted at POST time (the transcript forwarder is the single writer); the server records it in an in-memory pending-inputs index and returns its `pending_id`. The id is what makes the bubble durable across a rebind: it (a) re-hydrates from the snapshot's `pending_inputs` (the replayed bubble carries the id) and (b) is dropped by id when the matching `session.input.consumed` arrives carrying `cleared_pending_id`. A client *may* adopt the id onto its live optimistic bubble for id-based dedupe; the first-party web client deliberately does NOT (it keeps a client temp id for React-key stability and relies on a stable key + FIFO matching), so adoption is optional, not required. **Interrupt is dual-path on purpose.** Posting `{"type": "interrupt"}` is exposed as an event for API uniformity, but it does NOT enter the queue — the route invokes the loop's `cancel_loop` directly so the interrupt can preempt items already queued in front of it. On every user-triggered cancel the server emits BOTH `response.incomplete` (reason `"user_interrupt"`, from the runtime) AND `session.interrupted` (from the route). Co-emitting the Responses-style event lets off-the-shelf parsers close cleanly while the session-scoped event carries the cancel intent for session-aware clients. The internal terminal-observed envelopes also bypass the queue: `external_conversation_item` appends/broadcasts an already-observed item and returns its stored `item_id`, while `external_output_text_delta` publishes a transient `response.output_text.delta` event without persisting. Its `data` is `{delta: string, message_id?: string, index?: integer, final?: boolean}`: `delta` is required; the optional `message_id` / `index` / `final` are set only for terminal-observed live streaming (claude-native), where they let a client scope an in-flight buffer to one assistant message, order its chunks, and detect the last one (the authoritative final text still arrives separately via `external_conversation_item`). They are omitted for ordinary task streaming, so the published event's wire shape is unchanged when absent. `external_session_status` publishes a `session.status` event without creating a task or item. Runner-hosted native sub-agent sessions also mirror that status into the parent stream as `session.child_session.updated` when the child is registered for fan-out. New user-facing event types should default to the queue. ### Resolve Elicitation (URL-based) ``` POST /v1/sessions/{session_id}/elicitations/{elicitation_id}/resolve Content-Type: application/json {"action": "accept"} # or "decline" / "cancel"; optional "content": {...} 202 Accepted {"queued": false} 404 Not Found — no session with that id 422 Unprocessable Entity — body fails Pydantic validation (e.g. a bad `action` value) ``` Delivers a human approval verdict for an outstanding elicitation (one published as a `response.elicitation_request` SSE event) to a dedicated, owner-gated URL. The `elicitation_id` rides in the URL path; the body is the MCP `ElicitationResult` — `action` plus optional form `content`. Requires LEVEL_EDIT on the session. This is the URL-based counterpart to posting an `approval` event to `POST .../events`: both converge on the same server-side resolver (set the parked Future, publish `response.elicitation_resolved` to clear the pending-elicitation badge, and forward the verdict to the bound runner). Routing the verdict through this resource-scoped URL keeps human approval on a dedicated path rather than an in-band session event — which is what policy ASK gates rely on, so the verdict cannot be conflated with a generic session event. Any value other than `action: "accept"` denies. ### Fork Session ``` POST /v1/sessions/{source_id}/fork Content-Type: application/json { "title": "Exploring alternative approach", // optional "up_to_response_id": "resp_abc123" // optional } Request body matches `SessionForkRequest`: title (string | null, optional) Title for the forked session. When null or omitted, the server derives "Fork of ". up_to_response_id (string | null, optional) Truncation point for the copied history ("fork from this response"). When set, only items up to and including the last item of that response are copied; items after it are dropped from the fork. A truncated fork into a native target rebuilds its transcript from the truncated items instead of resuming the source's full native transcript. When null or omitted, the full history is copied. 201 Created — body matches `SessionResponse` (status "idle", items are the deep-copied items from the source session). 400 Bad Request — source session is a sub-agent session, has no agent binding, or up_to_response_id names no response in the source session 404 Not Found — no session with that source_id, or the source's agent row is missing ``` Creates a new session by deep-copying every item from the source session. The server also clones the source's agent (new agent ID, same bundle and config) so the fork can be reconfigured independently. The forked session is **not** bound to a runner — clients must `PATCH /v1/sessions/{id}` with `runner_id` before posting events, the same way they bind a runner after resuming an existing session. The response is a full `SessionResponse` snapshot of the fork — same shape as `GET /v1/sessions/{id}` — with status `"idle"` and all copied items in chronological order. Clients must bind a runner before opening the fork's SSE stream or posting events. ### Stream Session ``` GET /v1/sessions/{session_id}/stream 200 OK Content-Type: text/event-stream event: session.status data: {"type":"session.status","data":{"status":"running"}} event: session.input.consumed data: {"type":"session.input.consumed","data":{"queued_item_id":"...","type":"message", ...}} event: response.output_item.added data: {"type":"response.output_item.added","item":{...}} event: response.output_text.delta data: {"type":"response.output_text.delta","delta":"Hello"} ... event: response.completed data: {"type":"response.completed","response":{...}} event: session.status data: {"type":"session.status","data":{"status":"idle"}} data: [DONE] 404 Not Found — no session with that id ``` **Live tail only.** No `starting_after` parameter, no replay of past events, no sequence numbers exposed on the wire. Reconnecting clients reconcile via the snapshot endpoint — see "Reconnect Contract". The stream stays open until the client disconnects or the conversation is closed; events flow in publish order. Multiple subscribers to the same session receive the same events. The stream terminates with `data: [DONE]\n\n`. ### Stream Events **Single source of truth: [`openapi.json`](../../openapi.json)** at the repo root. Every SSE event the server emits to clients is modeled as a typed Pydantic class in `omnigent/server/schemas.py`; the discriminated union `ServerStreamEvent` ties them together, and `scripts/dump_openapi.py` materializes that union into the OpenAPI 3.2 spec under `components.schemas.ServerStreamEvent` (referenced from the SSE routes via the `itemSchema` keyword). The tables below are derived / illustrative — for canonical wire shapes, field defaults, and schema constraints consult `openapi.json` or the per-event Pydantic class docstrings. Two families coexist on the stream: **Session-scoped (`session.*`)** — wrap the underlying response stream and surface queue/interrupt semantics. | Event | Pydantic class | Wire shape (illustrative) | |---|---|---| | `session.status` | `SessionStatusEvent` | `{type, conversation_id, status: "running" \| "waiting" \| "idle" \| "failed"}` | | `session.reasoning_effort` | `SessionReasoningEffortEvent` | `{type, conversation_id, reasoning_effort: string \| null}` | | `session.collaboration_mode` | `SessionCollaborationModeEvent` | `{type, conversation_id, mode: string}` | | `session.input.consumed` | `SessionInputConsumedEvent` | `{type, data: {queued_item_id, type, data, position}}` (nested envelope) | | `session.interrupted` | `SessionInterruptedEvent` | `{type, data: {requested_at, queued_item_id?: null}}` (nested envelope) | | `session.created` | `SessionCreatedEvent` | `{type, conversation_id: , child_conversation_id, agent_id, ...}` — emitted on the PARENT session's stream when a sub-agent is spawned. | > **Note on `session.input.consumed`:** This event name and payload > may change in a future revision; clients should isolate the > constant rather than hardcoding it. Importing > `SessionInputConsumedEvent` from > `omnigent.server.schemas` is the supported pattern. **Response (`response.*`)** — emitted by the executor (and the AP streaming routes for the lifecycle events). The session stream multiplexes them; the per-response stream emits them directly. | Event | Pydantic class | |---|---| | `response.created` | `CreatedEvent` | | `response.queued` | `QueuedEvent` | | `response.in_progress` | `InProgressEvent` | | `response.completed` | `CompletedEvent` | | `response.failed` | `FailedEvent` | | `response.incomplete` | `IncompleteEvent` | | `response.cancelled` | `CancelledEvent` | | `response.output_text.delta` | `OutputTextDeltaEvent` | | `response.output_item.done` | `OutputItemDoneEvent` | | `response.output_file.done` | `OutputFileDoneEvent` | | `response.reasoning.started` | `ReasoningStartedEvent` | | `response.reasoning_text.delta` | `ReasoningTextDeltaEvent` | | `response.reasoning_summary_text.delta` | `ReasoningSummaryTextDeltaEvent` | | `response.retry` | `RetryEvent` | | `response.error` | `ErrorEvent` | | `response.compaction.in_progress` | `CompactionInProgressEvent` | | `response.client_task.cancel` | `ClientTaskCancelEvent` | | `response.heartbeat` | `HeartbeatEvent` | | `response.elicitation_request` | `ElicitationRequestEvent` | See the per-class docstring in `omnigent/server/schemas.py` for the canonical wire shape and field types of each `response.*` event. When a child/sub-agent elicitation is mirrored into an ancestor stream, `response.elicitation_request.params.target_session_id` is the child session whose resolve endpoint must receive the verdict. ### Reconnect Contract The session API has **no replay machinery**. To reconnect to a session after a disconnect: 1. **Open the SSE stream** (`GET /v1/sessions/{id}/stream`). The stream is registered eagerly at session create and survives across turns, so subscribing is safe at any point in the session lifecycle — including before the first turn starts. 2. **GET the snapshot** (`GET /v1/sessions/{id}`). 3. **Dedupe items between the snapshot and the stream by item id.** Items in `snapshot.items` that also appear in stream events are the same item — drop the duplicate. Server-issued IDs are stable. Opening the stream BEFORE the snapshot is still recommended so no events fire in the gap, but because the stream queue stays alive across turns, transient races during reconnect are bounded to the in-flight HTTP roundtrip rather than the full turn duration. Clients should rely on `session.input.consumed` events and item-id dedupe against the snapshot to reconcile accepted inputs. ### Sessions Typical Flow ``` 1. Client creates a session with an uploaded agent bundle -> POST /v1/sessions multipart {metadata, bundle} -> 201 {"session_id": "conv_abc123"} 2. Client binds the registered runner -> PATCH /v1/sessions/{id} {"runner_id":"runner_abc123"} -> 200 session snapshot with runner_id set 3. Client opens the SSE stream -> GET /v1/sessions/{id}/stream -> events flow: session.status -> session.input.consumed -> response.* deltas -> response.completed -> session.status (idle) 4. Client sends a user message -> POST /v1/sessions/{id}/events {type:"message", data:{...}} -> 202 {"queued": true} -> existing SSE stream emits events for the turn 5. Client loses connection (laptop close, network flap) -> agent continues processing server-side 6. Client reconnects: a. Open new GET /v1/sessions/{id}/stream (subscribe FIRST) b. GET /v1/sessions/{id} (snapshot SECOND) c. Dedupe items between snapshot and stream by item id 7. Client interrupts a running agent -> POST /v1/sessions/{id}/events {type:"interrupt"} -> 202 {"queued": false} -> stream emits both response.incomplete (reason:user_interrupt) and session.interrupted, then session.status:idle ``` --- ## Not Yet - `PUT /api/agents/{id}` — update agent (new bundle) - Stream resumption on GET (sequence_number-based reconnection) - Authentication - Rate limiting - User filtering on conversation list (by metadata, dedicated user field, or auth identity) - Conversation update metadata (beyond title) - Search across conversations (full-text search over message content) - Multi-user identity (`user` field on requests/items to attribute messages in shared conversations) - `logprobs` on `output_text` content blocks (optional, used with `top_logprobs`) - `metadata` on sessions / tasks — caller-attached key-value pairs (max 16 keys, keys ≤64 chars, values ≤512 chars). - `purpose` field on file uploads (e.g. `"input"`, `"fine-tune"`) - Audio input (`input_audio` content type) - Additional output item types: `image_generation_call`, `web_search_call`, `file_search_call`, `code_interpreter_call`, `mcp_tool_call`, `computer_call`, `local_shell_call`, `apply_patch_call`, `compaction`