CLI architecture and lifecycle
System shape, session and thread lifecycle, claims/leases/retries, and the raw-to-normalized chunk pipeline.
This page is the single source of truth for how the CLI bridges the Convex backend to a local agent runtime. It replaces several older internals pages (architecture-overview, lifecycle, claims-heartbeats-retries-failures, message-and-chunk-data-model, protocols-and-payloads, claude-code-runtime).
System shape
Web UI <-> Convex backend <-> Convoy CLI <-> local runtime (e.g. Claude Code)- The web app owns user interaction and writes durable thread state to Convex.
- Convex owns queue, claim, session, and message state.
- The CLI owns local execution and filesystem access.
- The runtime generates the streamed output. Claude Code uses an Agent SDK
query. Codex uses its app-server protocol by default, with an optional
codex exec --jsontransport. OpenCode uses a long-livedopencode serveprocess driven through its SDK. Cursor uses a freshcursor-agentprint-mode process for each turn.
Main code areas
Backend (Convex):
apps/web/convex/cliThreads.ts—listPendingThreads,claimThread,heartbeatClaim,releaseClaimapps/web/convex/cliMessages.ts— Thread run lifecycle (startRunResponse,appendRunContent,complete,fail,retry)apps/web/convex/cliSessions.ts— CLI session registration and heartbeatsapps/web/convex/http.ts—/api/agent/register,/api/agent/heartbeat,/api/agent/disconnectapps/web/convex/cli/lib/runtimes/registry.ts— runtime registry and theCliNormalizedEventdispatchapps/web/convex/cli/lib/runtimes/claudeCode/normalizeChunk.ts— Claude Code raw-chunk → normalized-event mappingapps/web/convex/cli/lib/chunks/—normalizedEvents.ts,store.tsapps/web/convex/lib/contracts/validators.ts—cliNormalizedEventValidatoris the authoritative event union
CLI:
apps/cli/src/agent/app/runner.ts— orchestrates registration, heartbeat, pending-thread subscription, shutdownapps/cli/src/agent/app/thread-processor.ts— FIFO queue of claimed threads; owns the claim lease loop and run lifecycleapps/cli/src/agent/runtimes/registry.ts— CLI-side runtime registryapps/cli/src/agent/runtimes/claudeCode/runner.ts— spawns Claude Code, yields JSON stream chunksapps/cli/src/agent/runtimes/codex/app-server-runner.tsruns the default Codex transport;codex/runner.tsruns the optional exec transportapps/cli/src/agent/runtimes/opencode/runner.tsdrives the OpenCode SDKapps/cli/src/agent/runtimes/cursor/runner.tsexecutes Cursor print mode
Frontend:
apps/web/lib/cli-runtime-ui/— icons, labels, selector metadata per runtime slugapps/web/lib/cli-stream-ui/— composer/recovery layer that turns normalized events into AI SDKUIMessageparts
Session lifecycle
- CLI resolves a saved profile (
--profile,CONVOY_PROFILE, cwd-path match, ordefault_profile). - CLI registers a session via HTTP
POST /api/agent/register(API-key auth, conflicts resolvable withforce=true). - CLI sends
POST /api/agent/heartbeatevery 30s. Sessions expire after 60s of silence. - CLI subscribes to
cliThreads.listPendingThreadsvia realtime query. - On SIGINT/SIGTERM,
POST /api/agent/disconnectruns and the process exits.
Thread lifecycle
pending → claimed → streaming → claimed → … → (idle, claim released)- A user message arrives. Convex creates only the user message and flags the
thread pending (
hasPendingUserMessages). - CLI claims the thread via
cliThreads.claimThread, which recordsclaimedBySessionId,claimedAt, andclaimLeaseExpiresAt. - CLI fetches unprocessed user messages and calls
cliMessages.startRunResponse, which creates one assistant envelope and setsactiveAssistantMessageId. - CLI spawns the selected runtime in the profile's working directory.
- Each JSON line from the runtime is sent to
cliMessages.appendRunContent. Raw chunks are stored incliMessageChunksand thenormalizedEventis computed at insert time (see below). - On success,
cliMessages.completefinalizes the assistant message. On failure,cliMessages.failmarks it retryable. - CLI loops: if new user messages arrived during streaming, it processes
them in the next run under the same claim. When the queue drains, the
claim is released and
activeAssistantMessageIdis cleared.
Claims, heartbeats, and retries
Claims are thread-level, lease-based, and belong to exactly one session at a
time. The message-level claimed status exists only for legacy rows and is
not used to drive active work.
Two separate health signals:
- Session heartbeat — the CLI process is online (
cliSessionstable). - Claim heartbeat — this specific thread lease is still held
(
cliThreads.heartbeatClaim).
A session can be online while a particular claim goes stale. Stale claims are recoverable: the backend can release them, and the thread becomes re-claimable by another session without corrupting the message timeline.
Invariants any code change in this area must preserve:
retryon a failed assistant run must requeue the user messages in the run and remove the failed assistant message. Leaving the failed message on top of the new attempt produces a confusing duplicate state.- Any path that ends or abandons streaming must clear the thread's stale
activeAssistantMessageId. - Queue-state checks (e.g. "does this thread have remaining unprocessed user
messages?") must use a shared indexed helper with
.first(), not repeated.collect().length. cliMessageStatusValidatormust remain backward-compatible with legacy stored statuses until a migration explicitly removes them.
Message and chunk data model
Three tables, three responsibilities:
cliThreads— thread-level queue and claim state. Source of truth for "who is processing this and for how long".cliMessages— user messages and the assistant envelope. Source of truth for message-level lifecycle (pending,streaming,completed,failed).cliMessageChunks— the streamed rows. Each row carries:- raw
typeanddatafrom the runtime - a computed
normalizedEvent(CliNormalizedEvent) used by the frontend
- raw
The split exists because assistant output is streamed incrementally, but the thread still needs stable message-level lifecycle state while chunks flow in.
Normalized events
The canonical union is
cliNormalizedEventValidator
in apps/web/convex/lib/contracts/validators.ts. Kinds:
| Kind | Purpose |
|---|---|
text | Assistant text block |
reasoning | Model reasoning / thinking block |
context-window | Token usage / window meter |
context-compaction | Runtime compacted context |
tool-input | Tool call (id, name, input JSON) |
tool-output | Tool result |
tool-error | Tool result marked as error |
metadata | Session metadata, cost/duration, or a bundled event wrapper |
runtime-notice | Runtime-level notice (e.g. Claude API retry) |
warning | Non-fatal warning |
unknown | Parser couldn't recognize the chunk (raw kept for debug) |
Clients page older history with cli.messageChunks.listWindowByThread,
subscribe to new chunks with cli.messageChunks.listLiveByThread (delta
cursor), and feed events through
apps/web/lib/cli-stream-ui/composer.ts to produce AI SDK UI parts.
Example: assistant text block
Raw chunk from the Claude Code stream:
{
"type": "assistant",
"data": "{\"message\":{\"content\":[{\"type\":\"text\",\"text\":\"Hello world\"}]}}"
}Normalized event written to cliMessageChunks.normalizedEvent:
{ "kind": "text", "text": "Hello world" }Example: tool call and result
Raw assistant chunk with a tool_use block:
{
"type": "assistant",
"data": "{\"message\":{\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Read\",\"input\":{\"file_path\":\"/tmp/x\"}}]}}"
}→ { "kind": "tool-input", "toolCallId": "toolu_1", "toolName": "Read", "inputJson": "{\"file_path\":\"/tmp/x\"}" }
Subsequent user chunk with a tool_result:
{
"type": "user",
"data": "{\"message\":{\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"file contents\"}]}}"
}→ { "kind": "tool-output", "toolCallId": "toolu_1", "outputJson": "file contents" }
Example: unknown chunk
When the parser cannot recognize a chunk, raw data is preserved so operators can debug:
{
"kind": "unknown",
"reason": "json-parse-error",
"rawType": "assistant",
"rawData": "{malformed..."
}Bundling
When a single raw chunk expands into multiple semantic events (a result
chunk commonly emits both metadata and context-window), the normalizer
returns a bundled metadata event whose otherJson payload holds
{ "events": [...] }. The client-side composer unpacks these via
expandCliNormalizedEvent.
Runtime-specific contracts
Runtime-specific parsing lives inside
apps/web/convex/cli/lib/runtimes/<slug>/. Generic runtime support is
provided by shared/genericRuntime.ts; Claude Code has its own claudeCode/
subfolder with normalizeChunk.ts, models.ts, continuation.ts, and
runtime.ts. Keep runtime conditionals inside these subsystems — do not
scatter cliType === "claude-code" checks across top-level Convex modules.
Current runtime status
The CLI registers executable runners for Claude Code, Codex, OpenCode, and
Cursor. Codex uses the app-server protocol by default; convoy connect --codex-exec selects its exec transport. Other runtime slugs have no
registered CLI runner and fail when selected for execution.
See Supported runtimes for approval, steering, attachment, and isolation differences. See Adding a runtime for how to add execution support.