Openheim API Specification

Version: 0.1.0 Base URL: http://{host}:{port} (default 0.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

  1. Overview
  2. REST API
  3. WebSocket — Multiplexed Connection
  4. TypeScript Interfaces
  5. Sequence Diagrams
  6. 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_key is never included. mcp_servers entries omit args entirely (command lines often carry tokens) and show only the keys of env and headers, each with the value "<redacted>". Provider api_base and MCP url values are shown without any user:password@ part, query string, or fragment. memory has no db_path, and data_dir is never included. kind is 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_task is always registered. remember/search_memory/edit_memory/forget are registered with the rag feature (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 _meta field 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 sessionId is 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. cwd is 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/sessions is simpler. Use session/list over 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:

  1. Client sends session/load with the sessionId and the current cwd.
  2. Agent replays the full conversation history as a series of session/update notifications (user_message_chunk and agent_message_chunk).
  3. Agent responds with an empty result to signal that history replay is complete.
  4. The session is now active — subsequent session/prompt requests use the loaded sessionId.

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_call notifications (status: "in_progress"), and tool results arrive as tool_call_update notifications with status: "completed" or status: "failed" — the correct status is preserved in the stored history via the is_error field 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_chunk notifications will arrive in sequence. The content.text values 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_chunk notifications. Check content._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 watch again replaces the previous watch. Watching is only needed for live fs_event notifications — 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 Close frame.
  • If the server shuts down (SIGINT), it drains gracefully.
  • If the connection drops, the client should reconnect and re-initialize (call initialize, then either session/new for a fresh session or session/load to 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