Openheim API Specification
Version: 0.1.0 Base URL:
http://{host}:{port}(default0.0.0.0:1217) Protocol: REST + multiplexed WebSocket over ACP (Agent Client Protocol)
This document describes every HTTP and WebSocket endpoint that openheim server exposes.
Table of Contents
- Overview
- REST API
- WebSocket — Multiplexed Connection
- TypeScript Interfaces
- Sequence Diagrams
- Error Handling
1. Overview
Openheim exposes a multiplexed WebSocket at /ws, a second bare-ACP WebSocket at /acp, and a small set of REST endpoints at /api/*.
/ws carries two logical channels over one physical connection:
| Channel | Purpose |
|---|---|
| agent | ACP (Agent Client Protocol) — initialize, create sessions, send prompts, receive streamed LLM responses + tool call updates |
| fs | Filesystem operations — CRUD, directory listing, live file watching |
All /ws messages are JSON envelopes tagged with a channel field so the client can route them without opening multiple connections. /acp (see §3.4) carries only the agent channel's content, unwrapped — for generic ACP clients that don't know about the envelope or the fs channel.
2. REST API
2.1 GET /api/config
Returns the public server configuration: an explicit allow-list of fields, so anything not shown below (API keys, local paths, fields added to the config later) is never included.
Response 200:
{
"default_provider": "openai",
"max_iterations": 10,
"work_dir": "/home/user/project",
"allow_shell": false,
"tui": { "theme_color": "green" },
"providers": {
"openai": {
"kind": "openai",
"api_base": "https://api.openai.com/v1",
"default_model": "gpt-4",
"models": ["gpt-4", "gpt-4-turbo", "gpt-3.5-turbo"],
"env_var": "OPENAI_API_KEY",
"timeout_secs": 120,
"max_tokens": 4096,
"context_window": 128000
},
"anthropic": {
"kind": "anthropic",
"api_base": "https://api.anthropic.com/v1",
"default_model": "claude-3-5-sonnet-20241022",
"models": ["claude-3-5-sonnet-20241022", "claude-3-opus-20240229"],
"env_var": "ANTHROPIC_API_KEY"
}
},
"mcp_servers": {
"github": {
"command": "npx",
"env": { "GITHUB_TOKEN": "<redacted>" },
"headers": {}
}
},
"memory": {
"embedding_provider": "openai",
"embedding_model": "text-embedding-3-small",
"top_k": 5
}
}
Note:
api_keyis never included.mcp_serversentries omitargsentirely (command lines often carry tokens) and show only the keys ofenvandheaders, each with the value"<redacted>". Providerapi_baseand MCPurlvalues are shown without anyuser:password@part, query string, or fragment.memoryhas nodb_path, anddata_diris never included.kindis always present (resolved from the provider name when the config doesn't set it). Unset optional fields are omitted.
2.2 GET /api/models
Returns available models grouped by provider.
Response 200:
{
"default_provider": "openai",
"providers": {
"openai": {
"default_model": "gpt-4",
"models": ["gpt-4", "gpt-4-turbo", "gpt-3.5-turbo"]
},
"anthropic": {
"default_model": "claude-3-5-sonnet-20241022",
"models": ["claude-3-5-sonnet-20241022", "claude-3-opus-20240229", "claude-3-haiku-20240307"]
}
}
}
2.3 GET /api/skills
Returns a sorted list of installed skill names (loaded from ~/.openheim/skills/*.md).
Response 200:
["debugging", "rust", "vue"]
2.4 GET /api/tools
Returns all registered tool definitions (built-in + MCP). Each tool follows the OpenAI function-calling schema.
Response 200:
[
{
"type": "function",
"function": {
"name": "execute_command",
"description": "Execute a shell command (e.g., ls, pwd, echo). Use this for listing directories and running system commands. Commands are killed after 120 seconds and output is truncated at 64 KiB per stream.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The shell command to execute"
}
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a text file. Returns up to 100 KB; a longer file ends with a note giving the offset to call again with. Use offset and limit to read a specific range of lines.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to read"
},
"offset": {
"type": "integer",
"minimum": 1,
"description": "Line number to start reading from (1-based). Defaults to 1."
},
"limit": {
"type": "integer",
"minimum": 1,
"description": "Maximum number of lines to read. Defaults to as many as fit in 100 KB."
}
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write content to a file at the specified path. Creates the file if it doesn't exist.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to write"
},
"content": {
"type": "string",
"description": "The content to write to the file"
}
},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "Edit a file by replacing an exact occurrence of old_string with new_string, without rewriting the whole file. old_string must match the file's existing content exactly (including whitespace/indentation) and must be unique in the file unless replace_all is set. Use write_file instead to create a new file or replace a file's entire contents.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The path to the file to edit"
},
"old_string": {
"type": "string",
"description": "The exact text to replace. Must be unique in the file unless replace_all is set."
},
"new_string": {
"type": "string",
"description": "The text to replace old_string with"
},
"replace_all": {
"type": "boolean",
"description": "Replace every occurrence of old_string instead of requiring exactly one. Defaults to false."
}
},
"required": ["path", "old_string", "new_string"]
}
}
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List the immediate contents of a directory (not recursive). Directories are suffixed with '/' and symlinks are shown as 'name -> target'. Defaults to the current directory if no path is given.",
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The directory to list. Defaults to the current directory if omitted."
}
}
}
}
},
{
"type": "function",
"function": {
"name": "search",
"description": "Search files under a path for lines matching a regex pattern (ripgrep-style). Respects .gitignore and skips hidden and binary files. Returns matches as 'path:line: content', capped at 200 results.",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "The regex pattern to search for"
},
"path": {
"type": "string",
"description": "The file or directory to search. Defaults to the current directory if omitted."
},
"case_insensitive": {
"type": "boolean",
"description": "Match case-insensitively. Defaults to false."
}
},
"required": ["pattern"]
}
}
},
{
"type": "function",
"function": {
"name": "web_fetch",
"description": "Fetch a web page or other text-like resource (HTML, plain text, JSON, XML) from a public http(s) URL and return its content as plain text. HTML is stripped of markup. Requests time out after 20 seconds, redirects are not followed automatically (the redirect target is reported instead), and content is truncated at 256 KiB. Only publicly-routable addresses can be fetched.",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The http:// or https:// URL to fetch"
}
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "delegate_task",
"description": "Delegate a self-contained task to a specialized subagent that runs independently with its own context, persona, and (optionally) its own model or restricted tool set. The subagent CANNOT see this conversation, so `task` must be a complete, standalone brief containing every detail it needs. Only its final answer is returned to you — its intermediate steps are not visible.\n\nPick a pre-configured subagent by `agent` name, OR define an ephemeral one inline by providing `system_prompt` (with optional `tools`, `model`, `provider`, `max_iterations`). Inline subagents exist only for this one call and are not saved. Exactly one of `agent` or `system_prompt` is required.\n\nAvailable subagents: ... (lists configured profiles by name and description; empty unless subagent profiles are configured — see subagents.md)",
"parameters": {
"type": "object",
"properties": {
"task": { "type": "string" },
"agent": { "type": "string", "description": "Name of a pre-configured subagent to delegate to. Mutually exclusive with `system_prompt`." },
"system_prompt": { "type": "string", "description": "System prompt for an ephemeral inline subagent. Mutually exclusive with `agent`." },
"tools": { "type": "array", "items": { "type": "string" }, "description": "Inline subagent only: restrict it to this set of tool names." },
"model": { "type": "string", "description": "Inline subagent only: run it on this model instead of yours." },
"provider": { "type": "string" },
"max_iterations": { "type": "integer" }
},
"required": ["task"]
}
}
},
{
"type": "function",
"function": {
"name": "remember",
"description": "Save a fact, preference, decision, or piece of context to long-term memory so it can be recalled in future sessions with `search_memory`. Use it when the user asks you to remember something, or states something clearly worth keeping. Write one self-contained note per call, in plain prose, including enough context to make sense on its own.",
"parameters": {
"type": "object",
"properties": {
"content": { "type": "string", "description": "The note to remember (max 4000 characters)" }
},
"required": ["content"]
}
}
},
{
"type": "function",
"function": {
"name": "search_memory",
"description": "Search notes previously saved with `remember`. Use it when the user refers to something from an earlier session, asks what you remember, or when a stored preference or decision would change your answer. Returns the most relevant notes with their id and date. Search is keyword-based by default, or semantic when an `embedding_provider` is configured — see configuration.md.",
"parameters": {
"type": "object",
"properties": {
"query": { "type": "string", "description": "What to recall" },
"top_k": { "type": "integer", "description": "Number of notes to return (default from config, max 20)", "minimum": 1, "maximum": 20 }
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_memory",
"description": "Replace the content of an existing note in long-term memory by its id (the `#N` shown by `search_memory` or returned by `remember`). Use it when a stored fact or preference has changed rather than `forget`-ing it and `remember`-ing a new one. Search first if you don't know the id.",
"parameters": {
"type": "object",
"properties": {
"id": { "type": "integer", "description": "Id of the memory to edit" },
"content": { "type": "string", "description": "The note's new content (max 4000 characters)" }
},
"required": ["id", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "forget",
"description": "Permanently delete a note from long-term memory by its id (the `#N` shown by `search_memory` or returned by `remember`). Use it when the user asks you to forget something or when a note is outdated and being replaced. Search first if you don't know the id.",
"parameters": {
"type": "object",
"properties": {
"id": { "type": "integer", "description": "Id of the memory to delete" }
},
"required": ["id"]
}
}
},
{
"type": "function",
"function": {
"name": "filesystem__read_file",
"description": "... (from MCP server)",
"parameters": { "..." : "..." }
}
}
]
delegate_taskis always registered.remember/search_memory/edit_memory/forgetare registered with theragfeature (on by default for the binary) — see configuration.md for the[memory]section. MCP tools are namespaced as{server_name}__{tool_name}(double underscore); both parts are sanitized so every provider accepts the name (see configuration.md's[mcp_servers]section).
2.5 GET /api/mcp-servers
Returns the connection status of all configured MCP servers.
Response 200:
[
{
"name": "filesystem",
"transport": "stdio",
"command": "npx",
"url": null,
"connected": true,
"tool_count": 3,
"error": null
},
{
"name": "remote-tools",
"transport": "http",
"command": null,
"url": "http://localhost:8080/mcp",
"connected": false,
"tool_count": 0,
"error": "connection refused"
}
]
2.6 GET /api/sessions
Returns a list of all persisted conversation sessions, sorted newest-first by updated_at. This is the REST equivalent of session/list over ACP.
Response 200:
[
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2025-05-10T14:22:15Z",
"updated_at": "2025-05-10T14:35:42Z",
"title": "Refactor the auth module",
"model": "gpt-4-turbo",
"provider": "openai",
"skills": ["rust", "debugging"],
"cwd": "/home/user/my-project",
"context_usage": {
"input_tokens": 1820,
"output_tokens": 240,
"cache_creation_tokens": 0,
"cache_read_tokens": 1024
}
},
{
"id": "661f9511-f3ac-52e5-b827-557766551111",
"created_at": "2025-05-09T09:00:00Z",
"updated_at": "2025-05-09T09:15:30Z",
"title": "Explain the project structure",
"model": null,
"provider": null,
"skills": [],
"cwd": null
}
]
| Field | Type | Description |
|---|---|---|
id |
string (UUID) |
Unique session identifier — use as sessionId in ACP calls |
created_at |
string (ISO 8601) |
When the session was created |
updated_at |
string (ISO 8601) |
When the session was last active |
title |
string | null |
Auto-generated from the first user message (up to 80 chars) |
model |
string | null |
Model used in this session |
provider |
string | null |
Provider used in this session |
skills |
string[] |
Skills loaded for this session |
cwd |
string | null |
Working directory — populated after the first prompt in the session |
context_usage |
Usage | undefined |
Snapshot of the most recent turn's context size (the last LLM call's token usage) — how full the context window is right now, not a running total. Omitted until the first turn completes. See Usage in §4 TypeScript Interfaces. |
Sessions are persisted to
~/.openheim/history/{uuid}.json(metadata) and~/.openheim/history/{uuid}.jsonl(messages, appended incrementally as the conversation grows) and survive server restarts.
2.7 GET /api/sessions/:id
Returns the full conversation for a session, including all messages.
Path parameter: :id — the UUID of the session.
Response 200:
{
"meta": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"created_at": "2025-05-10T14:22:15Z",
"updated_at": "2025-05-10T14:35:42Z",
"title": "Refactor the auth module",
"model": "gpt-4-turbo",
"provider": "openai",
"skills": ["rust"],
"cwd": "/home/user/my-project",
"context_usage": {
"input_tokens": 1820,
"output_tokens": 240,
"cache_creation_tokens": 0,
"cache_read_tokens": 1024
}
},
"messages": [
{
"role": "user",
"content": [
{ "type": "text", "text": "Refactor the auth module to use JWTs." }
]
},
{
"role": "assistant",
"content": [
{ "type": "thinking", "thinking": "The user wants JWTs...", "signature": "..." },
{ "type": "text", "text": "I'll help you refactor the auth module..." }
]
},
{
"role": "assistant",
"content": [
{
"type": "tool_use",
"id": "call_abc123",
"name": "read_file",
"arguments": "{\"path\": \"src/auth.rs\"}"
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"tool_call_id": "call_abc123",
"tool_name": "read_file",
"content": "use actix_web::...\n// file contents"
}
]
},
{
"role": "tool",
"content": [
{
"type": "tool_result",
"tool_call_id": "call_def456",
"tool_name": "read_file",
"content": "Error: permission denied: /etc/shadow",
"is_error": true
}
]
}
]
}
Message roles:
role |
Description |
|---|---|
"user" |
Message sent by the human |
"assistant" |
LLM response text, reasoning, and/or tool call requests |
"tool" |
Tool execution result fed back to the LLM |
"system" |
System prompt injected by the agent (skills, context) |
Content block types (content is always an array, in the order the model
produced it — an assistant turn commonly holds a leading thinking block
followed by text and/or tool_use blocks, and with interleaved thinking
more thinking blocks can sit between them; a tool message holds exactly
one tool_result block):
type |
Fields | Description |
|---|---|---|
"text" |
text: string |
Plain text (user, assistant, or system content) |
"thinking" |
thinking: string, signature?: string | null |
Extended-thinking output. signature must be replayed unmodified — it's how the provider verifies the block wasn't tampered with. |
"redacted_thinking" |
data: string |
Thinking the provider returned encrypted (Anthropic). Nothing to display; kept so it can be sent back unchanged. |
"image" |
data: string (base64), mime_type: string |
User-supplied image, e.g. from an ACP client's image content block |
"tool_use" |
id: string, name: string, arguments: string (JSON string), signature?: string |
A tool call the assistant requested. signature is an opaque provider token (Gemini's thought signature), present only when the provider sent one |
"tool_result" |
tool_call_id: string, tool_name: string, content: string, is_error?: boolean |
Result of executing a tool call. is_error omitted from JSON when false (absence means success); forwarded to Anthropic as is_error in the tool result block so the LLM receives accurate signal. |
Error 400 — if :id is not a valid UUID:
{ "error": "invalid session id" }
Error 404 — if the session does not exist:
{ "error": "session not found" }
3. WebSocket — Multiplexed Connection
Endpoint
WS ws://{host}:{port}/ws
3.1 Wire Format
Every message in both directions is a JSON envelope:
Client → Server (inbound):
{
"channel": "agent", // "agent" | "fs"
"data": { ... } // channel-specific payload
}
Server → Client (outbound):
{
"channel": "system", // "system" | "agent" | "fs"
"data": { ... }
}
On connection, the server immediately sends:
{
"channel": "fs",
"data": {
"type": "connected",
"message": "Connected to Openheim"
}
}
3.2 Agent Channel (ACP)
The agent channel uses the Agent Client Protocol (ACP), which is JSON-RPC 2.0 under the hood. Messages on the agent channel's data field are raw ACP JSON-RPC messages.
ACP JSON-RPC Wire Format
Request (Client → Agent):
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": { ... }
}
Response (Agent → Client):
{
"jsonrpc": "2.0",
"id": 1,
"result": { ... }
}
Notification (Agent → Client, no id):
{
"jsonrpc": "2.0",
"method": "session/update",
"params": { ... }
}
Error Response:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32002,
"message": "Resource not found",
"data": "session not found: abc123"
}
}
3.2.1 Initialize
Handshake to negotiate capabilities. Must be called before creating sessions.
Full WS message (Client → Server):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "0.1.0",
"clientCapabilities": {},
"clientInfo": {
"name": "openheim-ui",
"version": "1.0.0"
}
}
}
}
Full WS message (Server → Client):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "0.1.0",
"agentCapabilities": {
"loadSession": true,
"sessionCapabilities": {
"list": {}
}
},
"agentInfo": {
"name": "openheim",
"version": "0.1.0"
},
"_meta": {
"models": {
"default_provider": "openai",
"providers": {
"openai": {
"default_model": "gpt-4",
"models": ["gpt-4", "gpt-4-turbo", "gpt-3.5-turbo"]
}
}
},
"mcp_servers": [
{
"name": "filesystem",
"transport": "stdio",
"connected": true,
"tool_count": 3
}
],
"skills": ["debugging", "rust"],
"tools": [
{
"type": "function",
"function": {
"name": "execute_command",
"description": "Execute a shell command...",
"parameters": { "...": "..." }
}
}
]
}
}
}
}
agentCapabilities fields:
| Field | Type | Description |
|---|---|---|
loadSession |
true |
Agent supports session/load to resume past conversations |
sessionCapabilities.list |
{} |
Agent supports session/list to enumerate past conversations |
The
_metafield on the initialize response contains a snapshot of all available models, MCP servers, skills, and tools. The same data is available via the REST endpoints.
3.2.2 Create Session
Creates a new blank conversation session. Returns a sessionId used for all subsequent prompts. To resume an existing session instead, see §3.2.4 Load Session.
Full WS message (Client → Server):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 2,
"method": "session/new",
"params": {
"cwd": "/path/to/workspace",
"_meta": {
"model": "gpt-4-turbo",
"skills": ["rust", "debugging"]
}
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
cwd |
string |
No | Working directory for the session: tools resolve relative paths and run commands here when it's inside the agent's work_dir (otherwise they use work_dir). Also stored for session/list filtering. |
_meta.model |
string |
No | Model override (must match a model from a configured provider) |
_meta.skills |
string[] |
No | Skills to load for this session |
Full WS message (Server → Client):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 2,
"result": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000"
}
}
}
The
sessionIdis a UUID. Store it — it's required for all prompt requests.
3.2.3 List Sessions
Returns all persisted sessions known to the agent, optionally filtered by cwd. Requires the agent to have advertised sessionCapabilities.list in its initialize response (Openheim always does).
Full WS message (Client → Server):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 3,
"method": "session/list",
"params": {}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
cwd |
string |
No | If set, only return sessions whose stored cwd matches exactly |
cursor |
string |
No | Opaque pagination cursor from a previous response's nextCursor |
Full WS message (Server → Client):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 3,
"result": {
"sessions": [
{
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"cwd": "/home/user/my-project",
"title": "Refactor the auth module",
"updatedAt": "2025-05-10T14:35:42Z"
},
{
"sessionId": "661f9511-f3ac-52e5-b827-557766551111",
"cwd": "/",
"title": "Explain the project structure",
"updatedAt": "2025-05-09T09:15:30Z"
}
],
"nextCursor": null
}
}
}
| Field | Type | Description |
|---|---|---|
sessions[].sessionId |
string (UUID) |
Use as sessionId in session/load or session/prompt |
sessions[].cwd |
string |
Working directory ("/" if never recorded) |
sessions[].title |
string | null |
Auto-generated title from the first user message |
sessions[].updatedAt |
string | null |
ISO 8601 timestamp of last activity |
nextCursor |
string | null |
If present, pass as cursor in the next request to get the next page |
The sessions list is sorted newest-first.
cwdis populated from the working directory of the first prompt sent in the session; sessions that were listed but never prompted will show"/".
For most use-cases the REST endpoint
GET /api/sessionsis simpler. Usesession/listover ACP when you need filtering, pagination, or want to keep everything on a single connection.
3.2.4 Load Session
Resumes a previously persisted session. Requires loadSession: true in the agent's capabilities (Openheim always advertises this).
The flow is:
- Client sends
session/loadwith thesessionIdand the currentcwd. - Agent replays the full conversation history as a series of
session/updatenotifications (user_message_chunkandagent_message_chunk). - Agent responds with an empty result to signal that history replay is complete.
- The session is now active — subsequent
session/promptrequests use the loadedsessionId.
Full WS message (Client → Server):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 4,
"method": "session/load",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"cwd": "/home/user/my-project",
"mcpServers": []
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
sessionId |
string |
Yes | UUID of the session to resume |
cwd |
string |
Yes | Current working directory, used by the session's tools like session/new's cwd (unless the session is already live, which keeps its own) |
mcpServers |
array |
No | MCP server overrides for the loaded session (usually []) |
History replay notifications (Server → Client, before the response):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"sessionUpdate": "user_message_chunk",
"content": { "type": "text", "text": "Refactor the auth module to use JWTs." }
}
}
}
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "I'll help you refactor the auth module..." }
}
}
}
Tool calls from the original session are replayed: assistant tool-call requests arrive as
tool_callnotifications (status: "in_progress"), and tool results arrive astool_call_updatenotifications withstatus: "completed"orstatus: "failed"— the correct status is preserved in the stored history via theis_errorfield on the message.
Response (after all history has been replayed):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 4,
"result": null
}
}
Error — session not found:
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 4,
"error": {
"code": -32002,
"message": "Resource not found",
"data": "Conversation 550e8400-... not found at ..."
}
}
}
session/load also fails with session_busy when a session/prompt turn is already in flight for this session — see §3.2.5 Errors — instead of handing back a history snapshot that would never catch up with the in-flight turn.
3.2.5 Send Prompt
Send a user message to the agent within a session. The agent will stream back response chunks and tool call updates as session/update notifications.
Full WS message (Client → Server):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 3,
"method": "session/prompt",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"prompt": [
{
"type": "text",
"text": "List all Rust files in the src directory and explain the project structure."
}
]
}
}
}
| Field | Type | Required | Description |
|---|---|---|---|
sessionId |
string |
Yes | Session ID from session/new |
prompt |
ContentBlock[] |
Yes | Array of content blocks — text and image pass through; resource_link is converted to a text hint; audio and embedded resources are rejected |
Response (after all streaming is complete):
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 3,
"result": {
"stopReason": "end_turn"
}
}
}
stopReason value |
Description |
|---|---|
"end_turn" |
Agent completed successfully |
"tool_use" |
Agent stopped to request tool execution (shouldn't happen — openheim auto-executes tools) |
Errors specific to session/prompt and session/load:
Both requests can fail with one of two structured errors, in addition to the plain-string errors listed under Common JSON-RPC error codes (e.g. -32002 for a session that doesn't exist). Both carry a machine-readable data.kind so a client can offer a "busy, retry" UX instead of surfacing a generic failure.
session_busy — another turn is already in flight for this session, in this process (only one session/prompt runs per session at a time). Retry once it completes; unlike session_locked below, this is expected under normal concurrent use and not a hard failure.
{
"code": -32603,
"message": "Internal error",
"data": { "kind": "session_busy", "session_id": "550e8400-e29b-41d4-a716-446655440000" }
}
session_locked — session/prompt only. Another openheim process (a different pid/host sharing the same ~/.openheim/history/) holds the cross-process write lease for this session's history. Retry later, or treat as a hard conflict if pid/host don't correspond to a process you expect to release it soon.
{
"code": -32603,
"message": "Internal error",
"data": {
"kind": "session_locked",
"session_id": "550e8400-e29b-41d4-a716-446655440000",
"pid": 12345,
"host": "my-machine"
}
}
3.2.6 Streaming Updates (Server → Client)
While processing a prompt, the server sends notifications (JSON-RPC messages without an id field) via the agent channel. These arrive between the prompt request and its response.
All streaming notifications use method: "session/update" and contain a sessionUpdate discriminator.
Agent Message Chunk
Streamed text from the LLM. Accumulate these to build the full response.
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "Here is the project structure:\n\n"
}
}
}
}
Multiple
agent_message_chunknotifications will arrive in sequence. Thecontent.textvalues should be concatenated to form the full assistant response.
Tool Call Created
When the LLM requests a tool execution, a tool call is created with in_progress status.
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"sessionUpdate": "tool_call",
"toolCallId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"title": "execute_command",
"status": "in_progress",
"rawInput": {
"command": "find src -name '*.rs' -type f"
}
}
}
}
Tool Call Update (Completed)
When the tool finishes, a tool_call_update is sent with the result.
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"sessionUpdate": "tool_call_update",
"toolCallId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "completed",
"rawOutput": "src/main.rs\nsrc/lib.rs\nsrc/agent.rs\n..."
}
}
}
Tool Call Failed
If the tool execution errors:
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"sessionUpdate": "tool_call_update",
"toolCallId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "failed",
"rawOutput": "Unknown tool: nonexistent"
}
}
}
Thinking / Reasoning Chunks
For providers that support extended thinking (Anthropic, OpenAI o-series), reasoning tokens arrive as agent_message_chunk notifications with an extra content._meta.kind == "thinking" marker. They are otherwise identical to regular text chunks and should be rendered separately (e.g. collapsed or styled differently).
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"method": "session/update",
"params": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"sessionUpdate": "agent_message_chunk",
"content": {
"type": "text",
"text": "Let me think about this step by step...",
"_meta": { "kind": "thinking" }
}
}
}
}
Thinking chunks arrive interleaved with regular
agent_message_chunknotifications. Checkcontent._meta?.kind === "thinking"to distinguish them.
Summary of sessionUpdate Types
sessionUpdate |
Direction | Description |
|---|---|---|
agent_message_chunk |
Server → Client | Streamed LLM text chunk (also used for thinking — see content._meta.kind) |
user_message_chunk |
Server → Client | Echo of user message (not currently used) |
agent_thought_chunk |
Server → Client | Reserved — not currently used; thinking arrives via agent_message_chunk |
tool_call |
Server → Client | New tool call started (status: "in_progress") |
tool_call_update |
Server → Client | Tool call status/result update |
plan |
Server → Client | Agent execution plan (not currently used) |
Tool Call Status Lifecycle
pending → in_progress → completed
→ failed
3.3 Filesystem Channel
All filesystem operations are sent over the fs channel. The channel is sandboxed to the agent's configured work_dir (see Configuration) — the same boundary the agent's own read_file/write_file/edit_file/list_dir/search tools are held to. Relative paths resolve against work_dir; absolute paths must be within it. Symlinks are followed and canonicalized so they cannot escape the boundary.
No watch call is required before file operations — every request is validated against work_dir directly.
Request ids. Any request may include an id (any JSON value) next to action. Its reply, success or error, carries the same id, so a client with several requests in flight can match each reply to its request:
{ "channel": "fs", "data": { "action": "read", "path": "src/main.rs", "id": 7 } }
{ "channel": "fs", "data": { "type": "file_content", "path": "src/main.rs", "content": "…", "id": 7 } }
Messages that aren't a reply to a request (the connected greeting, fs_event from a watch, and the error for an unparseable payload) never carry an id. Requests without an id get replies without one, as before.
Ordering. A connection's fs requests are carried out one at a time, in the order sent, so their replies come in that order too (a write followed by a read of the same file reads what was written). They run apart from the agent channel: a slow fs request, such as a large recursive list, doesn't delay ACP messages, and an fs reply can arrive before or after agent frames sent at the same time.
3.3.1 Watch / Unwatch
Start watching a directory for live file change events. The directory must be within work_dir. Watching does not affect path validation for other operations.
Watch (Client → Server):
{
"channel": "fs",
"data": {
"action": "watch",
"path": "/path/to/workspace"
}
}
Watch Success (Server → Client):
{
"channel": "fs",
"data": {
"type": "watching",
"path": "/path/to/workspace"
}
}
Unwatch (Client → Server):
{
"channel": "fs",
"data": {
"action": "unwatch"
}
}
Unwatch Success (Server → Client):
{
"channel": "fs",
"data": {
"type": "unwatched"
}
}
You can only watch one directory at a time; calling
watchagain replaces the previous watch. Watching is only needed for livefs_eventnotifications — all other operations work without it.
3.3.2 List Directory
Request:
{
"channel": "fs",
"data": {
"action": "list",
"path": "src",
"recursive": false
}
}
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
path |
string |
Yes | — | Directory path (relative to work_dir or absolute within it) |
recursive |
boolean |
No | false |
If true, lists all descendants recursively |
Response:
{
"channel": "fs",
"data": {
"type": "file_list",
"path": "src",
"entries": [
{
"path": "/path/to/workspace/src/main.rs",
"name": "main.rs",
"is_dir": false,
"size": 2048,
"modified": 1700000000
},
{
"path": "/path/to/workspace/src/core",
"name": "core",
"is_dir": true,
"size": null,
"modified": 1700000050
}
]
}
}
FileEntry fields:
| Field | Type | Description |
|---|---|---|
path |
string |
Full path to the entry |
name |
string |
Filename or directory name |
is_dir |
boolean |
true if directory |
size |
number | null |
File size in bytes (null for directories) |
modified |
number | null |
Last modified as Unix timestamp in seconds |
3.3.3 Read File
Request:
{
"channel": "fs",
"data": {
"action": "read",
"path": "src/main.rs"
}
}
Response:
{
"channel": "fs",
"data": {
"type": "file_content",
"path": "src/main.rs",
"content": "fn main() {\n println!(\"Hello\");\n}"
}
}
3.3.4 Write File
Creates the file if it doesn't exist. Creates parent directories automatically if needed.
Request:
{
"channel": "fs",
"data": {
"action": "write",
"path": "src/new_module.rs",
"content": "pub fn hello() -> &str {\n \"world\"\n}"
}
}
Response:
{
"channel": "fs",
"data": {
"type": "write_success",
"path": "src/new_module.rs"
}
}
3.3.5 Create Directory
Creates the directory and all parent directories (equivalent to mkdir -p).
Request:
{
"channel": "fs",
"data": {
"action": "mkdir",
"path": "src/utils/helpers"
}
}
Response:
{
"channel": "fs",
"data": {
"type": "mkdir_success",
"path": "src/utils/helpers"
}
}
3.3.6 Delete
Deletes a file or directory (recursively if directory). A symlink is deleted as a link; what it points to is left alone. The work directory itself can't be deleted: a path that names it ("", ., sub/.., its absolute path) gets an error reply. Both rules also apply to from and to of a rename, so renaming a symlink moves the link.
Request:
{
"channel": "fs",
"data": {
"action": "delete",
"path": "src/old_file.rs"
}
}
Response:
{
"channel": "fs",
"data": {
"type": "delete_success",
"path": "src/old_file.rs"
}
}
3.3.7 Rename / Move
Request:
{
"channel": "fs",
"data": {
"action": "rename",
"from": "src/old_name.rs",
"to": "src/new_name.rs"
}
}
Response:
{
"channel": "fs",
"data": {
"type": "rename_success",
"from": "src/old_name.rs",
"to": "src/new_name.rs"
}
}
3.3.8 Filesystem Events (Server → Client)
When a directory is being watched, file change events are pushed automatically:
{
"channel": "fs",
"data": {
"type": "fs_event",
"eventKind": "Create(File)",
"paths": [
"/path/to/workspace/src/new_file.rs"
]
}
}
eventKind examples |
Description |
|---|---|
"Create(File)" |
New file created |
"Create(Folder)" |
New directory created |
"Modify(File)" |
File modified |
"Remove(File)" |
File deleted |
"Remove(Folder)" |
Directory deleted |
"Any" |
Other/combined events |
Events are polled at ~1 second intervals. Multiple rapid changes may be batched into a single event.
3.3.9 Filesystem Error Response
Any fs operation can return an error:
{
"channel": "fs",
"data": {
"type": "error",
"message": "Tool execution error: path '../secrets' is outside the work directory '/path/to/work_dir'"
}
}
Common error messages:
| Message | Cause |
|---|---|
"Tool execution error: path '...' is outside the work directory '...'" |
Path escapes work_dir (absolute path outside it, or .. traversal) |
"Tool execution error: path '...' is a dangling symlink ..." |
Path is a symlink whose target does not exist (rejected so writes can't create the target outside the sandbox) |
"Invalid directory: ..." |
Watch path isn't a directory |
"Failed to read: ..." |
File read error (permissions, missing, etc.) |
"Failed to write: ..." |
File write error |
"Failed to create dirs: ..." |
Parent directory creation failed |
"Failed to mkdir: ..." |
Directory creation failed |
"Failed to delete file: ..." / "Failed to delete dir: ..." |
Deletion error |
"Failed to rename: ..." |
Rename/move error |
3.4 Bare ACP Endpoint (/acp)
/ws is openheim's rich, product-specific endpoint: an envelope multiplexing ACP with the filesystem sidecar above. /acp is a second, minimal endpoint for clients that only speak plain ACP and know nothing about that envelope or the fs channel.
Each WebSocket text frame on /acp is exactly one JSON-RPC 2.0 message — no {"channel": "agent", "data": {...}} wrapper. Send the same initialize / session/new / session/prompt / etc. requests documented in §3.2, just unwrapped:
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": 1}}
There is no filesystem sidecar on this endpoint — use /ws if you need it. Everything else (session lifecycle, streaming session/update notifications, tool calls, permission requests) behaves identically to the agent channel on /ws.
4. TypeScript Interfaces
// ─── REST API ───────────────────────────────────────────────
interface ProviderConfig {
kind: "openai" | "anthropic" | "gemini" | "openai_compatible";
api_base: string; // credentials, query string and fragment removed
default_model: string;
models: string[];
env_var?: string;
timeout_secs?: number;
max_tokens?: number;
context_window?: number;
// api_key is NEVER included in responses
}
interface AppConfig {
default_provider: string;
max_iterations: number;
work_dir: string;
allow_shell: boolean;
tui: { theme_color?: string };
providers: Record<string, ProviderConfig>;
mcp_servers: Record<string, McpServerConfig>;
memory?: { embedding_provider?: string; embedding_model?: string; top_k: number };
}
interface McpServerConfig {
command?: string;
url?: string; // credentials, query string and fragment removed
env: Record<string, "<redacted>">; // keys only
headers: Record<string, "<redacted>">; // keys only
tool_timeout_secs?: number;
// args is NEVER included in responses
}
interface ProviderModels {
default_model: string;
models: string[];
}
interface ModelsInfo {
default_provider: string;
providers: Record<string, ProviderModels>;
}
// GET /api/skills
type SkillsResponse = string[];
interface FunctionDefinition {
name: string;
description: string;
parameters: Record<string, unknown>; // JSON Schema object
}
interface Tool {
type: "function";
function: FunctionDefinition;
}
// GET /api/tools
type ToolsResponse = Tool[];
interface McpServerStatus {
name: string;
transport: "stdio" | "http" | "unknown";
command?: string | null;
url?: string | null;
connected: boolean;
tool_count: number;
error?: string | null;
}
// GET /api/mcp-servers
type McpServersResponse = McpServerStatus[];
// ─── Session History ─────────────────────────────────────────
interface ConversationMeta {
id: string; // UUID
created_at: string; // ISO 8601
updated_at: string; // ISO 8601
title?: string | null;
model?: string | null;
provider?: string | null;
skills: string[];
cwd?: string | null; // populated after first prompt in the session
context_usage?: Usage; // omitted until the first turn completes
}
// Usage of the most recent LLM call — how full the context window is right
// now, not a running total across the whole session.
interface Usage {
input_tokens: number;
output_tokens: number;
cache_creation_tokens: number; // tokens written to a prompt cache
cache_read_tokens: number; // tokens served from a prompt cache
}
interface Message {
role: "user" | "assistant" | "tool" | "system";
content: ContentBlock[];
}
type ContentBlock =
| { type: "text"; text: string }
| { type: "thinking"; thinking: string; signature?: string | null }
| { type: "redacted_thinking"; data: string }
| { type: "image"; data: string; mime_type: string } // data is base64-encoded
| { type: "tool_use"; id: string; name: string; arguments: string; signature?: string } // arguments is a JSON string
| {
type: "tool_result";
tool_call_id: string;
tool_name: string;
content: string;
is_error?: boolean; // omitted when false
};
interface Conversation {
meta: ConversationMeta;
messages: Message[];
}
// GET /api/sessions
type SessionsResponse = ConversationMeta[];
// GET /api/sessions/:id
type SessionResponse = Conversation;
// ─── WebSocket Envelopes ────────────────────────────────────
// Inbound (Client → Server)
interface ClientEnvelope {
channel: "agent" | "fs";
data: AgentData | FsRequest;
}
// Outbound (Server → Client)
interface ServerEnvelope {
channel: "system" | "agent" | "fs";
data: SystemEvent | AgentData | FsResponse;
}
// ─── System Events ──────────────────────────────────────────
interface SystemEvent {
type: "connected" | "error";
message: string;
}
// ─── ACP JSON-RPC ───────────────────────────────────────────
interface JsonRpcRequest {
jsonrpc: "2.0";
id: number;
method: string;
params: Record<string, unknown>;
}
interface JsonRpcResponse {
jsonrpc: "2.0";
id: number;
result?: unknown;
error?: JsonRpcError;
}
interface JsonRpcNotification {
jsonrpc: "2.0";
method: string;
params: Record<string, unknown>;
}
interface JsonRpcError {
code: number;
message: string;
data?: string;
}
// ─── ACP Types ──────────────────────────────────────────────
interface InitializeRequest {
protocolVersion: string;
clientCapabilities: Record<string, unknown>;
clientInfo?: {
name: string;
version: string;
title?: string;
};
_meta?: Record<string, unknown>;
}
interface AgentCapabilities {
loadSession: boolean; // true — agent supports session/load
sessionCapabilities: {
list?: {}; // present — agent supports session/list
};
promptCapabilities?: Record<string, unknown>;
mcpCapabilities?: Record<string, unknown>;
}
interface InitializeResponse {
protocolVersion: string;
agentCapabilities: AgentCapabilities;
agentInfo?: {
name: string;
version: string;
title?: string;
};
_meta?: {
models?: ModelsInfo;
mcp_servers?: McpServerStatus[];
skills?: string[];
tools?: Tool[];
};
}
interface NewSessionRequest {
cwd?: string;
_meta?: {
model?: string;
skills?: string[];
};
}
interface NewSessionResponse {
sessionId: string; // UUID
}
// ─── ACP: session/list ──────────────────────────────────────
interface ListSessionsRequest {
cwd?: string; // filter by exact working directory path
cursor?: string; // opaque pagination cursor
}
interface SessionInfo {
sessionId: string; // UUID — use in session/load or session/prompt
cwd: string; // "/" if not yet recorded
title?: string | null;
updatedAt?: string | null; // ISO 8601
}
interface ListSessionsResponse {
sessions: SessionInfo[];
nextCursor?: string | null; // pass as cursor in next request for pagination
}
// ─── ACP: session/load ──────────────────────────────────────
interface LoadSessionRequest {
sessionId: string; // UUID of the session to resume
cwd: string; // working directory to use for subsequent prompts
mcpServers?: unknown[]; // MCP server overrides (usually [])
}
// Response is null / empty result — history is delivered via session/update notifications
interface ContentBlock {
type: "text" | "image" | "audio" | "resource_link" | "resource";
text?: string; // for type: "text"
data?: string; // for type: "image" | "audio"
mimeType?: string; // for type: "image" | "audio"
}
interface PromptRequest {
sessionId: string;
prompt: ContentBlock[];
}
interface PromptResponse {
stopReason: "end_turn" | "tool_use";
}
// ─── Session Update Notifications ───────────────────────────
interface AgentMessageChunk {
sessionUpdate: "agent_message_chunk";
content: ContentBlock;
}
interface ToolCallNotification {
sessionUpdate: "tool_call";
toolCallId: string;
title: string;
kind?: "read" | "edit" | "delete" | "move" | "search" | "execute" | "think" | "fetch" | "switch_mode" | "other";
status: "pending" | "in_progress" | "completed" | "failed";
rawInput?: Record<string, unknown>;
rawOutput?: Record<string, unknown>;
}
interface ToolCallUpdateNotification {
sessionUpdate: "tool_call_update";
toolCallId: string;
status?: "pending" | "in_progress" | "completed" | "failed";
title?: string;
rawInput?: Record<string, unknown>;
rawOutput?: Record<string, unknown>;
}
interface SessionNotification {
sessionId: string;
sessionUpdate: AgentMessageChunk | ToolCallNotification | ToolCallUpdateNotification;
// ... plus other variant fields inline
}
// ─── Filesystem Types ───────────────────────────────────────
interface FileEntry {
path: string;
name: string;
is_dir: boolean;
size?: number | null;
modified?: number | null; // Unix timestamp (seconds)
}
type FsRequest =
| { action: "watch"; path: string }
| { action: "unwatch" }
| { action: "list"; path: string; recursive?: boolean }
| { action: "read"; path: string }
| { action: "write"; path: string; content: string }
| { action: "mkdir"; path: string }
| { action: "delete"; path: string }
| { action: "rename"; from: string; to: string };
type FsResponse =
| { type: "connected"; message: string }
| { type: "watching"; path: string }
| { type: "unwatched" }
| { type: "file_list"; path: string; entries: FileEntry[] }
| { type: "file_content"; path: string; content: string }
| { type: "write_success"; path: string }
| { type: "mkdir_success"; path: string }
| { type: "delete_success"; path: string }
| { type: "rename_success"; from: string; to: string }
| { type: "fs_event"; eventKind: string; paths: string[] }
| { type: "error"; message: string };
5. Sequence Diagrams
5.1 Typical Chat Session
Frontend Openheim Server LLM
│ │ │
│ WS connect to /ws │ │
│─────────────────────────────────►│ │
│ { channel:"fs", type:"connected"}│ │
│◄─────────────────────────────────│ │
│ │ │
│ { channel:"agent", │ │
│ method:"initialize", ... } │ │
│─────────────────────────────────►│ │
│ { channel:"agent", │ │
│ result: { ... _meta } } │ │
│◄─────────────────────────────────│ │
│ │ │
│ { channel:"agent", │ │
│ method:"session/new", ... } │ │
│─────────────────────────────────►│ │
│ { channel:"agent", │ │
│ result:{sessionId:"uuid"} } │ │
│◄─────────────────────────────────│ │
│ │ │
│ { channel:"agent", │ │
│ method:"session/prompt", │ │
│ params:{sessionId,prompt} } │ │
│─────────────────────────────────►│ │
│ │ POST /chat/completions │
│ │──────────────────────────►│
│ │ │
│ session/update: │ (streaming tokens) │
│ agent_message_chunk │◄──────────────────────────│
│◄─────────────────────────────────│ │
│ session/update: │ │
│ agent_message_chunk │ (tool call requested) │
│◄─────────────────────────────────│◄──────────────────────────│
│ │ │
│ session/update: │ │
│ tool_call (in_progress) │ │
│◄─────────────────────────────────│ │
│ │ (executes tool locally) │
│ session/update: │ │
│ tool_call_update (completed) │ │
│◄─────────────────────────────────│ │
│ │ POST (feed tool result) │
│ │──────────────────────────►│
│ session/update: │ (more streaming tokens) │
│ agent_message_chunk │◄──────────────────────────│
│◄─────────────────────────────────│ │
│ │ │
│ prompt response: │ │
│ { stopReason:"end_turn" } │ │
│◄─────────────────────────────────│ │
5.2 Resuming a Past Session
Frontend Openheim Server
│ │
│ GET /api/sessions │
│─────────────────────────────────►│
│ [ { id, title, updatedAt }, ... ]│
│◄─────────────────────────────────│
│ │
│ (user picks a session from list) │
│ │
│ { channel:"agent", │
│ method:"session/load", │
│ params:{sessionId, cwd} } │
│─────────────────────────────────►│
│ │
│ session/update: │
│ user_message_chunk (msg 1) │
│◄─────────────────────────────────│
│ session/update: │
│ agent_message_chunk (reply 1) │
│◄─────────────────────────────────│
│ session/update: ... (all msgs) │
│◄─────────────────────────────────│
│ │
│ { result: null } (load done) │
│◄─────────────────────────────────│
│ │
│ { channel:"agent", │
│ method:"session/prompt", │
│ params:{sessionId, prompt} } │
│─────────────────────────────────►│
│ (continues conversation ...) │
5.3 Filesystem Operations
Frontend Openheim Server
│ │
│ { channel:"fs", action:"watch", │
│ path:"/workspace" } │
│─────────────────────────────────►│
│ { channel:"fs", type:"watching",│
│ path:"/workspace" } │
│◄─────────────────────────────────│
│ │
│ { channel:"fs", action:"list", │
│ path:"src" } │
│─────────────────────────────────►│
│ { channel:"fs", type:"file_list",│
│ entries:[...] } │
│◄─────────────────────────────────│
│ │
│ { channel:"fs", action:"read", │
│ path:"src/main.rs" } │
│─────────────────────────────────►│
│ { channel:"fs", │
│ type:"file_content", │
│ content:"fn main() {...}" } │
│◄─────────────────────────────────│
│ │
│ ... (user edits file externally)│
│ { channel:"fs", │
│ type:"fs_event", │
│ eventKind:"Modify(File)", │
│ paths:["/workspace/src/main.rs"]}|
│◄─────────────────────────────────│
6. Error Handling
REST Errors
All REST endpoints return 200 with JSON body on success. If the server is misconfigured (e.g., missing config file), the connection will be refused entirely.
WebSocket Errors
Invalid payload:
{
"channel": "fs",
"data": {
"type": "error",
"message": "Invalid payload: missing field `action`"
}
}
ACP errors are returned as JSON-RPC error responses:
{
"channel": "agent",
"data": {
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32602,
"message": "Invalid params",
"data": "Invalid argument: invalid session id format"
}
}
}
Common JSON-RPC error codes:
| Code | Meaning |
|---|---|
-32700 |
Parse error (invalid JSON) |
-32600 |
Invalid request |
-32601 |
Method not found (a method openheim doesn't implement) |
-32602 |
Invalid params (malformed session id, unsupported prompt content, unknown model, mode, or config option) |
-32603 |
Internal error (anything else, plus the structured session_busy / session_locked errors) |
-32002 |
Resource not found (the session or conversation doesn't exist) |
Except for session_busy / session_locked, data is a human-readable string describing the problem.
Connection Lifecycle
- The WebSocket stays open until the client disconnects or sends a
Closeframe. - If the server shuts down (SIGINT), it drains gracefully.
- If the connection drops, the client should reconnect and re-initialize (call
initialize, then eithersession/newfor a fresh session orsession/loadto resume an existing one). - Conversation history is persisted to disk — sessions survive WebSocket reconnections and server restarts.
Appendix: Quick Reference
REST Endpoints Summary
| Method | Path | Response Type | Description |
|---|---|---|---|
GET |
/api/config |
AppConfig (sanitized) |
Server configuration |
GET |
/api/models |
ModelsInfo |
Available models by provider |
GET |
/api/skills |
string[] |
Installed skill names |
GET |
/api/tools |
Tool[] |
All tool definitions |
GET |
/api/mcp-servers |
McpServerStatus[] |
MCP server statuses |
GET |
/api/sessions |
ConversationMeta[] |
All persisted sessions, newest-first |
GET |
/api/sessions/:id |
Conversation |
Full conversation including all messages |
GET |
/ws |
WebSocket upgrade | Multiplexed agent + fs channels (§3) |
GET |
/acp |
WebSocket upgrade | Bare ACP JSON-RPC, no envelope, no fs (§3.4) |
WebSocket Message Types Summary
Inbound (Client → Server):
| Channel | Action/Method | Description |
|---|---|---|
agent |
initialize |
Handshake — negotiate capabilities |
agent |
session/new |
Create a new blank session |
agent |
session/list |
List persisted sessions (ACP native) |
agent |
session/load |
Resume a persisted session + replay history |
agent |
session/prompt |
Send a message in the active session |
fs |
watch |
Start watching directory |
fs |
unwatch |
Stop watching |
fs |
list |
List directory contents |
fs |
read |
Read file |
fs |
write |
Write file |
fs |
mkdir |
Create directory |
fs |
delete |
Delete file/directory |
fs |
rename |
Rename/move file/directory |
Outbound (Server → Client):
| Channel | Type/Method | Description |
|---|---|---|
fs |
connected |
Initial connection greeting |
fs |
watching |
Watch confirmed |
fs |
unwatched |
Unwatch confirmed |
fs |
file_list |
Directory listing result |
fs |
file_content |
File read result |
fs |
write_success |
File write confirmed |
fs |
mkdir_success |
Directory creation confirmed |
fs |
delete_success |
Deletion confirmed |
fs |
rename_success |
Rename confirmed |
fs |
fs_event |
Live file change event |
fs |
error |
Filesystem error |
agent |
JSON-RPC response | Responses to initialize / session/* requests |
agent |
session/update |
Streaming: message chunks, tool calls, history replay |