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 (Claude Code, Codex, or OpenCode) generates the streamed
output. Two transport shapes exist: per-batch subprocesses (Claude Code's
SDK query, Codex's
codex exec --json) and a long-lived server driven through an SDK (OpenCode'sopencode serve, which also routes mid-run permission/question requests through the interaction bridge).
Main code areas
Backend (Convex):
apps/web/convex/cliThreads.ts—listPendingThreads,claimThread,heartbeatClaim,releaseClaimapps/web/convex/cliMessages.ts— batched assistant response lifecycle (startBatchResponse,appendBatchContent,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 batch 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/runner.ts— placeholder; throws on execute until a real runner lands
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.startBatchResponse, 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.appendBatchContent. 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 batch 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 batch must requeue the batched user messages 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 subscribe to cliMessageChunks.listNormalizedByThread (bootstrap +
delta cursor pattern) 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
Claude Code, Codex (two transports: the app-server protocol by default,
codex exec with convoy connect --codex-exec), and OpenCode are fully
executable runtimes. Other slugs (amp, droid, cursor, gemini,
coderabbit, github-copilot, other) are registered so the UI can select
them, but the CLI-side runner throws "not implemented" on execute. See
Roadmap for the current status and
Adding a runtime for how to land
execution support.