Serve protocol
lich serve is the desktop/TUI-grade control surface for a headless Agent. Ossuary (and later other rich clients) talk to it over loopback WebSocket JSON-RPC. It is not the messaging gateway (lich gateway / POST /message): gateway adapters deliver chat replies to Telegram, Discord, Twitch, or an HTTP webhook; serve owns sessions, live AgentEvent streaming, and abort.
This page documents the shared contract in src/serve/protocol.ts, the loopback WebSocket transport in src/serve/server.ts, and prompt/event handling in src/serve/prompts.ts. Start it with lich serve (or bun src/cli.ts serve).
Role in the system
flowchart LR
Ossuary["apps/ossuary<br/>Electron + React"]
Serve["lich serve<br/>WS JSON-RPC"]
Agent["Agent + events"]
Gateway["lich gateway<br/>messaging adapters"]
Ossuary -->|"session / prompt / event"| Serve
Serve --> Agent
Gateway -.->|"not this contract"| Agent- Serve — one process, loopback only, token-gated; server-owned conversation history; pushes
AgentEventas notifications. - Gateway — multi-platform messaging bus; final text out, no Dockview-grade event stream. Do not build ossuary panels on the webhook.
JSON-RPC shape
Standard JSON-RPC 2.0 envelopes (jsonrpc: "2.0", id on requests/responses). Requests use the locked method names below. Server → client notifications use method event (no id).
Methods (locked names)
| Method | Params | Result |
|---|---|---|
health | {} | { status: "ok", version } (LICH_VERSION) |
session.create | { label?, source } | { session_id } |
session.list | {} | { sessions: [{ id, mtime_ms }, ...] } |
session.clear | { session_id } | { session_id } |
session.resume | { id, source? } | { session_id, resumed_id, message_count } |
prompt.submit | { session_id, text } | reply, usage, session_path, stopped_reason, … |
prompt.abort | { session_id } | { session_id, aborted } |
health and session.list have no param fields. Typed clients still send params: {}, because ServeRequest requires params. JSON-RPC 2.0 also allows omitting params; serve handlers accept that omission the same as {}.
Clients must not invent method names outside the locked set above.
session.create opens one SessionHandle and an empty in-memory history bag, and eagerly creates the (empty) .jsonl transcript so session.list sees the new id immediately — before any prompt appends to it. session.clear resets that bag's in-memory history (handle stays; the on-disk transcript is untouched). session.list / session.resume reuse resolve_session_path / transcript listing semantics from CLI --resume and TUI /sessions. session.resume seeds history from disk and opens a fresh SessionHandle for later prompt.submit — like CLI --resume, each resume forks a new transcript; it does not re-bind the original. The fork is written from the filtered bag history (every trailing user already dropped by read_session_messages), not a raw byte copy, and the handle is marked seeded so a later prompt.submitrecorder.seed appends only the new turn. A later latest resume (or the fork id after a restart) reloads that filtered history. A transcript that is deleted between resolve and read resumes as not_found, never as an empty history. SessionResumeResult.resumed_id reports which transcript was resolved, even for latest / prefix resumes.
In-memory bags are capped (LRU, default 32 via max_session_bags): the oldest is evicted first, and ServeServer.stop() drops all of them — after draining in-flight RPC handlers, so a mid-I/O session.create / session.resume cannot resurrect a bag after shutdown. Eviction is log-only — there is no client notification; a client operating on an evicted id learns about it from the next session.clear or prompt.submit failing with not_found. Evicted transcripts stay on disk and can be resumed again.
prompt.submit runs the server Agent with that bag's history and AgentRunOptions.session (one JSONL file per serve session). Runs of one session are serialized; different sessions run concurrently. Each run's events arrive through its own on_event and are tagged with that run's session_id. While a run is in flight, prompt.abort aborts it via AbortSignal. The submit result mirrors AgentRunResult (reply, usage, session_path, turns_used, stopped_reason).
Notifications
| Method | Params |
|---|---|
event | { session_id, event } where event is an AgentEvent (same discriminated union the TUI consumes) |
Events are 1:1 with the in-process emitter — turn_start, llm_*, tool_call_*, final, error, and the rest — so a Chat pane can mirror TUI semantics without embedding Agent in Electron. Notifications are pushed on the same WebSocket that issued prompt.submit while the call is still in flight.
Transport (loopback)
- Bind
127.0.0.1only (or other loopback aliases); port0picks an ephemeral port. - On listen, emit one stdout JSON line:
{"port":…,"token":…}for Electron to parse. - Upgrade checks, in order:
Hostmust be a loopback name (127.0.0.1,localhost,::1, with optional port) — otherwise 403 (DNS-rebinding defense).- Token via query
?token=or headerx-lich-token(preferred for clients that should not put secrets in URLs / logs) — mismatch or missing → 401.
- Origin allowlisting is deferred until Electron’s page origin policy is decided; do not assume
file:///app://behavior here. - Frame size capped at ~1 MiB (
maxPayload). healthreturns{ status: "ok", version }(LICH_VERSIONfromsrc/version.ts).- Frames are queued per session within a connection: frames whose
params.session_idmatch are handled in arrival order and get in-order replies, while other sessions, and frames that name no session (such ashealth,session.listorsession.create), do not wait behind them. Replies from different sessions can therefore interleave; match them by JSON-RPCid.prompt.abortis dispatched immediately so it can cancel an in-flightprompt.submiton the same socket instead of waiting behind that run. A submit already accepted on that socket, but still waiting behind another frame, is armed when the frame arrives so the same abort cancels it too. - Pass
agentoragent_config(same shape as CLI /create_agent_with_plugins) soprompt.*is available; without an agent, those methods return an application error. - CLI:
lich serve [--host 127.0.0.1] [--port 0]builds the Agent from the same config resolution as TUI/chat and passes it asagent_configwithsession_dirfrom the agent config (${work_dir}/.lich/sessions). The library default whensession_diris omitted remains<cwd>/.lich/sessions.