Docs

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's opencode serve, which also routes mid-run permission/question requests through the interaction bridge).
Rendering diagram...

Main code areas

Backend (Convex):

  • apps/web/convex/cliThreads.tslistPendingThreads, claimThread, heartbeatClaim, releaseClaim
  • apps/web/convex/cliMessages.ts — batched assistant response lifecycle (startBatchResponse, appendBatchContent, complete, fail, retry)
  • apps/web/convex/cliSessions.ts — CLI session registration and heartbeats
  • apps/web/convex/http.ts/api/agent/register, /api/agent/heartbeat, /api/agent/disconnect
  • apps/web/convex/cli/lib/runtimes/registry.ts — runtime registry and the CliNormalizedEvent dispatch
  • apps/web/convex/cli/lib/runtimes/claudeCode/normalizeChunk.ts — Claude Code raw-chunk → normalized-event mapping
  • apps/web/convex/cli/lib/chunks/normalizedEvents.ts, store.ts
  • apps/web/convex/lib/contracts/validators.tscliNormalizedEventValidator is the authoritative event union

CLI:

  • apps/cli/src/agent/app/runner.ts — orchestrates registration, heartbeat, pending-thread subscription, shutdown
  • apps/cli/src/agent/app/thread-processor.ts — FIFO queue of claimed threads; owns the claim lease loop and batch lifecycle
  • apps/cli/src/agent/runtimes/registry.ts — CLI-side runtime registry
  • apps/cli/src/agent/runtimes/claudeCode/runner.ts — spawns Claude Code, yields JSON stream chunks
  • apps/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 slug
  • apps/web/lib/cli-stream-ui/ — composer/recovery layer that turns normalized events into AI SDK UIMessage parts

Session lifecycle

  1. CLI resolves a saved profile (--profile, CONVOY_PROFILE, cwd-path match, or default_profile).
  2. CLI registers a session via HTTP POST /api/agent/register (API-key auth, conflicts resolvable with force=true).
  3. CLI sends POST /api/agent/heartbeat every 30s. Sessions expire after 60s of silence.
  4. CLI subscribes to cliThreads.listPendingThreads via realtime query.
  5. On SIGINT/SIGTERM, POST /api/agent/disconnect runs and the process exits.

Thread lifecycle

pending → claimed → streaming → claimed → … → (idle, claim released)
  1. A user message arrives. Convex creates only the user message and flags the thread pending (hasPendingUserMessages).
  2. CLI claims the thread via cliThreads.claimThread, which records claimedBySessionId, claimedAt, and claimLeaseExpiresAt.
  3. CLI fetches unprocessed user messages and calls cliMessages.startBatchResponse, which creates one assistant envelope and sets activeAssistantMessageId.
  4. CLI spawns the selected runtime in the profile's working directory.
  5. Each JSON line from the runtime is sent to cliMessages.appendBatchContent. Raw chunks are stored in cliMessageChunks and the normalizedEvent is computed at insert time (see below).
  6. On success, cliMessages.complete finalizes the assistant message. On failure, cliMessages.fail marks it retryable.
  7. 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 activeAssistantMessageId is 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 (cliSessions table).
  • 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:

  1. retry on 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.
  2. Any path that ends or abandons streaming must clear the thread's stale activeAssistantMessageId.
  3. 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.
  4. cliMessageStatusValidator must 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 type and data from the runtime
    • a computed normalizedEvent (CliNormalizedEvent) used by the frontend

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:

KindPurpose
textAssistant text block
reasoningModel reasoning / thinking block
context-windowToken usage / window meter
context-compactionRuntime compacted context
tool-inputTool call (id, name, input JSON)
tool-outputTool result
tool-errorTool result marked as error
metadataSession metadata, cost/duration, or a bundled event wrapper
runtime-noticeRuntime-level notice (e.g. Claude API retry)
warningNon-fatal warning
unknownParser 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.

On this page