Developer Guide

Assistant Architecture

Assistant Architecture

This document owns assistant-runtime architecture details. The repo-level architecture index lives in /ARCHITECTURE.md.

Channel Onboarding Playbook Bootstrap

  • Transport metadata arrives via conversation_create.transport (HTTP) or /channels/inbound (channelId, optional hints, optional uxBrief).
  • Telegram webhook ingress injects deterministic channel-safe transport metadata (hints + uxBrief) so non-dashboard channels defer dashboard-only UI tasks cleanly.
  • OnboardingPlaybookManager resolves <channel>_onboarding.md, checks onboarding/playbooks/registry.json, and applies per-channel first-time fast-path onboarding.
  • OnboardingOrchestrator derives onboarding-mode guidance (post-hatch sequence, users/<slug>.md persona capture) from playbook + transport context.
  • Conversation runtime assembly injects both <channel_onboarding_playbook> and <onboarding_mode> context before provider calls, then strips both from persisted conversation history.
  • Permission setup remains user-initiated and hatch + first-conversation flows avoid proactive permission asks.

Guardian Actor Context (Unified Across Channels)

  • Guardian/non-guardian/unverified classification is centralized in assistant/src/runtime/trust-context-resolver.ts.
  • The same resolver is used by:
    • /channels/inbound (Telegram/WhatsApp path) before run orchestration.
    • Inbound Twilio voice setup (media-stream routeSetup) to seed call-time actor context.
  • Runtime channel runs pass this as trustContext, and conversation runtime assembly includes actor context in the unified <turn_context> block (via buildUnifiedTurnContextBlock()) injected into provider-facing prompts.
  • Voice calls mirror the same prompt contract: CallController receives guardian context on setup and refreshes it immediately after successful voice challenge verification, so the first post-verification turn is grounded as actor_role: guardian.
  • Voice-specific behavior (DTMF/speech verification flow, call-setup state machine) remains voice-local; only actor-role resolution is shared.

Safe Storage Limits

Safe storage limits protect the workspace volume from running out of disk. The disk pressure guard samples storage continuously and blocks work, injects cleanup guidance, and narrows tool access while the workspace is critically full.

Disk pressure state: src/daemon/disk-pressure-guard.ts samples workspace storage usage every 60 seconds through src/util/disk-usage.ts. At or above the 95% critical threshold it creates an in-memory lock with lockId, usage snapshot, acknowledged, overrideActive, effectivelyLocked, and the blocked capabilities agent-turns, background-work, and remote-ingress. The lock clears when usage drops below the threshold or the process restarts. acknowledgeDiskPressureLock() only lets the guardian enter cleanup mode; overrideDiskPressureLock() requires the exact phrase I understand the risks and disables the effective lock while usage remains critical.

Runtime API and events: src/runtime/routes/disk-pressure-routes.ts exposes GET /v1/disk-pressure/status, POST /v1/disk-pressure/acknowledge, and POST /v1/disk-pressure/override. Route auth policies require normal runtime protection, and disk_pressure_status_changed events are emitted when the status changes so clients can update live.

Turn policy: src/daemon/disk-pressure-policy.ts classifies turns before the main agent loop. Local guardian/owner turns are allowed in cleanup mode; trusted contacts, non-guardian actors, unknown remote senders, background conversations, direct wakes, and non-main LLM call sites are blocked while effectivelyLocked is true. Blocked turns emit terminal conversation errors rather than reaching the provider.

Background work: Heartbeats, scheduled tasks, filing work, retry sweeps, and background tool completions call src/daemon/disk-pressure-background-gate.ts before starting work. While effectively locked they skip the wake or job and log throttled disk-pressure fields.

Prompt and tools: Cleanup-mode turns carry diskPressureContext through runtime assembly and receive the concise <disk_pressure_warning> injector in src/plugins/defaults/memory-retrieval/injectors.ts. The instruction tells the assistant to warn first, call skill_load for system-storage-cleanup, and explain that background processes and trusted-contact messages are blocked. Tool setup marks the turn as cleanup mode; skill_load remains available so the assistant can load the cleanup skill (or another already-installed skill) for its instructions, but under the lock it performs no side effects — it skips catalog auto-install (workspace writes / bun install) and strips inline command tokens instead of executing them, so loading a skill cannot write to the workspace or run shell. skill_execute and skill-origin tools remain unavailable, and a loaded skill's own tools stay filtered by the cleanup allowlist. The bundled system-storage-cleanup skill (src/config/bundled-skills/system-storage-cleanup/SKILL.md) carries the detailed cleanup procedure and deletion safety rules, including read-only SQLite diagnosis only; product-owned retention and maintenance work remains tracked separately by ATL-450 and related tickets. src/tools/tool-approval-handler.ts rejects non-cleanup-safe tools, and foreground shell inspection remains available while background bash and host_bash modes are rejected. When a new lock is created, active background terminal tools are cancelled with reason disk_pressure.

Resource Pressure Monitoring

Resource pressure monitoring reports sustained CPU/memory pressure on cloud-hosted assistants so clients can suggest a plan upgrade. Unlike disk pressure it never locks or blocks work; it is observe-and-report only.

Guard state: src/daemon/resource-pressure-guard.ts is gated on getIsCloud(): off-cloud there is no plan allocation to measure against, so the guard stays disabled and samples nothing. On the cloud it samples every 30 seconds. CPU percent comes from the shared rolling container CPU sampler (src/util/container-cpu-sampler.ts) measured against the cgroup CPU allocation (null when no CPU cores are reported); memory percent is the working set (cgroup usage minus reclaimable file cache) measured against the container memory limit. Each signal keeps a 20-sample window (10 minutes) with hysteresis: it enters elevated only when the window is full and at least 18 samples exceeded the enter threshold (CPU 85%, memory 90%), and clears only after 10 consecutive samples below the clear threshold (CPU 70%, memory 80%). An unavailable sample resets that signal's window; when both samples fail the status becomes unknown with the sample error. The overall state is elevated while either signal holds.

Runtime API and events: src/runtime/routes/resource-pressure-routes.ts exposes the read-only GET /v1/resource-pressure/status (scope settings.read, actor principals); there are no acknowledge or override transitions. resource_pressure_status_changed events broadcast the same status shape, but only on substantive transitions: the raw percents and lastCheckedAt are excluded from the change fingerprint so the 30-second cadence does not spam the SSE hub. The canonical wire contract lives in src/api/events/resource-pressure-status-changed.ts.

Lifecycle: src/daemon/resource-pressure-guard-lifecycle.ts starts the guard at daemon boot with the first sample deferred onto a macrotask so it never blocks startup, and stops the guard (cancelling any pending deferred sample) on shutdown. The web chat banner built on this status is documented in the repo-level /ARCHITECTURE.md "Resource Pressure Monitoring" section.

OOM Priority

When the container hits its memory limit, the kernel kills the process with the highest oom_score (memory share plus oom_score_adj). src/util/oom-priority.ts steers that choice so the assistant is never the victim of a runaway tool: the daemon sets itself to -700 as the first thing in runDaemon, before any spawn. Children inherit the parent's value at fork, so every spawn path resets its child: shell children get +1000 from the Linux prefix in buildShellInvocation (packages/environments), desktop children and ACP agents and terminals get +1000 from withOomScoreAdj, an sh -c ... exec wrapper that keeps pid and process group, and workers, the embedding worker, and Qdrant get 0 from a parent-side write right after spawn (the resource monitor gets -500 so it outlives everything but the daemon and records the kill). The monitor also sweeps the daemon's descendants every few seconds (src/monitoring/oom-inheritance-sweep.ts) and writes 0 over any negative value it finds, logging the comm, so a spawn path that forgets the reset degrades to a few seconds of inherited protection instead of a protected runaway. Bundled plugins (the memory plugin's rerank worker, for one) rely on the sweep rather than importing the helper, since the plugin import boundary forbids reaching outside the plugin. Raising never needs a capability; lowering needs CAP_SYS_RESOURCE, which the cloud grants and self-hosted Docker does not, where the daemon simply stays at 0. Nothing here caps a single process or acts early: it only decides who dies once the kernel has to kill.

Verification telemetry: the resource monitor reports kernel OOM kills in the container as a watchdog event with check_name oom_kill (src/monitoring/oom-kill-reporter.ts). It reads the kernel log through dmesg (the /dev/kmsg device is not mounted in the container; dmesg uses the syslog syscall, which the Kata profile permits) when the cgroup memory.events kill counter moves, on its first scan after start (the kill that took the daemon down restarts the monitor with it), and once a minute as a fallback. The log is shared by every container on that kernel, so a kill is attributed by the cgroup path in the kernel's oom-kill: header, matched against the container's own id, which it reads from the runtime-named mounts in /proc/self/mountinfo because /proc/self/cgroup reads / inside a cgroup namespace (attribution: "cgroup"). The kernel rate-limits that header in a burst, so header-less kills are attributed by the counter, re-read after the log: credit left after the header-attributed kills names header-less kills oldest first, header-less kills beyond the credit are another container's, and credit for a kill counted but not yet logged is spent on the next scan or reported unnamed (attribution: "cgroup_counter"). The first scan and hosts without the counter claim every new kill not assigned elsewhere by a header (attribution: "kernel_log"). The detail bag lists each victim's comm, oom_score_adj and resident size, the cgroup memory figures at the time, and killed_daemon, detected by the daemon's -700 score rather than a pid the restart has since replaced; where the log is unreadable a counter move still yields a victimless event. A cursor (kernel boot id plus last log timestamp) on the monitor data dir advances only after the event is queued, so a telemetry store outage delays a report rather than losing one, and resets when the kernel reboots.

Single-Header JWT Auth Model

All HTTP API requests use a single Authorization: Bearer <jwt> header for authentication. The JWT carries identity, permissions, and policy versioning in a unified token.

Token schema (JWT claims):

ClaimTypeDescription
iss'cadet-auth'Issuer — always cadet-auth
aud'cadet-daemon' or 'cadet-gateway'Audience — which service the token targets
substringSubject — encodes principal type and identity (see patterns below)
scope_profilestringNamed permission bundle (see profiles below)
expnumberExpiry timestamp (seconds since epoch)
policy_epochnumberPolicy version — stale tokens are rejected with refresh_required
iatnumberIssued-at timestamp
jtistringUnique token ID

Subject patterns:

PatternPrincipal TypeDescription
actor:<assistantId>:<actorPrincipalId>actorDesktop or CLI client
svc:gateway:<assistantId>svc_gatewayGateway service (ingress, webhooks)
svc:internal:<assistantId>:<sessionId>svc_internalInternal service connections
svc:daemon:<identifier>svc_daemonDaemon service token (local)
local:<assistantId>:<conversationId>localLocal session or single-route grant

Scope profiles:

ProfileScopesUsed by
actor_client_v1chat.{read,write}, approval.{read,write}, settings.{read,write}, attachments.{read,write}, calls.{read,write}, feature_flags.{read,write}Desktop, CLI clients
contact_client_v1chat.write, approval.write, attachments.writeNon-guardian contact clients
gateway_ingress_v1ingress.write, internal.writeGateway channel inbound + webhook forwarding
gateway_service_v1chat.{read,write}, settings.{read,write}, attachments.{read,write}, internal.writeGateway service-to-daemon calls
local_v1local.allLocal (loopback) conversation sessions
oauth_proxy_v1oauth.proxyOAuth passthrough proxy route only
speech_relay_v1speech.relayDaemon dial of the gateway speech relay only
ui_page_v1settings.readServed UI pages

Identity lifecycle:

  1. Bootstrap (loopback-only, desktop): On first launch, the desktop client calls POST /v1/guardian/init with { platform, deviceId }. The endpoint is loopback-only and mints a JWT access token + refresh token pair. Returns { guardianPrincipalId, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter, isNew }. The CLI obtains its bearer token during hatch and does not perform a separate bootstrap step.

  2. Refresh — POST /v1/guardian/refresh accepts { refreshToken } and returns a new access/refresh token pair. Single-use rotation with replay detection and family-based revocation.

  3. Local identity — Local connections use deterministic identity resolution without tokens.

Token role: Each access and refresh token records the principal it belongs to, guardian or contact. The role picks the scope profile at mint time, and rotation carries it forward.

Route policy enforcement: Every protected endpoint declares required scopes and allowed principal types in src/runtime/auth/route-policy.ts. The enforcePolicy() function checks the AuthContext against these requirements and returns 403 when access is denied. A guard test ensures every dispatched endpoint has a corresponding policy entry.

Trust class enforcement: A policy's allowedTrustClasses names whose turn may call a route, and an absent list means guardian only. A token whose scope profile is trust-checked (isTrustCheckedScopeProfile in @mycadet/gateway-client: every profile not on its exempt list, which is contact_client_v1 today plus any unknown profile) reaches only a route whose list admits the trust class its principal resolves to on the cadet-shared channel. That class is read per request, so a revoked contact is refused on its next request. A trust-exempt token counts as the guardian with no lookup, so it reaches only a route whose list admits guardian, which every route naming no classes does. A refused caller gets 404, as an unmatched path does. The daemon's HTTP router and the gateway's IPC proxy both apply this rule (tokenMayReachRoute in @mycadet/gateway-client), since the daemon's IPC server runs no policy check of its own.

Credential storage: Only hashed tokens are persisted. Access token hashes go in credential_records; refresh token hashes in refresh_token_records. Raw tokens are returned once and never stored server-side.

Notification scoping: Guardian-sensitive notifications (notification_intent, notification_conversation_created) are published with targetActorPrincipalId, so the event hub delivers them, live, on reconnect replay, and from the events/tail recovery route, only to SSE connections whose verified actorPrincipalId is the guardian's. Connections without a principal never receive them. The payload's targetGuardianPrincipalId records the scoping.

Key source files:

FilePurpose
src/runtime/auth/types.tsCore type definitions: TokenClaims, AuthContext, ScopeProfile, Scope, PrincipalType
src/runtime/auth/token-service.tsJWT signing, verification, and policy epoch management
src/runtime/auth/credential-service.tsCredential pair minting (access token + refresh token)
src/runtime/auth/scopes.tsScope profile resolver (resolveScopeProfile)
src/runtime/auth/context.tsAuthContext builder from JWT claims
src/runtime/auth/subject.tsSubject string parser (parseSub)
src/runtime/auth/middleware.tsJWT bearer auth middleware (authenticateRequest)
src/runtime/auth/route-policy.tsRoute-level scope/principal enforcement
src/runtime/routes/guardian-bootstrap-routes.tsPOST /v1/guardian/init (initial JWT issuance)
src/runtime/routes/guardian-refresh-routes.tsPOST /v1/guardian/refresh (token rotation)
src/runtime/local-actor-identity.tsresolveLocalAuthContext — synthetic local AuthContext + guardian principal lookup
src/runtime/local-principal-trust.tsresolveLocalPrincipalTrustContext — local-principal trust from the gateway guardian binding
src/channels/channel-verification-sessions.tsGuardian binding types, verification session management

Channel-Agnostic Scoped Approval Grants

Scoped approval grants allow a guardian's approval decision on one channel (e.g., Telegram) to authorize a tool execution on a different channel (e.g., voice). Two scope modes exist: request_id (bound to a specific pending request) and tool_signature (bound to toolName + canonical inputDigest). Grants are one-time-use, exact-match, fail-closed, and TTL-bound. Full architecture details (lifecycle flow, security invariants, key files) live in docs/architecture/security.md.

Guardian Decision Primitive

All guardian approval decisions, regardless of how they arrive, route through a single primitive in src/approvals/guardian-decision-primitive.ts, which centralizes decision logic for callback button handlers, the conversational approval engine, and channel text replies.

Core API:

FunctionPurpose
applyGuardianDecision(params)Apply a guardian decision against the gateway-owned request store: validate status/identity/expiry, commit via guardian_requests_decide (status CAS + ACL outcome, first-writer-wins, one gateway transaction), dispatch daemon side effects to the kind-specific resolver, mint a scoped grant on approve, and withdraw the request's approval cards. Returns a structured applied/failed result.

Security invariants enforced by the primitive:

  • Decision application is identity-bound to the expected guardian identity.
  • Decisions are first-response-wins (CAS stale protection via the gateway's guardian_requests_decide).
  • approve_always is downgraded to approve_once for guardian-on-behalf requests (guardians cannot permanently allowlist tools for requesters).
  • Scoped grant minting only fires on explicit approve for requests with tool metadata.

Unified interaction model — buttons first, text fallback: All guardian approval prompts follow a canonical "buttons first, text fallback" pattern. Structured button UIs are the primary interaction surface, but every prompt also carries deterministic text fallback instructions so guardians can always act even when buttons are unavailable or not used. This applies uniformly across all request kinds (tool_approval, pending_question, access_request) and all channels (macOS desktop, Telegram, WhatsApp).

Button-first path (deterministic):

  • Desktop clients (macOS) render GuardianDecisionPrompt objects as tappable card UIs with kind-aware headers and action buttons. The GuardianDecisionBubble renders distinct headers for each kind: "Tool Approval Required", "Question Pending", or "Access Request".
  • Desktop clients submit decisions via HTTP (POST /v1/guardian-actions/decision), routed through applyGuardianDecision.
  • Channel adapters (Telegram inline keyboards, WhatsApp) encode actions as callback data (apr:<requestId>:<action>).

Text fallback path (always available):

  • Every prompt includes a requestCode (6-char alphanumeric). Guardians can reply with <requestCode> approve or <requestCode> reject on any channel. The instruction saying so travels only in the card's plainTextFallback, which a transport appends when it sends text without buttons; composed copy and questionText never carry it.
  • access_request prompts carry the request-code verify/trust/reject/block directive in the card's plainTextFallback, appended by a transport only when it sends text without buttons; the "open invite flow" phrase for starting the Trusted Contacts invite flow stays in the text on every surface because nothing offers a button for it.
  • pending_question prompts (voice-originated) support <requestCode> <your answer> for free-text answers.
  • The routeGuardianReply router processes text replies through a priority-ordered pipeline: callback parsing -> request code parsing -> NL classification. All paths converge on applyGuardianDecision.

Shared type system: GuardianDecisionPrompt and GuardianDecisionAction (in src/runtime/guardian-decision-types.ts) define the structured prompt model. buildDecisionActions() computes the action set respecting persistentDecisionsAllowed and forGuardianOnBehalf flags. buildPlainTextFallback() generates parser-compatible text instructions. Channel adapters map these to channel-specific formats via toApprovalActionOptions() in channel-approval-types.ts.

Key source files:

FilePurpose
src/approvals/guardian-decision-primitive.tsUnified decision application: downgrade, approval info capture, handleChannelDecision, record update, grant minting
src/runtime/guardian-decision-types.tsShared types: GuardianDecisionPrompt, GuardianDecisionAction, buildDecisionActions, buildPlainTextFallback, ApplyGuardianDecisionResult
src/runtime/routes/guardian-action-routes.tsHTTP route handlers for GET /v1/guardian-actions/pending and POST /v1/guardian-actions/decision
src/runtime/channel-approval-types.tsChannel-facing approval action types and toApprovalActionOptions bridge

Temporary Approval Modes (Conversation-Scoped Overrides)

In addition to persistent trust rules (always_allow / always_deny), the approval system supports two temporary approval modes that auto-approve tool confirmations for the duration of a conversation or a fixed time window. These exist to reduce prompt fatigue during intensive sessions without permanently altering the trust configuration.

Two modes:

  1. allow_conversation — Auto-approve all tool confirmations for the remainder of the current conversation. The override persists until the session ends, the conversation is closed, or the mode is explicitly cleared.
  2. allow_10m — Auto-approve all tool confirmations for 10 minutes (configurable). The override expires lazily on the next read after the TTL elapses — no background sweep runs.

Conversation-scoped, in-memory only: Overrides are keyed by conversationId and stored in an in-memory Map inside conversation-approval-overrides.ts. They do not survive daemon restarts, which is intentional — temporary approvals should not outlive the conversation that created them.

Integration with the permission pipeline: The permission checker (src/tools/permission-checker.ts) checks for an active temporary override via getEffectiveMode() before prompting the user. If an active override exists for the current conversation, the confirmation is auto-approved without surfacing a prompt. This check runs after persistent trust rules, so a persistent deny rule still takes precedence.

No persistent side effects: Temporary modes do not write to trust.json or create persistent trust rules. They are purely ephemeral. The buildDecisionActions() function in guardian-decision-types.ts controls whether temporary options (allow_10m, allow_conversation) are surfaced in the approval prompt UI, gated by the temporaryOptionsAvailable flag.

Key source files:

FilePurpose
src/runtime/conversation-approval-overrides.tsIn-memory store: setConversationMode, setTimedMode, getEffectiveMode, clearMode, hasActiveOverride, clearAll
src/permissions/types.tsUserDecision type (includes allow_10m, allow_conversation, temporary_override), isAllowDecision() helper
src/runtime/guardian-decision-types.tsbuildDecisionActions() — controls which temporary options appear in approval prompts
src/tools/permission-checker.tsPermission pipeline integration — checks temporary overrides before prompting

Guardian Request System

The guardian request system provides a channel-agnostic, unified domain for all guardian approval and question flows — a single source of truth that works identically for voice calls, Telegram/WhatsApp, and desktop UI.

Architecture layers:

  1. Gateway-owned domain (single source of truth): All guardian requests — tool approvals, pending questions, access requests — are persisted in the gateway's guardian_requests table, with per-surface delivery records in guardian_request_deliveries (gateway/src/db/guardian-request-store.ts). Each request has a unique caller-supplied ID, a short human-readable request code, and a status that follows a CAS (compare-and-swap) lifecycle: pending -> approved | denied | expired | cancelled. The daemon reads and writes requests exclusively through the guardian_requests_* IPC client (src/channels/gateway-guardian-requests.ts).

  2. Unified apply primitive (single write path): applyGuardianDecision() in src/approvals/guardian-decision-primitive.ts is the single write path for all guardian decisions. It enforces identity validation, expiry checks, and approve_always downgrade (guardian-on-behalf invariant), then commits through the gateway's guardian_requests_decide — the status CAS and any ACL outcome (member activation/seed/block, outbound-session mint) execute in one gateway transaction, so an approved request can never exist without its ACL write. Daemon-domain side effects (resolver dispatch, scoped grant minting, notifications) run after a successful decide. All callers — HTTP API, inbound channel router, desktop session — route decisions through this function.

  3. Shared reply router (priority-ordered routing): routeGuardianReply() in src/runtime/guardian-reply-router.ts provides a single entry point for all inbound guardian reply processing across channels. It routes through a priority-ordered pipeline: (a) deterministic callback parsing (button presses with apr:<requestId>:<action>), (b) request code parsing (6-char alphanumeric prefix), (c) NL classification via the conversational approval engine. All decisions flow through applyGuardianDecision.

  4. Deterministic API (prompt listing and decision endpoints): Desktop clients and API consumers use GET /v1/guardian-actions/pending and POST /v1/guardian-actions/decision (HTTP). These endpoints surface guardian requests alongside legacy pending interactions and channel approval records, with deduplication to avoid double-rendering.

  5. Buttons first, text fallback: All request kinds (tool_approval, pending_question, access_request) are rendered as structured button cards when displayed in macOS guardian conversations. Each card also carries deterministic text fallback instructions (request-code-based directives, and for access_request the "open invite flow" phrase) in its plainTextFallback, appended by a transport only when it sends text without buttons, so text-based channels and manual fallback always work without the instructions duplicating the buttons. Code-only messages (just a request code without decision text) return clarification instead of auto-approving. Disambiguation with multiple pending requests stays fail-closed: no auto-resolve when the target is ambiguous.

Resolver registry: Kind-specific resolvers (src/approvals/guardian-request-resolvers.ts) handle side effects after CAS resolution. Built-in resolvers: tool_approval (channel/desktop approval path), pending_question (voice call question path), and access_request (trusted-contact verification session creation). New request kinds register resolvers without touching the core primitive.

Expiry: the daemon's 60-second sweep (src/runtime/routes/guardian-expiry-sweep.ts) lists a bounded batch of past-deadline pending requests (guardian_requests_list_expired_pending, read-only), fans out card withdrawal + requester notices per row, and only then confirms each with the per-request guardian_requests_expire CAS, so a lost response or crash leaves the row listed for the next round; interaction-bound kinds (tool_approval, pending_question) are expired at daemon boot via guardian_requests_expire_interaction_bound since they die with the in-memory pending-interactions map, and the boot call never touches persistent kinds. src/calls/guardian-action-sweep.ts sends the guardian-facing expiry notices for voice questions.

Key source files:

FilePurpose
src/channels/gateway-guardian-requests.tsTyped IPC client for the gateway-owned request/delivery lifecycle
gateway/src/approvals/guardian-request-service.tsGateway service: create/reads/decide (CAS + ACL outcome in one transaction)
src/approvals/guardian-decision-primitive.tsDecision primitive: applyGuardianDecision + scoped grant minting
src/approvals/guardian-request-resolvers.tsResolver registry: kind-specific daemon side-effect dispatch after the decide
src/runtime/guardian-reply-router.tsShared inbound router: callback -> code -> NL classification pipeline
src/runtime/routes/guardian-action-routes.tsHTTP endpoints for prompt listing and decision submission
src/runtime/routes/guardian-expiry-sweep.tsRequest expiry sweep (gateway CAS + daemon fan-out)

Outbound Channel Verification (HTTP Endpoints)

Channel verification is initiated through gateway HTTP endpoints (which forward to runtime handlers). This enables chat-first verification where the assistant guides the user through channel verification setup via normal conversation.

HTTP Endpoints:

EndpointMethodDescription
/v1/channel-verification-sessionsPOSTCreate a verification session. If destination is provided, starts outbound verification; if purpose: "trusted_contact" with contactChannelId, starts trusted contact verification; otherwise creates an inbound challenge. Body: { channel?, destination?, rebind?, purpose?, contactChannelId? }
/v1/channel-verification-sessions/resendPOSTResend the verification code for an active outbound session. Body: { channel }
/v1/channel-verification-sessionsDELETECancel all active sessions (inbound + outbound) for a channel. Body: { channel }
/v1/channel-verification-sessions/revokePOSTCancel all active sessions and revoke the guardian binding. Body: { channel? }
/v1/channel-verification-sessions/statusGETCheck guardian binding status. Query: ?channel=<channel>

All endpoints are JWT-authenticated via Authorization: Bearer <jwt>. Skills and user-facing tooling should target the gateway URL (default http://localhost:7830), not the runtime port.

Shared Business Logic:

The HTTP route handlers (channel-verification-routes.ts) delegate to action functions in verification-outbound-actions.ts. This module contains transport-agnostic business logic for starting, resending, and cancelling outbound verification flows across Telegram and voice channels. It returns OutboundActionResult objects that the transport layer maps to the HTTP response format.

Chat-First Orchestration Flow:

  1. The user asks the assistant (via desktop chat) to set up channel verification.
  2. The conversational routing layer detects the verification-setup intent and loads the guardian-verify-setup skill via skill_load.
  3. The skill guides the assistant through collecting the channel and destination, then calls the outbound HTTP endpoints using curl.
  4. The assistant relays verification status (code sent, resend available, expiry) back to the user conversationally.
  5. On the channel side, the verification code arrives (Telegram message or voice call) and the recipient enters it to complete the binding.

Key Source Files:

FilePurpose
src/runtime/verification-outbound-actions.tsShared business logic for start/resend/cancel outbound verification
src/runtime/routes/channel-verification-routes.tsHTTP route handlers for unified verification session API (/v1/channel-verification-sessions, /revoke, /status)
src/config/bundled-skills/guardian-verify-setup/SKILL.mdSkill that teaches the assistant how to orchestrate channel verification via chat

Guardian-Only Tool Invocation Gate:

Channel verification control-plane endpoints (/v1/channel-verification-sessions/*) are protected by a deterministic gate in the tool executor (src/tools/executor.ts). Before any tool invocation proceeds, the executor checks whether the invocation targets a guardian control-plane endpoint and whether the actor role is allowed. The policy uses an allowlist: only guardian and undefined (desktop/trusted) actor roles can invoke these endpoints. Non-guardian and unverified-channel actors receive a denial message explaining the restriction.

The policy is implemented in src/tools/verification-control-plane-policy.ts, which inspects tool inputs (bash commands, URLs) for verification endpoint paths. This is a defense-in-depth measure — even if the LLM attempts to call verification endpoints on behalf of a non-guardian actor, the tool executor blocks it deterministically.

The guardian-verify-setup skill is the exclusive handler for channel verification intents in the system prompt. Other skills (e.g., phone-calls) hand off to guardian-verify-setup rather than orchestrating verification directly.

Guardian Question Timeout & Expiry Lifecycle

Guardian requests (tool approvals, access requests, voice ASK_GUARDIAN questions) live in the gateway's guardian_requests / guardian_request_deliveries tables and carry an expiresAt timestamp. Two paths retire a request the guardian never answered, and all user-facing copy on those paths is LLM-generated (never hardcoded strings) to maintain a natural, conversational tone across voice and text channels.

Voice consultation timeout: When a voice call's ASK_GUARDIAN consultation times out before the guardian responds, the call controller expires the request via the gateway (guardian_requests_expire — deliveries expire with it), sends expiry notices to every surface the approval card reached (cadet conversations get an assistant message; external channels get a direct channel reply), and injects a [GUARDIAN_TIMEOUT] instruction so the model apologizes to the caller and offers a message or callback on the next voice turn.

Background expiry sweep: Independent of any active call, a 60-second sweep lists pending guardian requests past their expiresAt (guardian_requests_list_expired_pending), runs the side effects per row (withdrawing the approval cards on every surface, notifying the requester, releasing any in-memory pending interaction), and then confirms each with the per-request guardian_requests_expire CAS; a decision arriving past the deadline loses to expiry atomically, since the decide CAS guards on expires_at.

Generated messaging requirement: All user-facing copy in the guardian timeout/expiry path is generated through the guardian-action-message-composer.ts composition system, which uses a 2-tier priority chain: (1) daemon-injected LLM generator for natural, varied text; (2) deterministic fallback templates for reliability. No hardcoded user-facing strings exist in the flow files outside of internal log messages and LLM-instruction prompts. A guard test (guardian-action-no-hardcoded-copy.test.ts) enforces this invariant.

Key source files:

FilePurpose
src/channels/gateway-guardian-requests.tsGateway-backed guardian-request client: typed wrappers over the guardian_requests_* IPC routes that own request/delivery state and expiry
src/runtime/routes/guardian-expiry-sweep.tsPeriodic 60s sweep: bounded expired-pending list, per-row card-withdrawal, requester-notice, and pending-interaction fan-out, then per-request expire CAS
src/calls/call-controller.tsVoice timeout handling: expires the request, sends expiry notices, injects [GUARDIAN_TIMEOUT] instruction for the generated voice response
src/calls/guardian-action-sweep.tsExpiry notice delivery to guardian destinations (cadet conversation message or direct channel reply)
src/runtime/guardian-action-message-composer.ts2-tier text generation: daemon-injected LLM generator with deterministic fallback templates

WhatsApp Channel (Meta Cloud API)

The WhatsApp channel enables inbound and outbound messaging via the Meta WhatsApp Business Cloud API. It follows the standard ingress/egress pattern with Meta's HMAC-SHA256 signature validation (X-Hub-Signature-256).

Ingress (GET /webhooks/whatsapp — verification, POST /webhooks/whatsapp — messages):

  1. Webhook verification: Meta sends a GET with hub.mode=subscribe, hub.verify_token, and hub.challenge. The gateway compares hub.verify_token against the webhook_verify_token credential and echoes hub.challenge as plain text.
  2. On POST, the gateway verifies the X-Hub-Signature-256 header (HMAC-SHA256 of the raw request body using the app_secret credential). Fail-closed: a request is rejected with 500 when no app secret is configured, and with 403 when the signature does not match.
  3. Normalization: Text and media messages (image, audio, video, document, sticker) from messages change fields are forwarded. Delivery receipts, read receipts, and unsupported message types (contacts, location) are silently acknowledged with { ok: true }. Media attachments are downloaded from the WhatsApp Cloud API, uploaded to the runtime attachment store, and their IDs are passed alongside the message content.
  4. /new command: When the message body is /new (case-insensitive), the gateway resolves routing, resets the conversation, and sends a confirmation message without forwarding to the runtime.
  5. The payload is normalized into a GatewayInboundEvent with sourceChannel: "whatsapp" and conversationExternalId set to the sender's WhatsApp phone number (E.164).
  6. WhatsApp message IDs are deduplicated via StringDedupCache (24-hour TTL).
  7. The gateway marks each inbound message as read (best-effort, fire-and-forget).
  8. The event is forwarded to the runtime via POST /channels/inbound with WhatsApp-specific transport hints and a replyCallbackUrl of <gatewayInternalBaseUrl>/deliver/whatsapp. The gateway serves no route at that path: the daemon uses the URL only to address the reply (see Egress).

Egress (daemon WhatsApp transport, src/messaging/providers/whatsapp/):

  1. Replies, approval prompts, and proactive sends go out through the daemon's whatsappTransport (transport.ts), which calls the Meta Cloud API itself. isDirectDelivery() in src/messaging/providers/index.ts resolves the /deliver/whatsapp callback URL to this transport through channelForCallback() (src/messaging/providers/callback-routing.ts), so the daemon never posts back to the gateway or dials the URL's host and port. The gateway's own replies to invite and verification codes it intercepts at ingress reach this transport the same way: deliverVerificationReply (gateway/src/verification/reply-delivery.ts) hands the reply and the inbound message's callback URL to the daemon's IPC-only deliver_gateway_reply method (src/ipc/routes/channel-reply-ipc-routes.ts), which resolves the transport from that URL.
  2. api.ts reads phone_number_id and access_token from the secure store and sends through the Cloud API /{phoneNumberId}/messages endpoint.
  3. send.ts splits text into 4096-character chunks, preferring newline and then whitespace boundaries.
  4. An approval prompt renders as an interactive message with up to three reply buttons whose ids follow the shared apr:<requestId>:<action> convention. When the cap forces a cut, the reject and block actions are kept. The gateway normalizes the guardian's button_reply back into callback data on ingress.
  5. Attachments up to 25 MB are uploaded to the Cloud API media endpoint and sent as image, video, or document messages. Any that fail are listed in a follow-up text notice.

The gateway sends only its own notices to WhatsApp (the /new confirmation and routing-rejection notices), through gateway/src/whatsapp/send.ts.

Required credentials (credential vault, whatsapp service):

  • phone_number_id: the numeric WhatsApp Business phone number ID from Meta
  • access_token: System User or temporary access token
  • app_secret: app secret for webhook signature verification
  • webhook_verify_token: token for the Meta webhook subscription handshake

The daemon reads phone_number_id and access_token to send. The gateway reads all four: the last two for ingress, and the first two for its own notices.

Limitations: Contacts and location message types are acknowledged but not forwarded.

Channel Readiness: The channel readiness HTTP endpoints (GET /v1/channels/readiness, POST /v1/channels/readiness/refresh) backed by ChannelReadinessService in src/runtime/channel-readiness-service.ts provide a unified readiness subsystem for all channels. Each channel registers a ChannelProbe that runs synchronous local checks (credential presence, ingress config) and optional async remote checks with a 5-minute TTL cache. Built-in probes: Telegram (bot token, webhook secret, ingress). The GET endpoint returns cached snapshots; the refresh endpoint invalidates the cache first. Unknown channels return unsupported_channel. Route handlers live in src/runtime/routes/channel-readiness-routes.ts.

Slack Channel (Socket Mode)

The Slack channel provides text-based messaging via Slack's Socket Mode API. Unlike other channels that use HTTP webhooks, Slack uses a persistent WebSocket connection managed by the gateway — no public ingress URL is required. The assistant-side manages credential storage and validation through HTTP config endpoints.

Control-plane endpoints (/v1/integrations/slack/channel/config):

EndpointMethodDescription
/v1/integrations/slack/channel/configGETReturns current config status: hasBotToken, hasAppToken, hasUserToken, connected, plus workspace metadata (teamId, teamName, botUserId, botUsername)
/v1/integrations/slack/channel/configPOSTValidates and stores credentials. Body: { botToken?: string, appToken?: string, userToken?: string }
/v1/integrations/slack/channel/configDELETEClears all Slack channel credentials (bot, app, and user tokens) from secure storage and credential metadata. The credentials delete route handles slack_channel/user_token like any other credential except that it skips the OAuth teardown, so removing just the user token leaves the channel's connection (run on the bot + app tokens) intact. clearSlackUserToken is the surgical user-token-only helper used internally by Slack config handling.

All endpoints are JWT-authenticated via Authorization: Bearer <jwt>.

Credential storage pattern:

Both tokens are stored in the secure key store (CES credential store with encrypted file fallback):

Secure keyContent
credential/slack_channel/bot_tokenSlack bot token (used for chat.postMessage and auth.test)
credential/slack_channel/app_tokenSlack app token (xapp-..., used for Socket Mode apps.connections.open)
credential/slack_channel/user_tokenOptional. Slack user OAuth token (xoxp-...). Enables reading channels and DMs the bot isn't a member of (for triage). Never used for writes.

Workspace metadata (team ID, team name, bot user ID, bot username) is stored as JSON in the credential metadata store under ('slack_channel', 'bot_token').

Read/write auth split: The Slack adapter (src/messaging/providers/slack/adapter.ts) caches read and write auth separately. Reads (listConversations, getHistory, replies, search, users.info) prefer the user token when present via getReadAuth() — giving visibility into channels and DMs the bot hasn't been invited to. Writes (postMessage, markRead, and any future state-changing calls) always use the bot token via getWriteAuth() so posts come from the bot identity, never the user's. When no user token is stored, reads fall back to the bot token (unchanged legacy behavior).

Workspace-consistency invariant: The user token and bot token must be for the same Slack workspace. setSlackChannelConfig enforces this by calling auth.test on each token and comparing the returned team_id; if the user token's workspace does not match an already-stored bot token's workspace, the user token is rejected and not stored.

Token validation via auth.test:

When a bot token is provided via POST /v1/integrations/slack/channel/config, the handler calls POST https://slack.com/api/auth.test with the token before storing it. A successful response yields workspace metadata (team_id, team, user_id, user) that is persisted alongside the token. If auth.test fails, the token is rejected and not stored.

The app token is validated by format only — it must start with xapp-.

Connection status:

Both GET and POST endpoints report connected: true only when both hasBotToken and hasAppToken are true. The POST endpoint additionally returns a warning field when only one token is stored, describing which token is missing.

Key source files:

FilePurpose
src/daemon/handlers/config-slack-channel.tsBusiness logic for get/set/clear Slack channel config
src/runtime/routes/integrations/slack/channel.tsHTTP route handlers for /v1/integrations/slack/channel/config

Trusted Contact Access (Channel-Agnostic)

External users who are not the guardian can gain access to the assistant through a guardian-mediated verification flow. The flow is channel-agnostic — it works identically on Telegram, voice, and any future channel.

Full design doc: docs/trusted-contact-access.md

Flow summary:

  1. Unknown user messages the assistant on any channel.
  2. Ingress ACL (inbound-message-handler.ts) rejects the message and emits an ingress.access_request notification signal to the guardian.
  3. Guardian approves or denies via callback button or conversational intent (routed through guardian-approval-interception.ts).
  4. On approval, an identity-bound verification session with a 6-digit code is created (access-request-decision.ts → channel-verification-service.ts).
  5. Guardian gives the code to the requester out-of-band.
  6. Requester enters the code; identity binding is verified, the challenge is consumed, and an active contact channel is created in the contacts table.
  7. All subsequent messages are accepted through the ingress ACL.

Channel-agnostic design: The entire flow operates on abstract ChannelId and actorExternalId/conversationExternalId fields (DB column names externalUserId/externalChatId are unchanged). Identity binding adapts per channel: Telegram uses chat IDs, voice uses E.164 phone numbers, HTTP API uses caller-provided identity. No channel-specific branching exists in the trusted contact code paths.

Lifecycle states: requested → pending_guardian → verification_pending → active | denied | expired

Notification signals: The flow emits signals at each lifecycle transition via emitNotificationSignal():

  • ingress.access_request — unknown contact denied, guardian notified
  • ingress.trusted_contact.guardian_decision — the guardian's verdict (the payload's decision field carries approved/denied; exactly one lifecycle signal fires per denial)
  • ingress.trusted_contact.verification_sent — code created and delivered (stands in for guardian_decision on approve so the pipeline doesn't announce approval before verification)
  • ingress.trusted_contact.activated — requester verified, contact active

HTTP API (for management):

EndpointMethodDescription
/v1/contactsGETList contacts (filterable by role, search by query/channel/etc.)
/v1/contactsPOSTCreate or update a contact
/v1/contacts/:idGETGet a contact by ID
/v1/contacts/mergePOSTMerge two contacts
/v1/contact-channels/:contactChannelIdPATCHUpdate a contact channel's status/policy

Key source files:

FilePurpose
src/runtime/routes/inbound-message-handler.tsIngress ACL, unknown-contact rejection, verification code interception
src/runtime/routes/access-request-decision.tsGuardian decision → verification session creation
src/runtime/routes/guardian-approval-interception.tsRoutes guardian decisions (button + conversational) to access request handler
src/runtime/channel-verification-service.tsVerification session lifecycle, identity binding, rate limiting
src/runtime/routes/contact-routes.tsHTTP/IPC handlers for contacts, channels, and invites (invite ops relay to gateway IPC)
src/runtime/invite-service.tsDaemon-owned invite presentation (share link, guardian instruction) over gateway-minted invites
src/contacts/contact-store.tsContact read queries — lookup, search, list, and channel operations
src/memory/guardian-approvals.tsApproval request persistence
src/channels/channel-verification-sessions.tsVerification challenge persistence
src/config/bundled-skills/contacts/SKILL.mdUnified skill for contact management, access control, and invite links

Guardian-Initiated Invite Links

A complementary access-granting flow where the guardian proactively creates a shareable invite link rather than waiting for an unknown user to request access. Currently implemented for Telegram; the architecture supports future channel adapters.

Three-layer architecture:

┌─────────────────────────────────────────────────────────────┐
│  Conversational Orchestration (guardian-invite-intent.ts)    │
│  Pattern-based intent detection → forces contacts            │
│  skill load for create / list / revoke actions               │
├─────────────────────────────────────────────────────────────┤
│  Channel Transport Adapters (channel-invite-transport.ts)    │
│  Registry of per-channel adapters:                           │
│    • buildShareableInvite(token) → { url, displayText }     │
│    • extractInboundToken(payload) → token | undefined        │
│  Registered: Telegram  │  Deferred: Slack, Voice             │
├─────────────────────────────────────────────────────────────┤
│  Core Redemption Engine (gateway-native)                     │
│  gateway/src/verification/invite-redemption.ts               │
│  Token/code validation, expiry, use-count, channel-match     │
│  enforcement, atomic claim, gateway ACL activation           │
│  Returns: InviteRedemptionOutcome (discriminated union)      │
│  Daemon mirrors contact info via the invite_redeemed event   │
└─────────────────────────────────────────────────────────────┘

Invite link flow (Telegram):

  1. Guardian asks the assistant to create an invite via desktop chat.
  2. guardian-invite-intent.ts detects the intent and rewrites the message to force-load the contacts skill.
  3. The skill calls the invites HTTP API; the daemon relays the mint to the gateway (invites_create IPC), which writes the canonical ingress_invites row, then the daemon's Telegram transport adapter builds a deep link: https://t.me/<bot>?start=iv_<token>.
  4. Guardian shares the link with the invitee out-of-band.
  5. Invitee clicks the link, opening Telegram which sends /start iv_<token> to the bot.
  6. The gateway intercepts the /start iv_<token> message at ingress (before any daemon forwarding) and redeems the token against its canonical ingress_invites row.
  7. The gateway's redemption engine validates, activates the channel in the gateway ACL, and notifies the daemon (invite_redeemed) to mirror the contact info locally.
  8. A deterministic welcome message is delivered to the invitee (bypasses the LLM pipeline).

Token prefix convention: The iv_ prefix distinguishes invite tokens from gv_ (guardian verification) tokens. Both use the same Telegram /start deep-link mechanism but are routed to different handlers.

Inbound intercept points: Invite token/code extraction runs at gateway ingress for non-member senders, before ACL denial, so valid invites short-circuit the contact check. The engine handles: (a) unknown contacts — the invite creates their first channel record; (b) inactive contacts (revoked) — the invite reactivates them; blocked channels are never reactivated.

Channel adapter status:

ChannelStatusPrerequisites
TelegramShippedBot username resolved from credential metadata or config
VoiceShippedIdentity-bound voice code redemption via DTMF/speech in the call setup flow. Always-on canonical behavior with personalized friend/guardian name prompts.
SlackDeferredNeeds DM-safe ingress — Socket Mode handles channel messages but DM-initiated invite flows need routing

Voice Invite Flow (invite_redemption)

Voice invites use a short numeric code (6 digits) instead of a URL token. The guardian creates an invite bound to the invitee's E.164 phone number; the invitee redeems it by entering the code during an inbound voice call.

Creation flow:

  1. Guardian creates a voice invite via POST /v1/contacts/invites with sourceChannel: "phone" and expectedExternalUserId (E.164 phone).
  2. The daemon relays the mint to the gateway (invites_create IPC), which generates a cryptographically random numeric code and stores only its SHA-256 hash on the canonical ingress_invites row.
  3. The one-time plaintext voiceCode is returned in the creation response. The raw token is NOT returned for voice invites — redemption uses the identity-bound code flow exclusively.
  4. Guardian communicates the code to the invitee out-of-band.

Call-time redemption subflow (invite_redemption):

  1. Unknown caller dials in. The media-stream server resolves trust from the gateway trust verdict (a missing/failed verdict denies fail-closed). Caller is unknown, no pending guardian challenge.
  2. routeSetup asks the gateway for the active voice invite bound to the caller's phone number (get_active_voice_invite IPC via calls/gateway-invite-reader.ts; fail-soft — any gateway failure falls through to the unverified path).
  3. If an active, non-expired invite exists, the CallSetupFlow runs the invite_redemption sub-flow (collecting the code via DTMF or spoken digits) and prompts the caller with personalized copy: Welcome <friend-name>. Please enter the 6-digit code that <guardian-name> provided you to verify your identity.
  4. The gateway's redeem_voice_invite engine validates: identity match, code hash match, expiry, use count. On success, the phone channel is activated in the gateway ACL and the call transitions to the normal call flow.
  5. On invalid/expired code, the caller hears deterministic failure copy: Sorry, the code you provided is incorrect or has since expired. Please ask <guardian-name> for a new code. Goodbye. and the call ends immediately.

Security invariants:

  • The plaintext voice code is returned exactly once at creation time and never stored.
  • Voice invites are identity-bound: expectedExternalUserId must match the caller's E.164 number. An attacker with the code but the wrong phone number cannot redeem.
  • Failure responses are intentionally generic (invalid_or_expired) to prevent oracle attacks.
  • Blocked contacts cannot bypass the guardian's explicit block via invite redemption.

Key source files:

FilePurpose
gateway/src/verification/invite-redemption.ts (gateway)Core redemption engine + gateway-ingress token/code intercept — validation, atomic claim, ACL activation, discriminated-union outcomes
src/runtime/channel-invite-transport.tsTransport adapter registry with buildShareableInvite / extractInboundToken interface
src/runtime/channel-invite-transports/telegram.tsTelegram adapter — t.me/<bot>?start=iv_<token> deep links, /start iv_<token> extraction
src/runtime/channel-invite-transports/voice.tsVoice transport adapter — code-based redemption metadata
src/daemon/guardian-invite-intent.tsIntent detection — routes create/list/revoke requests into the contacts skill
src/runtime/invite-service.tsDaemon-owned invite presentation (share link, guardian instruction, channel handle) over gateway-minted invites
src/runtime/routes/contact-routes.tsHTTP/IPC invite handlers — relay mint/list/revoke/redeem to the gateway's invite IPC routes
src/calls/call-setup-flow.tsTransport-agnostic call setup flow — invite_redemption sub-flow (always-on canonical behavior)
src/calls/gateway-invite-reader.tsGateway IPC read of the active voice invite for a caller identity

Voice Inbound Security Model (Canonical)

The voice inbound security model determines how unknown callers are handled when they dial in. Three paths exist, evaluated in priority order by routeSetup (call-setup-router.ts) when the media-stream session starts. All guardian decisions route through applyGuardianDecision in the guardian request system.

Decision tree for inbound unknown callers:

Unknown caller dials in
        |
        v
gateway trust verdict → trustClass
        |
        ├── guardian / trusted_contact → normal call flow
        ├── blocked → immediate denial + disconnect
        ├── policy: deny → immediate denial + disconnect
        |
        └── unknown (no binding) ──┐
                                   |
              ┌────────────────────┼──────────────────────┐
              |                    |                       |
    pendingChallenge?     activeVoiceInvite?       no invite, no challenge
              |                    |                       |
              v                    v                       v
    Guardian verification   Invite redemption     Name capture +
    (DTMF/speech code)     (personalized code)   guardian approval wait

Path 1: Voice invite code redemption (guardian-initiated)

The guardian proactively creates a voice invite bound to the caller's E.164 phone number. When the unknown caller dials in and has an active, non-expired invite, the setup flow enters the invite_redemption sub-flow with personalized prompts using the friend's and guardian's names. This is always-on canonical behavior (no feature flag). See Voice Invite Flow above.

Path 2: Live in-call guardian approval (friend-initiated)

When no invite exists and no pending guardian challenge is active, the setup flow enters the name capture + guardian approval wait flow:

  1. The setup flow transitions to capturing_name state and prompts the caller for their name with a timeout.
  2. On name capture, notifyGuardianOfAccessRequest creates a guardian request (kind: 'access_request') in the gateway and notifies the guardian via the notification pipeline.
  3. The setup flow hands off to a GuardianWaitController (awaiting_guardian_decision), which speaks hold messaging and heartbeat updates while polling the request status via the gateway client.
  4. The guardian approves or denies via any channel (Telegram, desktop). All decisions route through applyGuardianDecision, which commits via guardian_requests_decide and dispatches daemon side effects to the access_request resolver in guardian-request-resolvers.ts.
  5. On approval: the decide transaction activates the caller as a trusted contact (sets channel status: 'active', policy: 'allow' in the gateway ACL), the poll detects the approved status, and the setup flow completes into the normal call flow with the caller's guardian context updated.
  6. On denial or timeout: the caller hears a denial message and the call ends.

Path 3: Inbound guardian verification (pending challenge)

When a pending voice guardian challenge exists (getPendingSession), the caller enters the DTMF/speech verification flow to complete an outbound-initiated guardian binding. This path is for guardian identity verification, not trusted-contact access.

Decision routing:

All guardian decisions for voice access requests flow through:

  • applyGuardianDecision (guardian request system; commits via the gateway's guardian_requests_decide)
  • accessRequestResolver in guardian-request-resolvers.ts (kind-specific daemon side effects)
  • For voice approvals: an activate_member ACL outcome inside the decide transaction (no verification session needed since the caller is already on the line)
  • For text-channel access requests: a mint_outbound_session outcome mints the 6-digit verification session inside the decide transaction

Key source files:

FilePurpose
src/calls/call-setup-router.tsInbound call decision tree (routeSetup)
src/calls/call-setup-flow.tsName capture, verification, and invite sub-flows over the media-stream transport
src/calls/guardian-wait-controller.tsGuardian approval wait — hold messaging, heartbeats, poll/timeout
src/runtime/access-request-helper.tsCreates the access request in the gateway and notifies the guardian
src/approvals/guardian-decision-primitive.tsapplyGuardianDecision — unified decision primitive
src/approvals/guardian-request-resolvers.tsaccess_request resolver — voice direct activation, text-channel verification session
src/runtime/trust-verdict-consumer.tsGateway verdict → caller trust classification (actorTrustContextFromVerdict)
src/channels/gateway-guardian-requests.tsGateway client for request persistence and the atomic decide

Speech-to-Text (STT) Boundaries

Audio-to-text conversion occurs in six distinct runtime boundaries, each with its own provider model and adapter layer. The services.stt config block is the single source of truth for STT provider selection across assistant, client, live voice, and telephony boundaries.

Per-consumer roles: services.stt.provider (plus that provider's services.stt.providers.<id>.model family) is the global selection. services.stt.roles.<role> overrides it for one consumer, naming a {provider, model?} pair; an unset role falls back to the global. Roles exist because capability is per boundary while the global is not: the Flux family streams but has no batch endpoint, so a global set to it breaks file transcription and telephony at the same moment it improves live voice. A role names the consumer rather than the boundary, because one telephony call resolves a streaming transcriber and a batch fallback within the same session.

RoleConsumersBoundaries required
liveVoicesrc/live-voice/live-voice-session.ts, src/live-voice/live-voice-credential-preflight.tsstreaming
telephonysrc/calls/media-stream-stt-session.ts (streaming dial and batch fallback)streaming + batch + telephony
dictation/v1/stt/stream (src/runtime/http-server.ts), POST /v1/stt/transcribe, src/plugin-api/transcription-session.tsstreaming + batch
watch/v1/watch/stream (src/runtime/routes/watch-routes.ts)streaming
batchinbound audio (routes/inbound-stages/transcribe-audio.ts), POST /v1/stt/transcribe-file, media-processing/transcribe skillsbatch

Roles are defined in src/stt/roles.ts: STT_ROLE_REQUIREMENTS declares each role's boundaries, sttSelectionForRole()/sttCatalogKeyForRole() perform the lookup, and sttRoleCapabilityGap() states why a pair cannot serve a role. Selection is resolved inside resolveBatchTranscriber()/resolveStreamingTranscriber(), so a caller passes role and nothing else. Capability is checked against the resolved catalog row (sttCatalogKeyFor(provider, model)), not the bare provider id, since deepgram batches and deepgram running flux does not. Validation is fail-closed: services.stt.roles rejects an incapable pair at config parse time rather than substituting a working provider at dial time. Telephony and live-voice preflight, the dictation session label, and the telephony language pin all read their own role, so what preflight judges is what the session dials.

Provider catalog model: The daemon's canonical provider catalog (src/providers/speech-to-text/provider-catalog.ts) is the single source of truth for all STT provider metadata — credential mappings, supported boundaries, telephony mode, conversation streaming mode, and client-facing display metadata (names, hints, setup mode, credentials guide). Native clients fetch provider metadata at launch via GET /v1/stt/providers. To add a new provider, follow the checklist in docs/stt-provider-onboarding.md.

Boundary overview:

BoundaryRuntimeProvider (current)Adapter moduleCaller
Telephony (media-stream)Daemon process (Twilio Media Streams WebSocket)telephony role, else services.stt.providersrc/calls/media-stream-stt-session.ts, src/providers/speech-to-text/resolve.ts, streaming provider adapterssrc/calls/media-stream-server.ts
Daemon batchDaemon process (REST API to provider)batch role, else services.stt.providersrc/stt/daemon-batch-transcriber.tssrc/runtime/routes/inbound-stages/transcribe-audio.ts
Conversation streamingDaemon process (WebSocket-based)dictation role, else services.stt.providersrc/stt/stt-stream-session.ts, src/providers/speech-to-text/deepgram-realtime.ts, src/providers/speech-to-text/google-gemini-live-stream.ts, src/providers/speech-to-text/openai-whisper-stream.ts, src/providers/speech-to-text/xai-realtime.tsWeb/Electron dictation client via gateway WS proxy
Live voice channelAssistant process (gateway-authenticated WebSocket)liveVoice role, else services.stt.providersrc/runtime/http-server.ts, src/live-voice/live-voice-session-manager.ts, src/live-voice/live-voice-session.ts, src/providers/speech-to-text/resolve.ts, streaming provider adaptersWeb/Electron live voice client via /v1/live-voice
Client service-firstWeb/Electron via gateway → daemondictation (transcribe) / batch (transcribe-file) role, else services.stt.providersrc/runtime/routes/stt-routes.tsWeb/Electron dictation and voice clients

Telephony boundary (media-stream):

Every phone call connects over Twilio Media Streams: the voice webhook emits <Connect><Stream> TwiML pointing to the gateway's media-stream proxy, and the daemon performs both STT and TTS itself — Twilio only carries mu-law audio frames. The <Stream url="..."> encodes callSessionId and auth token as URL path segments (e.g. .../media-stream/<callSessionId>/<token>) because Twilio Media Streams does not reliably preserve query parameters across the WebSocket upgrade. The gateway extracts metadata from path segments (with query-parameter fallback for backward compatibility) and proxies raw audio frames to the daemon.

Transcription mode is selected once per session in media-stream-stt-session.ts:

  • Streaming (default): when calls.voice.telephonyStreaming is enabled and the telephony role resolves a streaming transcriber (resolveStreamingTranscriber({ role: "telephony" })), inbound audio is decoded (mu-law → PCM16, resampled 8 kHz → 16 kHz) and fed to the provider's realtime adapter. Replies trigger only on utterance-boundary finals (for Deepgram, speech_final/UtteranceEnd, never mid-sentence is_final segments), and barge-in fires from local energy VAD, never from transcriber partials. Interim partial events are forwarded to the controller as handleCallerPartial: they commit nothing and start no turn, they are never persisted and never reach a setup flow, and they exist so a boundary the front door held mid-thought can be replayed on the caller's newest words rather than only the ones the provider has finalized. While something is interruptible (an assistant turn in flight, thinking or speaking, or a completed turn's tail still playing from Twilio's buffer), caller speech arms the shared sustained-speech barge-in guard (src/calls/barge-in-guard.ts, the same accounting live voice uses: speech accumulates toward 250 ms, short gaps are tolerated, a run that is mostly silence resets) and every inbound frame feeds it; outside that window the guard is dropped, so the caller's own utterance never carries into a turn that starts before the local VAD ends it. Only a fired guard reaches CallController.handleBargeIn, which interrupts a turn in either phase and ignores an idle controller (the playing tail is cleared instead).
  • Batch fallback: otherwise the session segments turns with the energy-based MediaTurnDetector and transcribes each completed turn via the same role's batch API. Both halves of a call read the telephony role, which is why a role names its consumer rather than a boundary.

Every phone turn runs the same two-leg triage as live voice through startVoiceTurn (src/calls/voice-session-bridge.ts): call-controller.ts opens on a toolless front-door leg (routingLeg: "front-door", the voiceFrontDoor call site) and drives it through the shared createFrontDoorLegCoordinator (src/calls/voice-leg-coordinator.ts), which reads the stream through the verdict machine and sequences the hand-off (pause narration, abort the leg, resolve and speak the bridge, mark it as the floor holder, start the escalated leg pinned to the conversation's own model, re-arm narration); each driver supplies only a host for how text and the bridge are spoken, how a leg is started or aborted, and (live voice only) the speculative hold and commit. A leg that carries the caller's own words is also the turn's endpoint decision, so it is taught the hold verdict: silence alone cannot tell a finished sentence from a thinking pause, and answering a fragment makes the rest of the sentence read as a barge-in that interrupts the assistant's own reply. On a hold the leg is discarded (its persisted user row rolled back through the handle's discard), the floor goes straight back to the caller, and the words are carried into their next final so the whole sentence is judged together. If no next words arrive, a replay timer (voice.frontModel.endpointExtensionMs) answers the sentence as it stands; any partial re-arms that timer and the next final cancels it. A partial also stretches the wait to at least calls.voice.utteranceEndMs plus a margin, because the provider still owes a final for those words and that final is the better boundary: racing it would ask the same words twice, once on the interim guess and once on the committed text. Reaching the timer with a partial outstanding therefore means no final is coming, so the interim text rides along as the only record of what the caller said. voice.frontModel.endpointMaxExtensions caps consecutive holds per sentence, after which the leg must answer. Openers, instructions, and other assistant-initiated turns are never dispatched that way. The controller also passes the bridge's turn callbacks (tool activity is recorded as tool_use_started / tool_use_completed call events, persisted row ids ride the assistant_spoke event), launchedAtMs for dispatch timing, and a voiceTelemetry bag keyed by the call session with a phone_inbound / phone_outbound entry. Both drivers share the spoken progress narration cadence (src/calls/voice-progress-cadence.ts, tuned by voice.frontModel.progress): the cadence owns the tool-activity log, the triggers (an ops burst, a long operation completing, a full interval of audible silence with news, the maxSilenceMs heartbeat) and the generated or static phrase, while each driver supplies its own view of audible silence (live voice from its TTS queue and playback-tail estimate; the media-stream transport from isPlaybackIdle() and a running sum of sent frame durations) and how to speak a phrase.

Both drivers also share the per-turn latency marks (src/calls/voice-metrics.ts). The controller opens a turn's marks when it dispatches, seeded with when the caller's transcript arrived and when the provider first had interim words for that same utterance, and stamps the leg dispatch, the first assistant delta, the first audible audio, a barge-in, an endpoint hold, and how the turn settled. A held turn settles as endpoint_hold and hands both seeds to the turn that finally answers, so the caller's wait is still measured from the moment they stopped talking. Live voice streams a snapshot to its client on every mark; a call has no client on the line, so the aggregate is logged once per turn, cancelled turns included. Each call also records a phone_call_started / phone_call_ended pair on the onboarding telemetry substrate (src/telemetry/phone-call-funnel.ts), written by the call store at session creation and at the single terminal status transition: duration is the gap between the two rows, and a call that took no caller turn carries why on the end row (silent_no_connect when it never connected, silent_no_turn when it did and nobody spoke).

A credential preflight (resolveTelephonyCredentialReadiness() in src/calls/telephony-credential-preflight.ts) gates every call: it requires a credentialed, telephony-capable STT provider and a media-stream-playable TTS provider (the configured one or a credentialed playable fallback). Inbound calls that fail the preflight receive <Say> setup-required copy plus <Hangup/> instead of a doomed stream; outbound placement fails before dialing via preflightVoiceIngress() with the same user-facing message.

Key modules:

ModulePurpose
src/calls/twilio-routes.tsVoice webhook handler; generates <Connect><Stream> TwiML, enforces the inbound credential preflight
src/calls/telephony-credential-preflight.tsCombined STT + TTS credential-readiness resolver
src/calls/media-stream-parser.tsTwilio Media Streams protocol parser
src/calls/voice-metrics.tsPer-turn latency marks, shared with live voice
src/calls/media-turn-detector.tsEnergy-based VAD turn detector for raw audio (batch mode)
src/calls/media-stream-stt-session.tsSTT session — streaming/batch mode selection and transcription via services.stt
src/calls/media-stream-audio-transcode.tsMu-law ↔ PCM16 codecs and resampling
src/calls/call-transport.tsTransport interface decoupling CallController from wire protocol
src/calls/media-stream-output.tsOutput adapter for sending TTS audio back via Media Streams
src/calls/media-stream-server.tsWebSocket server binding media-stream lifecycle to call sessions and setup flows
gateway/src/http/routes/twilio-media-websocket.tsGateway WebSocket proxy for Media Streams frames

Guard tests in __tests__/twilio-routes-twiml.test.ts and __tests__/twilio-routes.test.ts assert that every supported STT provider yields <Connect><Stream> TwiML for both inbound and outbound calls.

To add a new telephony STT provider: declare telephonyMode and supportedBoundaries on the provider's catalog entry in provider-catalog.ts. The telephony capability resolver (resolveTelephonySttCapability()) reads these fields plus credential availability from the catalog — no per-provider routing maps exist.

Daemon batch boundary:

The daemon transcribes audio attachments (e.g. voice messages from channel inbound) by calling a provider's REST API directly.

  • src/stt/types.ts defines provider-agnostic domain types: BatchTranscriber interface, SttTranscribeRequest, SttTranscribeResult, SttError with normalized categories (auth, rate-limit, timeout, invalid-audio, provider-error), and SttProviderId / SttBoundaryId discriminants.
  • createDaemonBatchTranscriber() in src/stt/daemon-batch-transcriber.ts is the factory that returns a BatchTranscriber backed by the configured STT provider (OpenAI Whisper or Deepgram, selected via services.stt.provider). Returns null when no API key is available for the selected provider. normalizeSttError() maps raw provider errors to SttError categories.
  • resolveBatchTranscriber() in src/providers/speech-to-text/resolve.ts is the credential-aware entry point — it reads the configured provider from services.stt, resolves the corresponding API key from the secure key store, and delegates to the factory.
  • tryTranscribeAudioAttachments() in src/runtime/routes/inbound-stages/transcribe-audio.ts is the callsite that uses the facade for channel audio attachment transcription.

To add a new daemon batch STT provider, follow the full checklist in docs/stt-provider-onboarding.md — it covers the daemon catalog, type registration, config schema, adapter wiring, credential plumbing, client catalog, and parity tests.

Conversation streaming boundary:

Real-time conversation chat message capture on macOS uses a WebSocket-based streaming STT path. When the provider the dictation role resolves to supports conversation streaming (determined by the conversationStreamingMode field in the provider catalog), native clients open a WebSocket session through the gateway to the daemon's /v1/stt/stream endpoint. The daemon resolves a StreamingTranscriber for that provider and streams partial/final transcript events back to the client in real time.

Each provider that advertises a conversation streaming mode in the catalog ships an adapter implementing the StreamingTranscriber interface from src/stt/types.ts. The catalog (src/providers/speech-to-text/provider-catalog.ts) is the source of truth for which providers are streaming-capable; resolveStreamingTranscriber() maps each to its adapter:

ProviderAdapterModeMechanism
Deepgramsrc/providers/speech-to-text/deepgram-realtime.tsrealtime-wsOpens a WebSocket to Deepgram's /v1/listen endpoint, forwards raw PCM audio, normalizes Deepgram's is_final/speech_final semantics into partial/final events. Uses model nova-2.
Deepgram Fluxsrc/providers/speech-to-text/deepgram-flux-realtime.tsrealtime-wsOpens a WebSocket to Deepgram's /v2/listen conversational endpoint. The model decides turn boundaries, so the adapter also emits turn-start, eager-turn-end, turn-resumed, and turn-end. Streaming only, no batch endpoint. Uses model flux-general-en.
Google Geminisrc/providers/speech-to-text/google-gemini-live-stream.tsrealtime-wsOpens a bidirectional streaming session against Gemini's Live API (ai.live.connect), forwards PCM audio frames, and normalizes serverContent.inputTranscription events into partial/final events. Uses model gemini-2.5-flash-native-audio-latest.
Cadet Cloudsrc/providers/speech-to-text/cadet-cloud-realtime.tsrealtime-wsWraps the Deepgram adapter and dials the gateway speech relay (/v1/speech/stt/stream) instead of Deepgram directly; both relay legs speak Deepgram's wire protocol, and the model is pinned server-side.
xAIsrc/providers/speech-to-text/xai-realtime.tsrealtime-wsOpens a WebSocket to wss://api.x.ai/v1/stt and normalizes xAI's transcript.partial/transcript.done payloads into partial/final events.
OpenAI Whispersrc/providers/speech-to-text/openai-whisper-stream.tsincremental-batchWhisper exposes no streaming endpoint, so the shared incremental-batch-stream.ts strategy re-transcribes an accumulating buffer and diffs successive results into partial/final events.

Provider-specific behavior differences:

  • Deepgram (realtime-ws): True WebSocket streaming with sub-second partial latency. Emits partial events for is_final: false frames and final events for is_final: true frames. Supports backpressure (drops audio frames when bufferedAmount > 1 MiB). Sends CloseStream message on stop with a 5-second grace period for the provider to flush remaining finals. Inactivity timeout: 30 seconds (provider-side hang detection). Connect timeout: 10 seconds. Auth errors map to close codes 1008/4001; rate limits to 1013.
  • Deepgram Flux (realtime-ws): The model owns turn detection, so the adapter carries no endpointing heuristics and exposes no finalizeUtterance: callers feature-detect it and fall back to stop(). It emits the four turn-boundary events alongside transcripts, where eager-turn-end is a speculative end-of-turn that a later turn-resumed retracts or a turn-end confirms. Streaming only: batch callers get a diagnostic naming deepgram as the batch-capable provider on the same credential.
  • Google Gemini (realtime-ws): WebSocket-backed Live API session. Partials are emitted as Gemini streams inputTranscription.text fragments; a final is emitted when the server signals generationComplete or turnComplete. On stop(), the adapter sends audioStreamEnd: true and waits up to a 5-second grace window for the server to flush remaining transcription before force-closing. Inactivity timeout: 30 seconds. Connect timeout: 10 seconds. Close codes 1008/4001 map to auth; 1013 maps to rate-limit; other codes map to provider-error. The model's own text turn is suppressed via a silent system instruction so we only pay for transcription.

Session lifecycle (daemon side):

  1. Client opens a WebSocket to /v1/stt/stream with required query parameter mimeType and optional provider and sampleRate. The provider parameter is optional compatibility metadata: the runtime is config-authoritative and always resolves the streaming transcriber from the dictation role (services.stt.roles.dictation, else services.stt.provider). When a requested provider disagrees with the one that role resolves to, the runtime logs a mismatch warning.
  2. SttStreamSession (in src/stt/stt-stream-session.ts) resolves a StreamingTranscriber via resolveStreamingTranscriber() from src/providers/speech-to-text/resolve.ts, using the dictation role's provider (not the requested one).
  3. The transcriber's start() method opens the provider session.
  4. A ready event (with provider field) is sent to the client, signaling that audio frames are accepted.
  5. Client sends audio frames (binary WebSocket frames or base64-encoded JSON) and a stop event when recording ends.
  6. The transcriber emits transcript events, plus turn-boundary events (turn-start, eager-turn-end, turn-resumed, turn-end) from providers that detect them, forwarded to the client as JSON frames with monotonic seq numbers.
  7. The session closes deterministically on: client disconnect, stop event followed by provider closed, idle timeout (60 seconds), or runtime shutdown.

Session lifecycle (client side):

The web client streaming dictation client lives at clients/web/src/domains/chat/voice/dictation-stream.ts; it opens the gateway WebSocket to /v1/stt/stream, parses partial/final frames, and reports failures so the caller can fall back to batch transcription. Before opening a session the client checks the configured provider's conversationStreamingMode from the GET /v1/stt/providers API. When the stream delivers at least one final event and has not failed, the streaming final text is used directly; otherwise the batch STT path (clients/web/src/domains/chat/voice/stt-api.ts) provides the fallback.

Fallback semantics:

The conversation streaming path degrades gracefully to the existing batch STT path:

  1. Unsupported provider (a hypothetical provider with conversationStreamingMode: "none"): The client checks streaming availability (the configured provider's conversationStreamingMode from GET /v1/stt/providers) before attempting a streaming session. When unavailable, recording proceeds with the batch-only flow (no WebSocket is opened). On the daemon side, if a streaming session is somehow opened for an unsupported provider, the session sends an error event followed by closed and closes the socket with code 1000.
  2. Connection failure (network error, gateway down, auth failure): The streaming client reports the failure to its caller, which marks the stream as failed and falls through to batch STT resolution when recording stops.
  3. Mid-session provider error (provider WebSocket disconnect, timeout, rate limit): The daemon session emits an error event (with a normalized SttErrorCategory) followed by closed. The client marks the stream as failed and defers to batch STT.
  4. Missing credentials: resolveStreamingTranscriber() returns null when the API key is not configured. The session sends an error+closed pair and the client falls back to batch.

Error category mapping:

CategoryDeepgram close codesGoogle Gemini close codesClient action
auth1008, 40011008, 4001Mark stream failed; batch fallback
rate-limit10131013Mark stream failed; batch fallback
timeoutN/A (inactivity)N/A (inactivity)Mark stream failed; batch fallback
provider-errorAll other codesAll other codes / Live session errorMark stream failed; batch fallback

Key source files:

FilePurpose
src/stt/types.tsStreamingTranscriber interface, SttStreamClientEvent/SttStreamServerEvent discriminated unions, ConversationStreamingMode type
src/stt/stt-stream-session.tsRuntime session orchestrator: lifecycle management, idle timeout, event forwarding with seq ordering
src/providers/speech-to-text/deepgram-realtime.tsDeepgram realtime-ws adapter: WebSocket to Deepgram /v1/listen, is_final/speech_final normalization
src/providers/speech-to-text/google-gemini-live-stream.tsGoogle Gemini realtime-ws adapter: bidirectional Live API session, serverContent.inputTranscription normalization
src/providers/speech-to-text/provider-catalog.tsProvider catalog with conversationStreamingMode per entry (realtime-ws, incremental-batch, none)
src/providers/speech-to-text/resolve.tsresolveStreamingTranscriber(): credential-aware factory for streaming adapters; resolveConversationStreamingSttCapability(): capability validator
src/runtime/http-server.tsRuntime WebSocket upgrade handler for /v1/stt/stream, session registry (activeSttStreamSessions), graceful shutdown
gateway/src/http/routes/stt-stream-websocket.tsGateway WebSocket proxy: authenticates client, opens upstream WS to daemon with service token
clients/web/src/domains/chat/voice/dictation-stream.tsWeb streaming dictation client: WebSocket session, event parsing, failure reporting
clients/web/src/domains/chat/voice/voice-recording-store.tsWeb voice recording state: streaming/batch priority, fallback on failure

Live voice channel boundary:

The local live voice channel uses a single gateway-authenticated WebSocket at /v1/live-voice. Native clients connect to the gateway route, the gateway validates an actor token, mints a gateway service token, and opens an upstream WebSocket to the assistant runtime route. Both text control frames and binary audio frames are proxied opaquely by gateway/src/http/routes/live-voice-websocket.ts; gateway/src/index.ts dispatches open, message, and close callbacks to that handler before the generic runtime proxy fallback.

The assistant runtime route lives in src/runtime/http-server.ts. It mirrors the STT streaming security posture: direct access must come from private-network peers/origins, and authenticated deployments require the gateway service token. The runtime parses JSON frames with parseLiveVoiceClientTextFrame(), parses binary frames with parseLiveVoiceBinaryAudioFrame(), and routes accepted sessions through LiveVoiceSessionManager. The V1 manager owns a single-active-session lock and returns a busy frame for concurrent sessions.

The assistant-side live voice module is intentionally bounded under src/live-voice/:

FileBoundary
protocol.tsProvider-agnostic client/server frame types, validation, binary audio parsing, and monotonic server-frame sequencing
live-voice-session-manager.tsSingle-active-session lock, session factory context, and dispatch/release lifecycle
live-voice-session.tsSession orchestration: streaming STT, push-to-talk release, voice turn bridge callbacks, assistant text deltas, TTS, archive, metrics, interrupt, and close
live-voice-tts.tsStreaming TTS helper that resolves services.tts, requires TtsProvider.synthesizeStream(), and forwards audio chunks as tts_audio frames
live-voice-archive.tsAudio artifact creation/linking for user utterance and assistant response message IDs
../calls/voice-metrics.tsShared per-session and per-turn latency marks; live voice emits each snapshot as a metrics frame, phone calls log the per-turn aggregate

Live voice STT uses the same resolveStreamingTranscriber() path as conversation streaming, dialed with the provider the liveVoice STT role resolves to after cloud-speech defaulting (resolveEffectiveSpeechProviders, see config/cloud-speech-defaults.ts). For V1 latency-sensitive behavior, that provider must resolve to a daemon-streaming transcriber whose catalog entry has conversationStreamingMode: "realtime-ws" and usable credentials. Providers that only support batch or incremental-batch transcription remain valid for other voice surfaces, but do not satisfy live voice's streaming STT requirement.

Live voice TTS uses streamLiveVoiceTtsAudio() and the TTS provider that resolveEffectiveSpeechProviders settles on (the configured services.tts.provider, or the cloud cadet provider when the configured one has no usable credentials). The selected provider must be registered, catalog-compatible, and expose capabilities.supportsStreaming plus synthesizeStream(). Providers whose catalog entry advertises supportsStreaming (currently all five catalog providers: ElevenLabs, Fish Audio, Deepgram, xAI, and the cloud Cadet provider) satisfy this requirement; a buffered-only provider would remain available for buffered message playback or other supported surfaces, but live voice reports a TTS error instead of silently falling back to buffered playback.

The voiceFrontDoor prompt skips current-turn legacy and v3 memory retrieval, while prior frozen memory cards and static memory context remain available. The front-door rule escalates rather than guessing when required personal context is absent. The escalated leg runs the ordinary memory pipeline against the latest visible caller prompt before the quality model, with low selector effort. This keeps memory work off the front-door TTFT path without cross-leg speculative state.

V1 is local/gateway-scoped. Managed/cloud WebSocket proxy support, cross-region routing, and p50/p95 latency guarantees are out of scope for this version. Metrics frames expose timing data for measurement, but the architecture does not promise a hard latency SLO.

Client service-first boundary:

All product-facing dictation and voice-streaming paths use a service-first STT strategy. Clients record audio, encode it to WAV, and POST it through the gateway to the daemon's POST /v1/stt/transcribe endpoint. The daemon resolves the dictation role's provider through resolveBatchTranscriber() and returns the transcribed text. POST /v1/stt/transcribe-file resolves the batch role the same way.

  • The client receives a typed result distinguishing success from notConfigured, serviceUnavailable, and error, so callers can deterministically trigger their fallback path.
  • The gateway proxies the request via assistant-scoped path rewriting: /v1/assistants/:id/stt/transcribe is rewritten to /v1/stt/transcribe on the daemon.
  • stt-routes.ts (src/runtime/routes/stt-routes.ts) defines the HTTP endpoint, validates the audio payload, and delegates to resolveBatchTranscriber().

The web client implements these flows in clients/web/src/domains/chat/voice/: stt-api.ts (batch transcribe), dictation-stream.ts (streaming dictation), and voice-recording-store.ts (recording state, streaming/batch priority, fallback). Push-to-talk dictation and conversation chat capture prefer a streaming final when available and fall back to batch transcription when streaming was not used, failed, or produced no finals.

Cross-boundary notes:

  • The services.stt config block is the single source of truth for STT provider selection across the daemon batch boundary, the conversation streaming boundary, the client service-first boundary, and the telephony boundary. The batch and streaming resolvers (resolveBatchTranscriber(), resolveStreamingTranscriber()) both read it, taking the caller's role override (services.stt.roles.<role>) when one is set and the global services.stt.provider otherwise, and resolve credentials through the same catalog; the telephony boundary's media-stream STT session uses these same resolvers, selecting streaming vs batch transcription via resolveStreamingTranscriber()/resolveBatchTranscriber() under the calls.voice.telephonyStreaming flag, gated by resolveTelephonySttCapability(), while call placement/TwiML is gated by resolveTelephonyCredentialReadiness(). The daemon provider catalog (src/providers/speech-to-text/provider-catalog.ts) is the authoritative registry of supported providers. Native clients fetch display metadata via GET /v1/stt/providers.
  • Spoken language is a second axis on the same block: services.stt.language is read by resolveStreamingTranscriber() and resolveBatchTranscriber() and forwarded to the adapters that accept one (Deepgram, xAI, and the cloud relay, which passes it to Deepgram server-side). Callers may override it per streaming session, but none need to: reading it in the resolvers means every boundary inherits the setting. Gemini and Whisper take no language option and auto-detect natively, so they ignore it. Leaving it unset is not auto-detection on Deepgram or the cloud relay: Deepgram's default is English, so non-English speech transcribes as English-sounding nonsense. "multi" selects nova-3 code-switching across English, Spanish, French, German, Hindi, Russian, Portuguese, Japanese, Italian, and Dutch, the mode to use when a speaker mixes languages inside one utterance. BYOK Deepgram pins model=nova-3 for any explicitly configured language, not just "multi": the curated roster is verified against nova-3 (the default nova-2 supports only a subset, and rejects "multi" outright), and deepgramLanguageOptions() owns the model+language pairing so every call site inherits the rule; leaving the language unset keeps the adapter default model with no language param. "multi" is never forwarded to xAI (it is not a BCP-47 code; xAI auto-handles multilingual audio). The cloud relay pins nova-3 and allowlists language, so streaming needs no platform-side change; managed batch goes through the cloud speech proxy, which accepts no language parameter, so forwarding it there is deferred pending a cloud-side change.
  • Conversation streaming does not replace the client service-first batch path. When streaming is available, it runs concurrently during recording and provides real-time partials and finals. The batch path remains the fallback for providers that do not support streaming, when streaming fails mid-session, or when streaming produces no final transcript.
  • Credential mapping is catalog-driven: provider-secret-catalog.ts derives STT API-key provider names from the daemon catalog via listCredentialProviderNames(), deduplicating against the LLM/search provider list. Adding a provider to the catalog automatically includes its credential name in API_KEY_PROVIDERS.
  • Terminology: "STT", "transcription", and "speech recognition" all refer to the same operation (converting audio to text).
  • Onboarding: For a step-by-step guide to adding a new STT provider, see docs/stt-provider-onboarding.md.

On-Demand Home Content Generation

LLM-generated content shown by clients (personalized home greeting, suggested prompts, conversation starters) is produced on demand, never at daemon startup or on unconditional timers.

Data flow (home greeting + suggested prompts):

  1. Read — GET /v1/home/feed reads both caches synchronously: the greeting from memory_checkpoints (home:greeting:*, 4-hour TTL, busted when identity files change) and suggested prompts from memory_checkpoints (home:suggested_prompts:*, 4-hour TTL, invalidated on OAuth connect/disconnect). When a cache is empty the handler falls back to a time-of-day greeting and an empty prompt list.
  2. Revalidate — The same GET fires revalidateHomeContentInBackground() (src/home/home-content-refresh.ts) fire-and-forget. It is single-flight (concurrent GETs share one run) and each refresher no-ops while its cache is fresh, so generation happens at most once per TTL window — and only when a user actually views Home.
  3. Notify — When fresh content lands, the coordinator publishes a home_feed_updated event; connected clients refetch the feed and the personalized content swaps in.

Conversation starters follow the same pattern via GET /v1/conversation-starters, which enqueues a generate_conversation_starters memory job when the starter set is stale (cooldown-gated, deduped against in-flight jobs).

Key source files:

FilePurpose
src/home/home-content-refresh.tsSingle-flight on-demand revalidation coordinator + SSE notify
src/home/home-greeting.tsGreeting generation (BTW side-chain, homeGreeting call site)
src/home/home-greeting-cache.tsCheckpoint-backed greeting cache with identity-hash invalidation
src/home/suggested-prompts.tsPrompt generation + checkpoint-backed cache + OAuth invalidation
src/runtime/routes/home-feed-routes.tsGET /v1/home/feed — cached read + fire-and-forget revalidation trigger
src/runtime/routes/conversation-starter-routes.tsOn-demand starter refresh via memory job enqueue

Assistant Feature Flags — Resolver and Enforcement Points

The assistant feature-flag resolver (src/config/assistant-feature-flags.ts) is the canonical module for determining whether an assistant feature flag is enabled. It loads default values from the unified registry at meta/feature-flags/feature-flag-registry.json (bundled copy at src/config/feature-flag-registry.json) and resolves the effective state for each declared assistant-scope flag. Assistant feature flags are declaration-driven assistant-scoped booleans that can gate any assistant behavior; skill availability is one consumer.

Canonical key format: Simple kebab-case (e.g., contacts, browser).

Resolution priority (highest wins):

  1. ~/.cadet/protected/feature-flags.json overrides (local) or gateway HTTP API (Docker/containerized) — written by the gateway's PATCH endpoint
  2. Defaults registry defaultEnabled — from the unified registry (meta/feature-flags/feature-flag-registry.json, filtered to scope: "assistant")
  3. false — unknown/undeclared flags with no override fail closed

Storage: Flags are persisted in ~/.cadet/protected/feature-flags.json (local) or GATEWAY_SECURITY_DIR/feature-flags.json (Docker), managed by the gateway's /v1/feature-flags API (see gateway/ARCHITECTURE.md). The daemon's config watcher monitors the protected directory and hot-reloads flag changes, so mutations take effect on the next tool resolution or session.

Public API:

  • isAssistantFeatureFlagEnabled(key, config) — full resolver with the canonical key
  • skillFlagKey(skill) — takes a skill object (anything with a featureFlag field) and returns the canonical flag key (string) if the skill declares a featureFlag in its SKILL.md frontmatter, or undefined if it does not (in config/skill-state.ts)

Skill-gating guarantee: Skill feature-flag gating is opt-in: only skills whose SKILL.md frontmatter contains a featureFlag field are gated. Skills without the field are always available regardless of feature flag state. For skills that declare a featureFlag, when the corresponding flag is OFF the skill is unavailable everywhere — it cannot appear in client UIs, model context, or runtime tool execution. This is enforced at six independent points:

Enforcement PointModuleEffect
1. Client skill listresolveSkillStates() in config/skill-state.tsSkills with flag OFF are excluded from the resolved list returned to clients (macOS skill list, settings UI). The skill never appears in the client.
2. Capability memory seedingseedSkillGraphNodes() in plugins/defaults/memory/graph/capability-seed.tsSkills with flag OFF are excluded from capability memory seeding. The model cannot discover them via semantic recall.
3. skill_load toolexecuteSkillLoad() in tools/skills/load.tsIf the model attempts to load a flagged-off skill by name, the tool returns an error: "skill is currently unavailable (disabled by feature flag)".
4. Runtime tool projectionprojectSkillTools() in daemon/conversation-skill-tools.tsEven if a skill was previously active in a session (has <loaded_skill> markers in history), the per-turn projection drops it when the flag is OFF. Already-registered tools are unregistered.
5. Included child skillsexecuteSkillLoad() in tools/skills/load.tsWhen a parent skill includes children via the includes directive, each child is independently checked against its feature flag. Flagged-off children are silently excluded from the loaded skill content.
6. Skill install gateinstallSkill() in daemon/handlers/skills.tsWhen a client requests skill installation, the function checks the skill's feature flag before proceeding. If the flag is OFF, the install is rejected with an error.

All six enforcement points derive the flag key via skillFlagKey(skill) — which returns undefined for ungated skills, short-circuiting the check — and then call isAssistantFeatureFlagEnabled(flagKey, config) for consistency.

Migration path: The legacy skills.<id>.enabled and feature_flags.<id>.enabled key formats are no longer supported. All code must use simple kebab-case keys (e.g., contacts, browser). Guard tests enforce canonical key usage and declaration coverage for literal key references in the unified registry.

Key source files:

FilePurpose
src/config/assistant-feature-flags.tsCanonical resolver: isAssistantFeatureFlagEnabled(), registry loader
src/config/skill-state.tsskillFlagKey(skill) — returns canonical flag key for skills with a featureFlag frontmatter field, undefined otherwise; resolveSkillStates() — enforcement point 1
src/plugins/defaults/memory/graph/capability-seed.tsseedSkillGraphNodes() — enforcement point 2
src/tools/skills/load.tsexecuteSkillLoad() — enforcement points 3 and 5
src/daemon/conversation-skill-tools.tsprojectSkillTools() — enforcement point 4
src/config/schema.tsAssistantConfig Zod schema definition (feature flag values are no longer stored here)
src/daemon/handlers/skills.tslistSkills() — uses resolveSkillStates() for client responses; installSkill() — enforcement point 6
meta/feature-flags/feature-flag-registry.jsonUnified feature flag registry (repo root) — all declared flags with scope, label, default values, and descriptions
src/config/feature-flag-registry.jsonBundled copy of the unified registry for compiled binary resolution

Data Persistence — Where Everything Lives

graph LR
    subgraph "Credential Store"
        K1["API Key<br/>service: mycadet<br/>account: anthropic<br/>stored via CES"]
        K2["Credential Secrets<br/>key: credential/{service}/{field}<br/>stored via secure-keys.ts<br/>(encrypted file fallback)"]
    end

    subgraph "UserDefaults (plist)"
        UD1["hasCompletedOnboarding"]
        UD2["assistantName"]
        UD3["activationKey (fn/ctrl)"]
        UD4["ambientAgentEnabled"]
        UD5["ambientCaptureInterval"]
        UD6["maxStepsPerSession"]
    end

    subgraph "~/Library/Application Support/mycadet/"
        direction TB
        SL["logs/session-*.json<br/>───────────────<br/>Per-session JSON log<br/>task, start/end times, result<br/>Per-turn: AX tree, screenshot,<br/>action, token usage"]
    end

    subgraph "$CADET_WORKSPACE_DIR/data/db/assistant-memory.db (SQLite + WAL)"
        direction TB
        SEG["memory_segments<br/>───────────────<br/>Text chunks for retrieval<br/>Linked to messages<br/>token_estimate per segment"]
        SUM["memory_summaries<br/>───────────────<br/>scope: conversation | weekly<br/>Compressed history for context<br/>window management"]
        EMB["memory_embeddings<br/>───────────────<br/>target: segment | item | summary<br/>provider + model metadata<br/>vector_json (float array)<br/>Powers semantic search"]
    end

    subgraph "$CADET_WORKSPACE_DIR/data/db/assistant.db (SQLite + WAL)"
        direction TB
        CONV["conversations<br/>───────────────<br/>id, title, timestamps<br/>token counts, estimated cost<br/>context_summary (compaction)<br/>conversation_type: 'standard' | 'background' | 'scheduled'"]
        MSG["messages<br/>───────────────<br/>id, conversation_id (FK)<br/>role: user | assistant<br/>content: JSON array<br/>created_at"]
        TOOL["tool_invocations<br/>───────────────<br/>tool_name, input, result<br/>decision, risk_level<br/>duration_ms"]
        JOBS["memory_jobs<br/>───────────────<br/>Async task queue<br/>Types: embed, extract,<br/>summarize, backfill, cleanup<br/>Status: pending → running →<br/>completed | failed"]
        ATT["attachments<br/>───────────────<br/>base64-encoded file data<br/>mime_type, size_bytes<br/>Linked to messages via<br/>message_attachments join"]
        REM["reminders<br/>───────────────<br/>One-time scheduled reminders<br/>label, message, fireAt<br/>mode: notify | execute<br/>status: pending → fired | cancelled<br/>routing_intent: single_channel |<br/>multi_channel | all_channels<br/>routing_hints_json (free-form)"]
        SCHED_JOBS["cron_jobs (recurrence schedules)<br/>───────────────<br/>Recurring schedule definitions<br/>cron_expression: cron or RRULE string<br/>schedule_syntax: 'cron' | 'rrule'<br/>timezone, message, next_run_at<br/>enabled, retry_count<br/>source_key: plugin-declared provenance<br/>definition_hash, user_enabled<br/>Legacy alias: scheduleJobs"]
        SCHED_RUNS["cron_runs (schedule runs)<br/>───────────────<br/>Execution history per schedule<br/>job_id (FK → cron_jobs)<br/>status: ok | error<br/>duration_ms, output, error<br/>Legacy alias: scheduleRuns"]
        TASKS["tasks<br/>───────────────<br/>Reusable prompt templates<br/>title, Handlebars template<br/>inputSchema, contextFlags<br/>requiredTools, status"]
        TASK_RUNS["task_runs<br/>───────────────<br/>Execution history per task<br/>taskId (FK → tasks)<br/>conversationId, status<br/>startedAt, finishedAt, error"]
    end

    subgraph "~/.cadet/ (Root Files)"
        TRUST["protected/trust.json<br/>Tool permission rules"]
    end

    subgraph "$CADET_WORKSPACE_DIR/ (Workspace Files)"
        CONFIG["config files<br/>Hot-reloaded by daemon"]
        ONBOARD_PLAYBOOKS["onboarding/playbooks/<br/>[channel]_onboarding.md<br/>assistant-updatable checklists"]
        ONBOARD_REGISTRY["onboarding/playbooks/registry.json<br/>channel-start index for fast-path + reconciliation"]
        APPS_STORE["data/apps/<br/><app-id>.json + pages/*.html<br/>User-created apps stored here"]
        SKILLS_DIR["skills/<br/>managed skill directories<br/>SKILL.md + TOOLS.json + tools/"]
    end

    subgraph "PostgreSQL (Web Server Only)"
        PG["assistants, users,<br/>channel_accounts,<br/>channel_contacts,<br/>api_tokens, api_keys<br/>───────────────<br/>Multi-tenant management<br/>Billing & provisioning"]
    end

Automatic Done

src/conversations/auto-archive.ts runs an opt-in automatic Done sweep after startup recovery, on assistant config or feature-flag invalidation, and hourly. It marks eligible inactive native chats through the existing archived_at state. Candidate selection and the conditional update share the same persisted eligibility predicates; runtime checks run synchronously with each conditional write. Explicit reopening records last_reopened_at to grant a full inactivity interval.

flowchart LR
    TRIGGER[Startup recovery / config or feature-flag change / hourly timer] --> PAGE[Bounded candidate page]
    PAGE --> GUARDIAN[Read pending guardian requests]
    GUARDIAN --> CHECK[Recheck config and runtime activity]
    CHECK --> CAS[Conditional SQLite Done update]
    CAS --> SYNC[Conversation list and metadata invalidation]

Readiness, eligibility, restart behavior, and the gateway snapshot boundary are documented in Automatic Done.


Plugin-Declared Schedules: Declaration → Reconciler → cron_jobs

Plugins contribute recurring schedules as a surface: directories under <pluginDir>/schedules/, each a <name>/ holding config.json plus exactly one index.md (runs as execute) or index.sh (runs as script) entrypoint. Ambiguity fails closed: a file sitting directly under schedules/, zero/multiple/unsupported entrypoints, and malformed config produce per-schedule DeclarationErrors without affecting siblings (src/schedule/plugin-schedule-declarations.ts).

A level-based reconciler (src/schedule/plugin-schedule-reconciler.ts) converges declarations into ordinary cron_jobs rows keyed by the nullable source_key column (plugin:<plugin>/<name>). Rows with source_key IS NULL are imperative schedules the reconciler never touches. Triggers: a startup pass in daemon/lifecycle.ts (after plugin init, before the scheduler starts), the end of reconcilePluginSourcesNow() in plugins/mtime-cache.ts (install/uninstall/upgrade and sentinel-driven changes), the plugin enable/disable routes, and a 60s backstop sweep registered with the HTTP server's background sweeps. The desired set is gated on activation as well as on what is on disk: collectDesiredDeclarations skips any plugin directory that isPluginDirActivated (plugins/mtime-cache.ts) does not report as brought up in this process, so a directory that merely exists under the plugins root never arms a row.

Reconcile lag never lets a disabled plugin run. The disable path writes a .disabled sentinel that only a reconcile pass turns into disarmed rows, so the scheduler re-reads the sentinel at fire time and records a skipped run instead of executing a claimed row whose plugin is off. Run-now applies the same boundary through pluginScheduleSourceAvailable (schedule/plugin-schedule-availability.ts), which composes the activation ledger with declarationExistsOnDisk. That disk probe also covers a plugin whose manifest no longer parses, a declaration directory that is gone, and a plugin root or declaration directory resolving outside the tree it belongs to (the same isInsidePluginRoot containment the loader applies before importing a plugin). Fire time in the scheduler and the user re-enable path in schedule-store.ts deliberately use the disk probe on its own, because both can run outside the daemon process, where the activation ledger is empty and every plugin would read as unactivated. The reconciler's sweep disarms the rows of a plugin it has not activated within one pass, which bounds what those disk-only probes can let through.

A declaration that stops parsing keeps its execute row armed on the message already stored in the row, but disarms its script rows: a script row fires its entrypoint by absolute path, so leaving it armed would run the very index.sh the parser rejected. Those rows re-arm on the pass after the declaration parses again.

Ownership boundaries: the reconciler owns definition columns (expression, timezone, message/script, retry policy, definition_hash); the execution engine owns runtime columns (next_run_at, status, last_*, retry_count) and its latches are never overridden; the user owns user_enabled, a sticky override consulted when computing effective enabled. definition_hash is a sha256 over the relPath and bytes of exactly two files, the declaration's config.json and its entrypoint, so nothing else under schedules/<name>/ can produce a definition change. Nothing ever writes to plugin files. Execution itself is unchanged: declared rows fire through the same claimDueSchedules path as imperative ones.

Consent and surfacing: CLI install/upgrade list declared schedules and prompt before finalizing; unattended daemon-route installs are covered by schedule.declared arrival notifications, with schedule.definition_changed and schedule.definition_error covering upgrades and broken declarations (events registered in notifications/signal.ts, deterministic templates in notifications/copy-composer.ts). Settings routes, CLI, and web render plugin-sourced rows read-only except the enabled toggle.


Web Server — Connection Modes

graph TB
    subgraph "Web Server (Next.js 16)"
        DASHBOARD["Web Dashboard<br/>React 19"]
        ROUTES["API Routes<br/>/v1/assistants/:id/*"]
        AUTH["Better Auth<br/>user/session/account"]
        PG["PostgreSQL<br/>(Drizzle ORM)"]
    end

    subgraph "Local Mode"
        LOCAL_CLIENT["RuntimeClient"]
        LOCAL_HTTP["HTTP API<br/>localhost:RUNTIME_HTTP_PORT"]
        LOCAL_DAEMON["Local Daemon<br/>(same machine)"]
        LOCAL_DB["$CADET_WORKSPACE_DIR/data/db/assistant.db"]
    end

    subgraph "Cloud Mode"
        RUNTIME_CLIENT["RuntimeClient"]
        CLOUD_HTTP["HTTP API<br/>CLOUD_RUNTIME_URL"]
        CLOUD_DAEMON["Hosted Daemon"]
        CLOUD_DB["Remote SQLite"]
    end

    DASHBOARD --> ROUTES
    ROUTES --> AUTH
    AUTH --> PG

    ROUTES -->|"ASSISTANT_CONNECTION_MODE=local"| LOCAL_CLIENT
    LOCAL_CLIENT --> LOCAL_HTTP
    LOCAL_HTTP --> LOCAL_DAEMON
    LOCAL_DAEMON --> LOCAL_DB

    ROUTES -->|"ASSISTANT_CONNECTION_MODE=cloud"| RUNTIME_CLIENT
    RUNTIME_CLIENT --> CLOUD_HTTP
    CLOUD_HTTP --> CLOUD_DAEMON
    CLOUD_DAEMON --> CLOUD_DB

Client-Server Communication — HTTP + SSE

All client-server communication uses HTTP for request/response operations and Server-Sent Events (SSE) for streaming server-to-client events. The runtime HTTP server (RUNTIME_HTTP_PORT, default 7821) is the sole transport.

Client → Server (HTTP POST): Clients send messages, session operations, configuration changes, and approval decisions via HTTP endpoints (e.g., POST /v1/messages, POST /v1/confirm, POST /v1/sessions).

Server → Client (SSE): The daemon streams events to clients via GET /v1/events. All agent events (text deltas, tool execution, confirmations, session state changes) are published through the assistantEventHub and delivered as SSE events.


Session Errors vs Global Errors

The daemon emits two distinct error message types via SSE:

Message typeScopePurposePayload
conversation_errorConversation-scopedTyped, actionable failures during conversation runtime (e.g., provider network error, rate limit, API failure)conversationId, code (typed enum), userMessage, retryable, debugDetails?
errorGlobalGeneric, non-conversation failures (e.g., daemon startup errors, unknown message types)message (string)

Design rationale: conversation_error carries structured metadata (error code, retryable flag, debug details) so the client can present actionable UI — a toast with retry/dismiss buttons — rather than a generic error banner. The older error type is retained for backward compatibility with non-conversation contexts.

Conversation Error Codes

CodeMeaningRetryable
PROVIDER_NETWORKUnable to reach the LLM provider (connection refused, timeout, DNS)Yes
PROVIDER_RATE_LIMITLLM provider rate-limited the request (HTTP 429)Yes
MANAGED_USAGE_LIMITCadet Cloud inference usage limit or quota was exceeded (HTTP 429)Yes
PROVIDER_APIProvider returned a server error (5xx) or retryable 4xxYes
PROVIDER_BILLINGInvalid/expired API key or insufficient credits (HTTP 401, billing 4xx)No
CONTEXT_TOO_LARGERequest exceeds the model's context window (HTTP 413, token limit)No
CONVERSATION_ABORTEDNon-user abort interrupted the requestYes
CONVERSATION_PROCESSING_FAILEDCatch-all for unexpected processing failuresNo
REGENERATE_FAILEDFailed to regenerate a previous responseYes
UNKNOWNUnrecognized error that does not match any specific categoryNo

Error Classification

The daemon classifies errors via classifyConversationError() in conversation-error.ts. Before classification, isUserCancellation() checks whether the error is a user-initiated abort (active abort signal or AbortError); if so, the daemon emits generation_cancelled instead of conversation_error — cancel never surfaces a conversation-error toast.

Classification uses a two-tier strategy:

  1. Structured provider errors: If the error is a ProviderError with a statusCode, the status code determines the category deterministically — 413 maps to CONTEXT_TOO_LARGE (not retryable), 401 maps to PROVIDER_BILLING (not retryable, invalid/expired key), 429 maps to MANAGED_USAGE_LIMIT when the provider is routed through the cloud proxy or the payload matches a Cadet Cloud quota/limit response, otherwise PROVIDER_RATE_LIMIT (retryable), 5xx to PROVIDER_API (retryable), other 4xx to PROVIDER_API (retryable) unless a message pattern matches a more specific non-retryable category (context-too-large, billing/auth).
  2. Regex fallback: For non-provider errors or ProviderError without a status code, regex pattern matching against the error message detects network failures, rate limits, and API errors. Phase-specific overrides handle regeneration contexts.

Debug details are capped at 4,000 characters to prevent oversized payloads.

Error → Toast → Recovery Flow

sequenceDiagram
    participant Daemon as Daemon (conversation-error.ts)
    participant DC as DaemonClient (Swift)
    participant VM as ChatViewModel
    participant UI as ChatView (toast)

    Note over Daemon: LLM call fails or<br/>processing error occurs
    Daemon->>Daemon: classifyConversationError(error, ctx)
    Daemon->>DC: conversation_error {conversationId, code,<br/>userMessage, retryable, debugDetails?}
    DC->>DC: broadcast to all subscribers
    DC->>VM: subscribe() stream delivers message
    VM->>VM: set conversationError property<br/>clear isThinking / isCancelling
    VM-->>UI: @Published conversationError observed

    UI->>UI: show conversationErrorToast<br/>[Retry] [Dismiss] [Copy Debug Info?]

    alt User taps Retry (retryable == true)
        UI->>VM: retryAfterConversationError()
        VM->>VM: dismissConversationError()<br/>+ regenerateLastMessage()
        VM->>DC: regenerate {conversationId}
        DC->>Daemon: HTTP POST /v1/messages
    else User taps Dismiss
        UI->>VM: dismissConversationError()
        VM->>VM: clear conversationError + errorText
    end
  1. Daemon encounters a conversation-scoped failure, classifies it via classifyConversationError(), and sends a conversation_error SSE event with the conversation ID, typed error code, user-facing message, retryable flag, and optional debug details. Conversation-scoped failures emit only conversation_error (never the generic error type) to prevent cross-conversation bleed.
  2. ChatViewModel receives the error via DaemonClient's subscribe() stream (each view model gets an independent stream), sets the conversationError property, and transitions out of the streaming/loading state so the UI is interactive. If the error arrives during an active cancel (wasCancelling == true), it is suppressed — cancel only shows generation_cancelled behavior.
  3. ChatView observes the published conversationError and displays an actionable toast with a category-specific icon and accent color:
    • Retry (shown when retryable is true): calls retryAfterConversationError(), which clears the error and sends a regenerate message to the daemon.
    • Copy Debug Info (shown when debugDetails is non-nil): copies structured debug information to the clipboard for bug reports.
    • Dismiss (X): calls dismissConversationError() to clear the error without retrying.
  4. If the error is not retryable, the Retry button is hidden and the user can only dismiss.

Context Overflow Recovery

The session loop implements a deterministic overflow convergence pipeline that recovers from context-too-large provider rejections without surfacing errors to the user. Instead of the previous behavior where a CONTEXT_TOO_LARGE error was emitted as a conversation_error, the pipeline iteratively reduces the context payload until it fits within the provider's limit.

Two-Phase Architecture

Phase 1 — Preflight budgeting: Before calling the provider, the session loop estimates prompt token count and compares it against a preflight budget (maxInputTokens * (1 - safetyMarginRatio)). If the estimate exceeds the budget, the reducer runs proactively, avoiding a wasted provider round-trip. This catches overflow caused by large tool results, media payloads, or accumulated history before any network call.

Phase 2 — Post-rejection convergence: If the provider returns a context-too-large error despite preflight checks (e.g., due to estimation inaccuracy), the same reducer runs reactively in a bounded loop, retrying the provider after each tier.

Tiered Reduction

The reducer (context-overflow-reducer.ts) applies four monotonically more aggressive tiers, each idempotent:

TierReductionEffect
1. Forced compactionEmergency maybeCompact() with force: true, minKeepRecentUserTurns: 0Summarizes older history more aggressively than normal compaction
2. Tool-result truncationtruncateToolResultsAcrossHistory() at 4,000 chars per resultShrinks verbose tool outputs (shell, file reads) across all retained messages
3. Media/file stubbingstripMediaPayloadsForRetry()Replaces image and file content blocks with lightweight text stubs; media in the latest user message is retained based on available token budget rather than a fixed count
4. Injection downgradeSets injectionMode to "minimal"Drops runtime injections (workspace listing, temporal context, memory recall) to minimal set

After each tier, the reducer re-estimates tokens. If the estimate is within budget, the loop breaks and the provider call proceeds. The loop is bounded by maxAttempts (default 3).

Overflow Policy and Latest-Turn Compression

When all four reducer tiers are exhausted and the provider still rejects, the overflow policy resolver (context-overflow-policy.ts) determines the next action based on config and session interactivity:

Session TypeConfig PolicyAction
Interactive"summarize" (default)auto_compress_latest_turn — compress without asking
Non-interactive"truncate" (default)auto_compress_latest_turn — compress without asking
Any"drop"fail_gracefully — fall through to the final context-overflow fallback, which emits a conversation_error

When standard compaction has been exhausted and the provider still reports a context overflow, the recovery pipeline forces an emergency compaction of the latest turn with aggressive settings (force: true, minKeepRecentUserTurns: 0). The user is not prompted — compaction is always automatic. Users who want to opt out entirely can set contextWindow.overflowRecovery.interactiveLatestTurnCompression to "drop", which short-circuits to a graceful failure instead.

Config

All overflow recovery settings live under contextWindow.overflowRecovery in the assistant config schema:

Config keyDefaultPurpose
enabledtrueMaster switch for the overflow recovery pipeline
safetyMarginRatio0.05Fraction of maxInputTokens reserved as safety margin for preflight budget
maxAttempts3Maximum reducer iterations per overflow event (both preflight and convergence)
interactiveLatestTurnCompression"summarize"Policy for interactive sessions: "summarize", "truncate", or "drop"
nonInteractiveLatestTurnCompression"truncate"Policy for non-interactive sessions: same options

Key Source Files

FilePurpose
src/plugins/defaults/compaction/context-overflow-reducer.tsTiered reducer: four-tier pipeline with idempotent steps and cumulative state
src/daemon/context-overflow-policy.tsOverflow policy resolver: maps config + interactivity to concrete action
src/daemon/conversation-agent-loop.tsIntegration: preflight budget check, convergence loop, emergency compaction
src/config/core-schema.tsContextOverflowRecoveryConfigSchema with defaults and validation

Task Routing — Voice Source Bypass and Escalation

When a task is submitted via task_submit, the daemon classifies it to determine routing. Voice-sourced tasks and built-in slash commands bypass the classifier entirely for lower latency and more predictable routing.

graph TB
    subgraph "Task Submission"
        SUBMIT["task_submit<br/>task, source?"]
    end

    subgraph "Routing Decision"
        SLASH_CHECK{"Built-in slash command?<br/>(resolveSlash)"}
        VOICE_CHECK{"source === 'voice'?"}
        CLASSIFIER["Classifier<br/>Haiku-4.5 tool call<br/>+ heuristic fallback"]
        CU_ROUTE["Route: computer_use<br/>→ CU session"]
        QA_ROUTE["Route: text_qa<br/>→ Text Q&A session"]
    end

    subgraph "Text Q&A Session"
        TEXT_TOOLS["Tools: sandbox file_* / bash,<br/>host_file_* / host_bash,<br/>ui_show, ...<br/>+ dynamically projected skill tools<br/>(browser_* via bundled browser skill,<br/>computer_use_* via bundled computer-use skill)"]
    end

    SUBMIT --> SLASH_CHECK
    SLASH_CHECK -->|"Yes (/models, /status, etc.)"| QA_ROUTE
    SLASH_CHECK -->|"No"| VOICE_CHECK
    VOICE_CHECK -->|"Yes"| QA_ROUTE
    VOICE_CHECK -->|"No"| CLASSIFIER
    CLASSIFIER -->|"computer_use"| CU_ROUTE
    CLASSIFIER -->|"text_qa"| QA_ROUTE

    QA_ROUTE --> TEXT_TOOLS
    TEXT_TOOLS -.->|"computer_use_* actions<br/>forwarded via HostCuProxy"| CU_ROUTE

Action Execution Hierarchy

The text_qa system prompt includes an action execution hierarchy that guides tool selection toward the least invasive method:

PriorityMethodToolWhen to use
BESTSandboxed filesystem/shellfile_*, bashWork that can stay isolated in sandbox filesystem
BETTERExplicit host filesystem/shellhost_file_*, host_bashHost reads/writes/commands that must touch the real machine
GOODHeadless browserbrowser_* (bundled browser skill)Web automation, form filling, scraping (background)
LAST RESORTForeground computer usecomputer_use_* (bundled computer-use skill)Only on explicit user request ("go ahead", "take over")

Computer-use tools are proxy tools provided by the bundled computer-use skill, preactivated via preactivatedSkillIds in desktop sessions. Each tool forwards actions to a compatible connected desktop client via HostCuProxy, which handles request/resolve proxying, step counting, loop detection, and observation formatting within the unified agent loop. Skill projection filters platform-specific actions from unsupported clients. Drag, app launching, and AppleScript remain macOS-only. These tools are not core-registered at daemon startup; they exist only through skill projection.

Sandbox Filesystem and Host Access

graph TB
    CALL["Model tool call"] --> EXEC["ToolExecutor"]

    EXEC -->|"file_read / file_write / file_edit"| SB_FILE_TOOLS["Sandbox file tools<br/>path-scoped to sandbox root"]
    SB_FILE_TOOLS --> SB_FS

    EXEC -->|"bash"| WRAP["buildShellInvocation()<br/>(packages/environments/shell)"]
    WRAP --> SHELL["bash -c in $CADET_WORKSPACE_DIR<br/>sanitized env (safe-env allowlist)"]
    SHELL --> SB_FS["Workspace filesystem root<br/>$CADET_WORKSPACE_DIR"]
    SHELL -.->|containerized deployments| CONTAINER["Container boundary<br/>(Docker / remote host) provides<br/>OS-level isolation"]

    EXEC -->|"host_file_* / host_bash"| HOST_TOOLS["Host-target tools<br/>(unchanged by backend choice)"]
    EXEC -->|"computer_use_* (skill-projected<br/>in CU sessions only)"| SKILL_CU_TOOLS["CU skill tools<br/>(bundled computer-use skill)"]
    HOST_TOOLS --> CHECK["Permission checker + trust-store"]
    SKILL_CU_TOOLS --> CHECK
    CHECK --> DEFAULTS["Default rules<br/>ask for host_* + computer_use_*"]
    CHECK -->|"allow"| HOST_EXEC["Execute on host filesystem / shell / computer control"]
    CHECK -->|"deny"| BLOCK["Blocked"]
    CHECK -->|"prompt"| PROMPT["confirmation_request<br/>executionTarget='host'"]
    PROMPT --> USER["User allow/deny<br/>optional allowlist/denylist save"]
    USER --> CHECK
  • Workspace scoping (software-enforced): file_read, file_write, and file_edit path-check every access against $CADET_WORKSPACE_DIR, including symlink resolution. bash spawns bash -c in the workspace directory with a sanitized environment and is not OS-confined on local installs; commands are gated by risk classification and the conversation's auto-approve threshold, with workspace path checks applied to allowlisted commands on non-containerized installs. Containerized deployments (cadet hatch --remote docker, remote hosts) get OS-level isolation from the container boundary. Workflow scripts run in a separate QuickJS-WASM sandbox with no ambient capabilities.
  • Host tools unchanged: host_bash, host_file_read, host_file_write, and host_file_edit always execute directly on the host regardless of deployment mode.
  • Sandbox defaults: file_* and bash execute within $CADET_WORKSPACE_DIR.
  • Host access is explicit: host_file_read, host_file_write, host_file_edit, and host_bash are separate tools.
  • Prompt defaults: host tools and computer_use_* skill-projected actions default to ask unless a trust rule allowlists/denylists them.
  • Browser tool defaults: all browser_* tools are auto-allowed by default via seeded allow rules at priority 100, preserving the frictionless UX from when browser was a core tool.
  • Confirmation payloads include executionTarget (sandbox or host) so clients can label where the action will run.

Slash Command Resolution

When a user message enters the daemon (via processMessage or the queue drain path), it passes through resolveSlash() before persistence or agent execution. Resolution uses direct string matching against a fixed set of built-in commands.

graph TB
    INPUT["User input"]
    RESOLVE{"resolveSlash()<br/>direct string matching"}
    PASSTHROUGH["Normal flow<br/>persist + agent loop"]
    HANDLED["Deterministic response<br/>assistant_text_delta + message_complete<br/>no agent loop"]

    INPUT --> RESOLVE
    RESOLVE -->|"kind: passthrough"| PASSTHROUGH
    RESOLVE -->|"kind: unknown<br/>(/models, /status, /commands)"| HANDLED

Key behaviors:

  • Built-in commands: /models, /status, and /commands are handled directly by resolveSlash(). A deterministic assistant_text_delta + message_complete is emitted. No message persistence or model call occurs.
  • Passthrough: Any input that does not match a built-in command passes through to the normal agent loop, including slash-like tokens that are not recognized.
  • Queue: Queued messages receive the same slash resolution.

Dynamic Skill Authoring — Tool Flow

The assistant can author, test, and persist new skills at runtime through a three-tool workflow. All operations target $CADET_WORKSPACE_DIR/skills/ (managed skills directory) and require explicit user confirmation.

graph TB
    subgraph "1. Evaluate (Sandbox)"
        SNIPPET["Model drafts<br/>TypeScript snippet"]
        EVAL_TOOL["evaluate_typescript_code<br/>───────────────<br/>RiskLevel: High<br/>Always sandboxed"]
        TEMP["Temp dir:<br/>workingDir/.cadet-eval/&lt;uuid&gt;"]
        WRAPPER["Wrapper runner<br/>imports snippet, calls<br/>default() or run()"]
        SANDBOX["wrapCommand()<br/>forced sandbox=true"]
        RESULT["JSON result:<br/>ok, exitCode, result,<br/>stdout, stderr,<br/>durationMs, timeout"]
    end

    subgraph "2. Persist (Filesystem)"
        SCAFFOLD["scaffold_cloud_skill<br/>───────────────<br/>RiskLevel: High<br/>Requires user consent"]
        MANAGED_STORE["cloud-store.ts<br/>───────────────<br/>validateCloudSkillId()<br/>buildSkillMarkdown()<br/>createCloudSkill()"]
        SKILL_DIR["$CADET_WORKSPACE_DIR/skills/&lt;id&gt;/<br/>SKILL.md (frontmatter + body)"]
    end

    subgraph "3. Load & Use"
        SKILL_LOAD["skill_load tool<br/>resolves from disk"]
        SESSION["Agent session<br/>uses skill instructions"]
    end

    subgraph "4. Delete"
        DELETE["delete_cloud_skill<br/>───────────────<br/>RiskLevel: High<br/>Requires user consent"]
        RM_DIR["rmSync skill directory"]
    end

    subgraph "Capability reseed"
        RESEED["SKILL.md mtime poll<br/>reseeds capability cards"]
    end

    SNIPPET --> EVAL_TOOL
    EVAL_TOOL --> TEMP
    TEMP --> WRAPPER
    WRAPPER --> SANDBOX
    SANDBOX --> RESULT
    RESULT -->|"ok=true + user consent"| SCAFFOLD

    SCAFFOLD --> MANAGED_STORE
    MANAGED_STORE --> SKILL_DIR

    SKILL_DIR --> RESEED
    SKILL_DIR --> SKILL_LOAD
    SKILL_LOAD --> SESSION

    DELETE --> RM_DIR
    RM_DIR --> RESEED

Key design decisions:

  • evaluate_typescript_code always forces sandbox.enabled = true regardless of global config.
  • Snippet contract: must export default or run with signature (input: unknown) => unknown | Promise<unknown>.
  • Managed-store writes are atomic (tmp file + rename) to prevent partial SKILL.md files.
  • After persist or delete, capability cards reseed from the SKILL.md set. The next turn continues in the same conversation.
  • macOS UI shows Inspect and Delete controls for managed skills only (source = "managed").
  • skill_load resolves the recursive include graph (via include-graph.ts) before emitting output. Missing children are listed as suggested skills without child <loaded_skill> markers; cycles still produce isError: true with no marker. Valid includes produce an "Included Skills (immediate)" metadata section showing child ID, name, description, and path.

Skills Authoring via HTTP

The Skills page in the macOS client can author managed skills through the daemon HTTP API without going through the agent loop:

  1. Draft (skills_draft): The client sends source text (with optional YAML frontmatter). The daemon parses frontmatter for metadata fields (skillId, name, description, emoji), fills missing fields via a latency-optimized LLM call, and falls back to deterministic heuristics if the provider is unavailable. Returns skills_draft_response with the complete draft.
  2. Create (skills_create): The client sends finalized skill metadata and body. The daemon calls createCloudSkill() from cloud-store.ts, auto-enables the skill in config, and broadcasts skills_state_changed.

Include Graph Validation

Skills can declare child relationships via the includes frontmatter field (a JSON array of skill IDs). When skill_load loads a parent skill, it attempts to resolve and auto-install missing includes before emitting output. Available includes are appended to the loaded skill output; unavailable includes are surfaced as suggestions instead of blocking the parent skill.

graph LR
    LOAD["skill_load(parent)"] --> CATALOG["loadSkillCatalog()"]
    CATALOG --> INDEX["indexCatalogById()"]
    INDEX --> AUTOINSTALL["Attempt catalog auto-install<br/>for missing includes"]
    AUTOINSTALL --> RESOLVE["collectAllMissing(rootId, index)<br/>+ validateIncludeCycles(rootId, index)"]
    RESOLVE -->|"ok + no missing child"| OUTPUT["Emit output +<br/>Included Skills (immediate)<br/>+ loaded_skill markers"]
    RESOLVE -->|"ok + missing child"| OUTPUT_MISSING["Emit parent output +<br/>Suggested Included Skills<br/>without child markers"]
    RESOLVE -->|"cycle detected"| ERR_CYCLE["isError: true<br/>no loaded_skill marker"]

Validation rules:

  • Missing children: Missing includes trigger catalog auto-install attempts. Any include still unavailable is listed under "Suggested Included Skills (not loaded)" and does not receive a <loaded_skill> marker.
  • Cycles: Three-state DFS (unseen → visiting → done) detects direct and indirect cycles. The error includes the cycle path.
  • Fail-closed cycles: Circular include chains still return isError: true with no <loaded_skill> marker.

Key constraint: Include metadata is advisory. Available included skills are appended to the parent output and receive explicit <loaded_skill> markers; unavailable included skills remain suggestions so the agent can search for and install them if the task needs their guidance or tools.

Source FilePurpose
assistant/src/skills/include-graph.tsindexCatalogById(), getImmediateChildren(), validateIncludes(), validateIncludeCycles(), traverseIncludes()
assistant/src/tools/skills/load.tsInclude resolution integration in skill_load execute path
assistant/src/config/skills.tsincludes field parsing from SKILL.md frontmatter
assistant/src/skills/cloud-store.tsincludes emission in buildSkillMarkdown()

Dynamic Skill Tool System — Runtime Tool Projection

Skills can expose custom tools via a TOOLS.json manifest alongside their SKILL.md. When a skill is activated during a session, its tools are dynamically loaded, registered, and made available to the agent loop. Browser, Gmail, Weather, and other capabilities are delivered as bundled skills rather than hardcoded tools. Browser tools (previously the core headless-browser tool) are now provided by the bundled browser skill with system default allow rules that preserve frictionless auto-approval.

Bundled Skill Retrieval Contract (CLI-First)

Config/status retrieval instructions in bundled SKILL.md files are CLI-first. Retrieval should flow through canonical cadet CLI surfaces (assistant config get for generic settings, secure credential surfaces for secrets, and domain reads where available) instead of direct gateway curl snippets or credential store lookups.

graph LR
    SKILL["SKILL.md retrieval instruction"] --> BASH["bash tool"]
    BASH --> CLI["assistant config get / secure credential surfaces / domain reads"]
    CLI --> GW["Gateway read route (when needed)"]
    GW --> RT["Runtime handler/config service"]

Rules enforced by guard tests:

  • Retrieval reads use bash + canonical CLI surfaces (assistant config get and domain read commands where available).
  • Direct gateway curl + manual bearer headers are for control-plane writes/actions, not retrieval reads.
  • Bundled skill docs must not instruct direct credential store lookups (security find-generic-password, secret-tool) for retrieval.
  • host_bash is not used for Cadet CLI retrieval commands unless intentionally allowlisted.
  • Outbound credentialed API calls use CES tools (make_authenticated_request, run_authenticated_command) so credential materialization happens in a separate process. Command output (stdout/stderr) is forwarded back to the assistant and may contain credential values if the command echoes them, so the isolation covers injection, not output. host_bash is available as a user-approved escape hatch but is outside the strong secrecy guarantee.

Skill Directory Structure

Each skill directory (bundled, managed, workspace, or extra) may contain:

skills/<skill-id>/
  SKILL.md          # Skill instructions (frontmatter + markdown body; optional includes: [...] for child skills)
  TOOLS.json        # Tool manifest (optional — skills without tools are instruction-only)
  tools/            # Executor scripts referenced by TOOLS.json
    my-tool.ts      # Exports run(input, context) → ToolExecutionResult

Bundled Skills

The following capabilities ship as bundled skills in assistant/src/config/bundled-skills/:

Skill IDToolsPurpose
browserbrowser_navigate, browser_snapshot, browser_screenshot, browser_close, browser_click, browser_type, browser_press_key, browser_wait_for, browser_extract, browser_fill_credentialHeadless browser automation: web scraping, form filling, interaction (previously core-registered as headless-browser; now skill-provided with default allow rules)
gmailGmail search, archive, send, etc.Email management via OAuth2 integration
computer-usecomputer_use_observe, computer_use_click, computer_use_type_text, computer_use_key, computer_use_scroll, computer_use_drag, computer_use_wait, computer_use_open_app, computer_use_run_applescript, computer_use_sequence, computer_use_done, computer_use_respondComputer-use proxy tools preactivated via preactivatedSkillIds in desktop sessions. Each tool forwards actions to a compatible connected desktop host via HostCuProxy, which handles request/resolve proxying, step counting, loop detection, and observation formatting in the same agent loop.
weatherget-weatherFetch current weather data
app-builderapp_create, app_delete, app_refresh, app_generate_iconDynamic app authoring: create and manage persistent apps; file editing uses generic file tools plus app_refresh (activated via skill_load app-builder; app_open remains a core proxy tool)
self-upgrade(instruction-only)Self-improvement workflow
start-the-day(instruction-only)Morning briefing routine

Activation and Projection Flow

graph TB
    subgraph "Activation Sources"
        MARKER["&lt;loaded_skill id=&quot;...&quot; /&gt;<br/>marker in conversation history"]
        CONFIG["Config / session<br/>preactivatedSkillIds"]
    end

    subgraph "Per-Turn Projection (conversation-skill-tools.ts)"
        DERIVE["deriveActiveSkills(history)<br/>scan all messages for markers"]
        UNION["Union: context-derived ∪ preactivated"]
        DIFF["Diff vs previous turn"]
        UNREGISTER["unregisterSkillTools(removedId)<br/>tear down stale tools"]
        CATALOG["loadSkillCatalog()<br/>bundled + managed + workspace + extra"]
        LOAD_MANIFEST["loadManifestForSkill()<br/>read TOOLS.json from skill dir"]
        FACTORY["createSkillToolsFromManifest()<br/>→ Tool[] with origin='skill'"]
        REGISTER["registerSkillTools(tools)<br/>add to global tool registry"]
        PROJECTION["SkillToolProjection<br/>{toolDefinitions, allowedToolNames}"]
    end

    subgraph "Agent Loop (loop.ts)"
        RESOLVE["resolveTools(history) callback<br/>merges base tools + projected skill tools"]
        PROVIDER["LLM Provider<br/>receives full tool list"]
    end

    MARKER --> DERIVE
    CONFIG --> UNION
    DERIVE --> UNION
    UNION --> DIFF
    DIFF -->|"removed IDs"| UNREGISTER
    UNION --> CATALOG
    CATALOG --> LOAD_MANIFEST
    LOAD_MANIFEST --> FACTORY
    FACTORY --> REGISTER
    REGISTER --> PROJECTION
    PROJECTION --> RESOLVE
    RESOLVE --> PROVIDER

Internal preactivation: Some bundled skills are preactivated programmatically rather than by model discovery. For example, desktop sessions set preactivatedSkillIds: ['computer-use'], causing projectSkillTools() to load the eligible computer_use_* tool definitions from the bundled skill's TOOLS.json on the first turn. These proxy tools forward actions to a compatible connected desktop client via HostCuProxy.

Skill Tool Execution

Skill tool executors are TypeScript scripts that export a run(input, context) function. Execution is routed based on the execution_target field in TOOLS.json:

graph TB
    CALL["Model tool_use call"] --> EXEC["ToolExecutor<br/>look up in registry"]
    EXEC --> CHECK{"tool.origin === 'skill'?"}
    CHECK -->|"No"| CORE["Core tool execution"]
    CHECK -->|"Yes"| RUNNER["runSkillToolScript()"]
    RUNNER --> TARGET{"execution_target?"}
    TARGET -->|"host"| HOST["Host Script Runner<br/>dynamic import + run()<br/>in-process execution"]
    TARGET -->|"sandbox"| SANDBOX["Sandbox Script Runner<br/>isolated subprocess<br/>wrapCommand() sandboxing"]

Permission Flow for Skill Tools

Skill-origin tools follow a stricter default permission model than core tools. Even if a skill tool declares risk: "low" in its manifest, the permission checker defaults to prompting the user unless a trust rule explicitly allows it. Additionally, high-risk tool invocations always prompt the user even when a matching allow rule exists.

graph TB
    TOOL_CALL["Skill tool invocation"] --> PERM["PermissionChecker"]
    PERM --> TRUST{"Matching trust rule<br/>in trust.json?"}
    TRUST -->|"Allow rule matches"| HRISK{"Risk level?"}
    HRISK -->|"Low / Medium"| ALLOW["Auto-allow"]
    HRISK -->|"High"| HPROMPT["Prompt user<br/>(high-risk always prompts)"]
    TRUST -->|"No rule matches"| ORIGIN{"tool.origin?"}
    ORIGIN -->|"core"| RISK["Normal risk-level logic<br/>Low=auto, Medium=check, High=prompt"]
    ORIGIN -->|"skill"| PROMPT["Always prompt user<br/>(default ask for skill tools)"]
    TRUST -->|"Deny rule matches"| DENY["Blocked"]

Inline Skill Command Expansion

Skills can embed dynamic shell output in their SKILL.md body using !command``tokens. Whenskill_load processes a skill containing these tokens, the commands are executed at load time through a sandboxed runner and their output is substituted inline. This enables externally authored skills to include project-specific context (e.g., directory listings, config values) without requiring manual edits.

Feature flag: inline-skill-commands (default: enabled). When disabled, loading a skill that contains !command`` tokens fails closed with an error rather than leaving raw tokens in the prompt.

Syntax and Parsing

The !command``syntax is parsed byparseInlineCommandExpansions() from the SKILL.md body after frontmatter extraction. The parser:

  • Extracts all !command`` tokens outside fenced code blocks (documentation examples in fenced blocks are ignored)
  • Assigns each token a stable placeholderId (0-indexed encounter order)
  • Rejects malformed tokens fail-closed: empty commands, nested backticks, and unmatched opening backticks produce InlineCommandExpansionError entries rather than best-effort expansions

Transitive Version Hash

When a skill contains inline command expansions, the permission system computes a transitive version hash (tv1:<sha256>) that covers the root skill and all its included children (DFS pre-order). The hash folds:

  1. Each visited skill ID (graph structure)
  2. Each visited skill's directory content hash (file changes)

Editing any file in the root skill or any included child invalidates the transitive hash, which forces re-approval. The hash is computed by computeTransitiveSkillVersionHash() and fails closed (TransitiveHashError) on missing children or cycles in the include graph.

Permission Gating (skill_load_dynamic:*)

Inline-command skill loads are classified High risk — in the gateway skill risk classifier (gateway/src/risk/skill-risk-classifier.ts), with a defense-in-depth elevation in check() for the gateway-unreachable path. The standard auto-approve threshold then governs them like any other high-risk action: they run without a prompt only at Full access (autoApproveUpTo: "high") and prompt at every level below it. There is no separate "always ask" override outside the threshold.

These guarantees sit alongside the threshold:

  • Escape hatch (guardian). A user trust rule that covers the load re-classifies its risk inside the gateway (arriving as matchType user_rule), so a guardian's own load runs without prompting at any threshold. (A trust rule does not lift the sensitive-tool capability floor, so a non-guardian actor's covered dynamic load is still escalated to the guardian — like any other sensitive tool.) Approvals are offered in a separate candidate namespace so a pinned rule can auto-approve a known-good version rather than falling through to the permissive default skill_load:* allow rule:
    1. skill_load_dynamic:<skill-id>@<transitive-hash> — version-pinned (most specific)
    2. skill_load_dynamic:<skill-id> — any-version
  • Guardian-only self-approval (sensitive-tool gate). An uncovered dynamic load is routed through the sensitive-tool gate (tools/tool-approval-handler.ts: isSensitiveTool → resolveSensitiveToolDecision). A non-guardian actor is escalated to the guardian via the capability floor — which no threshold, including Full access, can lift — while the guardian self-approves (proceed) and the threshold then governs (allow at Full access, prompt below). skill_load is otherwise not a side-effect tool, so this makes the dynamic case participate in the same capability floor as host/side-effect tools.
  • Non-interactive denial. An uncovered dynamic load in a session with no interactive client is denied outright — regardless of threshold, including Full access — because embedded shell must never run unattended without a covering rule. Enforced in tools/permission-checker.ts (lane B) via isDynamicSkillLoadInvocation, independent of the threshold decision.
graph TB
    LOAD["skill_load(selector)"] --> PARSE["Parse SKILL.md body"]
    PARSE --> CHECK{"Has !\x60command\x60<br/>tokens?"}
    CHECK -->|"No"| NORMAL["Normal skill_load:* candidate<br/>(auto-allowed)"]
    CHECK -->|"Yes"| FLAG{"inline-skill-commands<br/>flag enabled?"}
    FLAG -->|"No"| FAIL_FLAG["Fail closed:<br/>error returned"]
    FLAG -->|"Yes"| SOURCE{"Eligible source?<br/>(bundled/managed/workspace)"}
    SOURCE -->|"No (extra)"| FAIL_SOURCE["Fail closed:<br/>source not eligible"]
    SOURCE -->|"Yes"| HASH["Compute transitive hash"]
    HASH --> DYN["skill_load_dynamic:id@hash<br/>candidate emitted"]
    DYN --> PERM["PermissionChecker<br/>(classified High risk)"]
    PERM --> RULE{"Covering trust rule?"}
    RULE -->|"Allow"| RENDER["Execute + render"]
    RULE -->|"Deny"| DENY["Blocked"]
    RULE -->|"None"| SENS{"Sensitive-tool gate<br/>(lane A): can self-approve?"}
    SENS -->|"No (non-guardian)"| ESCALATE["Escalate to guardian<br/>(capability floor)"]
    SENS -->|"Yes (guardian)"| INTERACTIVE{"Interactive client?"}
    INTERACTIVE -->|"No"| DENY
    INTERACTIVE -->|"Yes"| THRESH{"autoApproveUpTo"}
    THRESH -->|"Full access"| RENDER
    THRESH -->|"Below full"| PROMPT["Prompt guardian"]

Sandbox-Only Execution

Inline commands are executed through runInlineCommand(), a purpose-built sandbox runner with strict security constraints:

  • Sandbox enforced: The sandbox is always enabled with networkMode: "off" — no outbound network connections
  • Sanitized environment: Uses buildSanitizedEnv() — no API keys, tokens, credentials, gateway URLs, or workspace paths in the environment
  • No host fallback: Unlike the general bash tool, there is no fallback to host execution when the sandbox is unavailable
  • No credential proxy: No CES client, no credential materialization
  • Timeout: 10-second wall-clock limit (killed with SIGKILL on timeout)
  • Output cap: 20,000 characters maximum (truncated with [output truncated] marker)
  • Binary rejection: Output with >10% non-printable characters (after ANSI stripping) is rejected
  • Stdout only: stderr is discarded; ANSI escape sequences are stripped from stdout

The runner returns a deterministic InlineCommandResult with machine-readable failure reasons (timeout, non_zero_exit, binary_output, spawn_failure) — raw stderr is never surfaced.

Rendering Flow

The renderInlineCommands() function processes expansions sequentially (not in parallel) to maintain deterministic order. Each !command`` token is replaced with an XML-wrapped result:

  • Success: <inline_skill_command index="N">...output...</inline_skill_command>
  • Failure: <inline_skill_command index="N">[inline command unavailable: <reason>]</inline_skill_command>

Rendering applies at two levels during skill_load:

  1. Root skill: If the loaded skill has inline expansions, they are rendered before the skill body is emitted. A root skill with inline commands that fail the feature-flag or source-eligibility check returns an error (fail closed, no <loaded_skill> marker).
  2. Included children: Each included child skill's body is rendered independently. A render failure in one child does not prevent sibling rendering — the failed child's body falls back to raw (unexpanded) text with a warning log.

v1 Source Restriction

In the initial release, only skills from bundled, managed, and workspace sources are eligible for inline command expansion. Skills from extra (third-party) roots are explicitly rejected with an error message. The INLINE_COMMAND_ELIGIBLE_SOURCES set in load.ts enforces this restriction. Unknown or future source types also fail closed.

Fail-Closed Behavior Summary

Every layer in the pipeline defaults to rejection rather than silent degradation:

LayerFailure modeBehavior
ParserMalformed token (empty, nested backtick, unmatched)Logged as error, not expanded
Feature flagFlag disabledskill_load returns error, no <loaded_skill> marker
Source checkextra or unknown sourceskill_load returns error, no <loaded_skill> marker
Transitive hashMissing child or cycle in include graphTransitiveHashError thrown, permission check fails
PermissionNo trust rule and non-interactiveDenied (never silently auto-approved)
Sandbox runnerTimeout, non-zero exit, binary output, spawn failureDeterministic stub rendered, no raw stderr
Renderer (root)Feature flag off or ineligible sourceError returned from skill_load
Renderer (child)Exception during renderRaw body used, sibling rendering continues

Key Source Files

FileRole
assistant/src/skills/inline-command-expansions.tsparseInlineCommandExpansions() — parser for !command`` tokens
assistant/src/skills/inline-command-runner.tsrunInlineCommand() — sandbox-only command executor
assistant/src/skills/inline-command-render.tsrenderInlineCommands() — token replacement and XML wrapping
assistant/src/skills/transitive-version-hash.tscomputeTransitiveSkillVersionHash() — hash covering root + included children
assistant/src/tools/skills/load.tsskill_load execute path — feature flag check, source check, render integration
assistant/src/permissions/checker.tsskill_load_dynamic:* candidate emission, allowlist options, High-risk elevation
assistant/src/tools/permission-checker.tsNon-interactive denial of uncovered dynamic loads (isDynamicSkillLoadInvocation)
meta/feature-flags/feature-flag-registry.jsoninline-skill-commands flag definition

Key Source Files

FileRole
assistant/src/config/skills.tsSkill catalog loading: bundled, managed, workspace, extra directories
assistant/src/config/bundled-skills/Bundled skill directories (browser, gmail, computer-use, weather, etc.)
assistant/src/skills/tool-manifest.tsTOOLS.json parser and validator
assistant/src/skills/active-skill-tools.tsderiveActiveSkills() — scans history for <loaded_skill> markers
assistant/src/skills/include-graph.tsInclude graph builder: indexCatalogById(), validateIncludes(), validateIncludeCycles()
assistant/src/daemon/conversation-skill-tools.tsprojectSkillTools() — per-turn projection, register/unregister lifecycle
assistant/src/tools/skills/skill-tool-factory.tscreateSkillToolsFromManifest() — manifest entries to Tool objects
assistant/src/tools/skills/skill-script-runner.tsHost runner: dynamic import + run() call
assistant/src/tools/skills/sandbox-runner.tsSandbox runner: isolated subprocess execution
assistant/src/tools/registry.tsregisterSkillTools() / unregisterSkillTools() — global tool registry
assistant/src/permissions/checker.tsSkill-origin default-ask permission policy

Permission and Trust Security Model

The permission system controls which tool actions the agent can execute without explicit user approval. It supports two operating modes (workspace and strict), execution-target-scoped trust rules, and risk-based escalation to provide defense-in-depth against unintended or malicious tool execution.

Permission Evaluation Flow

graph TB
    TOOL_CALL["Tool invocation<br/>(toolName, input, policyContext)"] --> CLASSIFY["classifyRisk()<br/>→ Low / Medium / High"]
    CLASSIFY --> CANDIDATES["buildCommandCandidates()<br/>tool:target strings +<br/>canonical path variants"]
    CANDIDATES --> FIND_RULE["findHighestPriorityRule()<br/>iterate sorted rules:<br/>tool, scope, pattern (minimatch),<br/>executionTarget"]

    FIND_RULE -->|"Deny rule"| DENY["decision: deny<br/>Blocked by rule"]
    FIND_RULE -->|"Ask rule"| PROMPT_ASK["decision: prompt<br/>Always ask user"]
    FIND_RULE -->|"Allow rule / No match"| SANDBOX_CHECK{"sandboxAutoApprove?<br/>(bash + allowlisted +<br/>containerized)"}

    SANDBOX_CHECK -->|"yes"| AUTO_SANDBOX["decision: allow<br/>Sandbox auto-approve"]
    SANDBOX_CHECK -->|"no, has Allow rule"| RISK_CHECK{"Risk level?"}
    SANDBOX_CHECK -->|"no, no match"| NO_MATCH{"Fallback logic"}

    RISK_CHECK -->|"Low / Medium"| AUTO_ALLOW["decision: allow<br/>Auto-allowed by rule"]
    RISK_CHECK -->|"High"| RISK_THRESHOLD{"Risk-based<br/>threshold fallback"}

    NO_MATCH -->|"tool.origin === 'skill'"| PROMPT_SKILL["decision: prompt<br/>Skill tools always ask"]
    NO_MATCH -->|"workspace-scoped<br/>+ Low risk"| AUTO_WS["decision: allow<br/>Workspace-scoped auto-allow"]
    NO_MATCH -->|"otherwise"| RISK_THRESHOLD

    RISK_THRESHOLD{"risk ≤ autoApproveUpTo<br/>threshold?"}
    RISK_THRESHOLD -->|"yes"| AUTO_THRESHOLD["decision: allow<br/>within auto-approve threshold"]
    RISK_THRESHOLD -->|"no"| PROMPT_THRESHOLD["decision: prompt<br/>above auto-approve threshold"]

Auto-Approve Threshold

Auto-approve thresholds are gateway-owned: they live in the gateway's SQLite database and are read by the assistant via IPC (get_global_thresholds, get_conversation_threshold, get_contact_threshold). A contact ceiling is written via set_contact_threshold (gateway contacts set-risk-threshold or POST /v1/contacts). Users also control thresholds via the Settings UI (Permissions & Privacy tab) or the per-conversation risk tolerance picker. When the gateway is unreachable, the assistant defaults to "none" (Strict): fail-closed with no local fallback.

autoApproveUpToLow-risk toolsMedium-risk toolsHigh-risk tools
"none"PromptedPromptedPrompted
"low" (default)Auto-allowedPromptedPrompted
"medium"Auto-allowedAuto-allowedPrompted
"high"Auto-allowedAuto-allowedAuto-allowed

When set to "none", every tool invocation requires explicit approval. Explicit deny and ask rules always take precedence over the threshold.

Trust Rules (v3 Schema)

Rules are stored in ~/.cadet/protected/trust.json with version 3. Each rule can include the following fields:

FieldTypePurpose
idstringUnique identifier (UUID for user rules, default:* for system defaults)
toolstringTool name to match (e.g., bash, file_write, skill_load)
patternstringMinimatch glob pattern for the command/target string
scopestringPath prefix or everywhere — restricts where the rule applies
decisionallow | deny | askWhat to do when the rule matches
prioritynumberHigher priority wins; deny wins ties at equal priority
executionTargetstring?sandbox or host — restricts by execution context

Missing optional fields act as wildcards. A rule with no executionTarget matches any target.

Risk Classification and Escalation

The classifyRisk() function determines the risk level for each tool invocation:

ToolRisk levelNotes
file_read, web_search, skill_loadLowRead-only or informational
file_write, file_editMedium (default)Filesystem mutations
file_write, file_edit targeting skill source pathsHighisSkillSourcePath() detects managed/bundled/workspace/extra skill roots
host_file_write, host_file_edit targeting skill source pathsHighSame path classification, host variant
bash, host_bashVariesParsed via tree-sitter: low-risk programs = Low, high-risk programs = High, unknown = Medium
scaffold_cloud_skill, delete_cloud_skillHighSkill lifecycle mutations always high-risk
evaluate_typescript_codeHighArbitrary code execution
Skill-origin tools with no matching rulePrompted regardless of riskEven Low-risk skill tools default to ask

The escalation of skill source file mutations to High risk is a privilege-escalation defense: modifying skill source code could grant the agent new capabilities, so such operations always require explicit approval.

Skill Load Approval

The skill_load tool generates version-aware command candidates for rule matching:

  1. skill_load:<skill-id>@<version-hash> — matches version-pinned rules
  2. skill_load:<skill-id> — matches any-version rules
  3. skill_load:<raw-selector> — matches the raw user-provided selector

When autoApproveUpTo is "none", skill_load without a matching rule is always prompted. The allowlist options presented to the user include both version-specific and any-version patterns. Note: the system default allow rule skill_load:* (priority 100) globally allows all skill loads regardless of threshold (see "System Default Allow Rules" below).

Starter Approval Bundle

The starter bundle is an opt-in set of low-risk allow rules that reduces prompt noise, particularly when autoApproveUpTo is "none". It covers read-only tools that never mutate the filesystem or execute arbitrary code:

RuleToolPattern
file_readfile_readfile_read:**
globglobglob:**
grepgrepgrep:**
list_directorylist_directorylist_directory:**
web_searchweb_searchweb_search:**
web_fetchweb_fetchweb_fetch:**

Acceptance is idempotent and persisted as starterBundleAccepted: true in trust.json. Rules are seeded at priority 90 (below user rules at 100, above system defaults at 50).

System Default Allow Rules

In addition to the opt-in starter bundle, the permission system seeds unconditional default allow rules at priority 100 for two categories:

Rule IDToolPatternRationale
default:allow-skill_load-globalskill_loadskill_load:*Loading any skill is globally allowed — no prompt for activating bundled, managed, or workspace skills
default:allow-browser_navigate-globalbrowser_navigatebrowser_navigate:*Browser tools migrated from core to the bundled browser skill; default allow preserves frictionless UX
default:allow-browser_snapshot-globalbrowser_snapshotbrowser_snapshot:*(same)
default:allow-browser_screenshot-globalbrowser_screenshotbrowser_screenshot:*(same)
default:allow-browser_close-globalbrowser_closebrowser_close:*(same)
default:allow-browser_click-globalbrowser_clickbrowser_click:*(same)
default:allow-browser_type-globalbrowser_typebrowser_type:*(same)
default:allow-browser_press_key-globalbrowser_press_keybrowser_press_key:*(same)
default:allow-browser_wait_for-globalbrowser_wait_forbrowser_wait_for:*(same)
default:allow-browser_extract-globalbrowser_extractbrowser_extract:*(same)
default:allow-browser_fill_credential-globalbrowser_fill_credentialbrowser_fill_credential:*(same)

These rules are emitted by getDefaultRuleTemplates() in assistant/src/permissions/defaults.ts. Because they use priority 100 (equal to user rules), they take effect regardless of the autoApproveUpTo threshold. The skill_load rule means skill activation never prompts; the browser_* rules mean the browser skill's tools behave identically to the old core headless-browser tool from a permission standpoint.

Shell Command Identity and Allowlist Options

For bash and host_bash tool invocations, the permission system uses parser-derived action keys (via shell-identity.ts) instead of raw whitespace-split patterns. This produces more meaningful allowlist options that reflect the actual command structure.

Candidate building (buildShellCommandCandidates): The shell parser (tools/terminal/parser.ts) produces segments and operators. analyzeShellCommand() extracts segments, operators, opaque-construct flags, and dangerous patterns. deriveShellActionKeys() then classifies the command:

  • Simple action (optional setup-prefix segments like cd, export, pushd + exactly one action segment): Produces hierarchical action: keys. For example, cd /repo && gh pr view 5525 --json title yields candidates: the full original command text (cd /repo && gh pr view 5525 --json title), and action keys action:gh pr view, action:gh pr, action:gh (narrowest to broadest, max depth 3).
  • Complex command (pipelines with |, or multiple non-prefix action segments): Only the full original command text is returned as a candidate — no action keys.

Allowlist option ranking (buildShellAllowlistOptions): For simple actions, the prompt offers options ordered from most specific to broadest: the full original command text (exact match), then action keys from deepest to shallowest. For complex commands, only the full original command text is offered. This prevents over-generalization of pipelines into permissive rules.

Trust rule pattern format: Action keys use the action: prefix in trust rules (e.g., action:gh pr view). The trust store matches these via findHighestPriorityRule() against the candidate list produced by buildShellCommandCandidates().

Scope ordering: Scope options for all tools (including shell) are ordered from narrowest to broadest: project > parent directories > everywhere. The macOS chat UI uses a two-step flow for persistent rules: the user first selects the allowlist pattern, then selects the scope. This explicit scope selection replaces any silent auto-selection, ensuring the user always knows where the rule will apply.

Prompt UX

When a permission prompt is sent to the client (via confirmation_request SSE event), it includes:

FieldContent
toolNameThe tool being invoked
inputRedacted tool input (sensitive fields removed)
riskLevellow, medium, or high
executionTargetsandbox or host — where the action will execute
allowlistOptionsSuggested patterns for "always allow" rules
scopeOptionsSuggested scopes for rule persistence

The user can respond with: allow (one-time), always_allow (create allow rule), deny (one-time), or always_deny (create deny rule). In containerized environments, commands tagged with sandboxAutoApprove in their risk spec are auto-allowed via the approval policy's sandbox auto-approve check; non-allowlisted commands (network tools, runtimes, package managers) use the user's autoApproveUpTo threshold. All other risk-based decisions use the autoApproveUpTo threshold (default: "low") -- tools at or below the threshold are auto-allowed, those above are prompted.

Canonical Paths

File tool candidates include canonical (symlink-resolved) absolute paths via normalizeFilePath() to prevent policy bypass through symlinked or relative path variations. The path classifier (isSkillSourcePath()) also resolves symlinks before checking against skill root directories.

Key Source Files

FileRole
assistant/src/permissions/types.tsTrustRule, PolicyContext, RiskLevel, UserDecision types
assistant/src/permissions/checker.tsclassifyRisk(), check(), buildCommandCandidates(), allowlist/scope generation
assistant/src/permissions/shell-identity.tsanalyzeShellCommand(), deriveShellActionKeys(), buildShellCommandCandidates(), buildShellAllowlistOptions() — parser-based shell command identity and action key derivation
assistant/src/permissions/trust-store.tsRule persistence, findHighestPriorityRule(), execution-target matching, starter bundle
assistant/src/permissions/prompter.tsPrompt flow: confirmation_request (SSE) → confirmation_response (HTTP POST)
assistant/src/permissions/defaults.tsDefault rule templates (system ask rules for host tools, CU, etc.)
assistant/src/skills/version-hash.tscomputeSkillVersionHash() — deterministic SHA-256 of skill source files
assistant/src/skills/path-classifier.tsisSkillSourcePath(), normalizeFilePath(), skill root detection
assistant/src/tools/executor.tsToolExecutor — orchestrates risk classification, permission check, and execution
assistant/src/daemon/handlers/config.tshandleToolPermissionSimulate() — dry-run simulation handler

Permission Simulation (Tool Permission Tester)

The tool_permission_simulate HTTP endpoint lets clients dry-run a tool invocation through the full permission evaluation pipeline without actually executing the tool or mutating daemon state. The macOS Settings panel exposes this as a "Tool Permission Tester" UI.

Simulation semantics:

  • The request specifies toolName, input, and optional context overrides (workingDir, isInteractive).
  • The daemon runs classifyRisk() and check() against the live trust rules, then returns the decision (allow, deny, or prompt), risk level, reason, matched rule ID, and (when decision is prompt) the full promptPayload with allowlist/scope options.
  • Simulation-only allow/deny: A simulated allow or deny decision does not persist any state. No trust rules are created or modified.
  • Always-allow persistence: When the tester UI's "Always Allow" action is used, the client sends a separate add_trust_rule message that persists the rule to trust.json, identical to the existing confirmation flow.
  • Non-interactive override: When isInteractive is false, prompt decisions are converted to deny (no client available to approve).

Opportunistic Message Queue — Handoff Flow

When the daemon is busy generating a response, the client can continue sending messages. These are queued (FIFO, max 10) and drained automatically at safe checkpoints in the tool loop, not only at full completion.

sequenceDiagram
    participant User
    participant Chat as ChatView
    participant VM as ChatViewModel
    participant DC as DaemonClient
    participant Daemon as Daemon

    User->>Chat: send message while busy
    Chat->>VM: enqueue message
    VM->>DC: user_message
    DC->>Daemon: HTTP
    Daemon-->>DC: message_queued (position)
    DC-->>VM: show queue status

    Note over Daemon: Processing previous request...<br/>Reaches safe tool-loop checkpoint

    Daemon-->>DC: generation_handoff (conversationId, queuedCount)
    Note over Daemon: Daemon yields current generation

    Daemon-->>DC: message_dequeued
    DC-->>VM: next queued message now processing

    Note over Daemon: Processes queued message...

    Daemon-->>DC: assistant_text_delta (streaming)
    Daemon-->>DC: message_complete
    DC-->>VM: generation finished

Assistant Events — SSE Transport Layer

The assistant-events system provides a single, shared publish path that fans out to all connected clients via HTTP SSE. The ServerMessage payload is wrapped in an AssistantEvent envelope and serialised as JSON.

Data Flow

graph TB
    subgraph "Event Sources"
        direction TB
        SESSION["Session process<br/>(conversation-process.ts)"]
        HTTP_RUN["HTTP Run path<br/>(run-orchestrator.ts)"]
    end

    subgraph "Event Bus"
        HUB["AssistantEventHub<br/>(assistant-event-hub.ts)<br/>──────────────────────<br/>maxSubscribers: 100<br/>FIFO eviction on overflow<br/>Synchronous fan-out"]
    end

    subgraph "SSE Transport"
        SSE_ROUTE["SSE Route<br/>GET /v1/events[?conversationId=... | ?conversationKey=...]<br/>(events-routes.ts)<br/>──────────────────────<br/>ReadableStream + CountQueuingStrategy(16)<br/>Heartbeat every 30 s<br/>Slow-consumer shed"]
    end

    subgraph "Clients"
        MACOS["macOS App<br/>(DaemonClient / ServerMessage)"]
        WEB["Web / Remote clients<br/>(EventSource / fetch)"]
    end

    SESSION -->|"buildAssistantEvent()"| HUB
    HTTP_RUN -->|"buildAssistantEvent()"| HUB

    HUB -->|"subscriber callback"| SSE_ROUTE

    SSE_ROUTE --> MACOS
    SSE_ROUTE --> WEB

AssistantEvent Envelope

Every event published through the hub is wrapped in an AssistantEvent (defined in runtime/assistant-event.ts):

FieldTypeDescription
idstring (UUID)Globally unique event identifier
assistantIdstringLogical assistant identifier ("self" for HTTP runs)
conversationIdstring?Resolved conversation ID when available
emittedAtstring (ISO-8601)Server-side timestamp
messageServerMessageThe outbound message payload

SSE Frame Format

event: assistant_event\n
id: <uuid>\n
data: <JSON-serialised AssistantEvent>\n
\n

Keep-alive heartbeats (every 30 s by default):

: heartbeat\n
\n

Subscription Lifecycle

EventAction
GET /v1/events receivedHub subscribes eagerly before ReadableStream is created
Client disconnects / abortsreq.signal abort listener disposes subscription and closes stream
Client cancels readerReadableStream.cancel() disposes subscription and closes stream
New connection pushes over cap (100)Oldest subscriber evicted (FIFO); its onEvict callback closes its stream
Client buffer full (16 queued frames)desiredSize <= 0 guard sheds the subscriber immediately

Key Source Files

FileRole
assistant/src/runtime/assistant-event.tsAssistantEvent type, buildAssistantEvent() factory, SSE framing helpers
assistant/src/runtime/assistant-event-hub.tsAssistantEventHub class and process-level singleton
assistant/src/runtime/assistant-event-targeting.tsmatchesTargeting(), the targeting check live fanout and replay share
assistant/src/runtime/assistant-stream-state.tsPer-assistant seq counter and ring buffer behind reconnect and events/tail replay
assistant/src/runtime/routes/events-routes.tshandleSubscribeAssistantEvents(), the SSE route handler
assistant/src/daemon/server.tsSession event paths that publish to the hub (send → publishAssistantEvent)

Notification System — Signal-Driven Decision Engine

The notification module (assistant/src/notifications/) uses a signal-based architecture where producers emit free-form events and an LLM-backed decision engine determines whether, where, and how to notify the user. See assistant/src/notifications/README.md for the full developer guide.

Producer → NotificationSignal → Candidate Generation → Decision Engine (LLM) → Deterministic Checks → Broadcaster → Conversation Pairing → Adapters → Delivery
                                                              ↑                                                            ↓
                                                      Preference Summary                                    notification_conversation_created SSE event
                                                      Conversation Candidates                                (creation-only — not emitted on reuse)

Channel Policy Registry

assistant/src/channels/config.ts is the single source of truth for per-channel notification behavior. Every ChannelId must have an entry in the CHANNEL_POLICIES map (enforced at compile time via satisfies Record<ChannelId, ChannelNotificationPolicy>). Each policy defines:

  • deliveryEnabled — whether the channel can receive notification deliveries. The NotificationChannel type is derived from this flag: only channels with deliveryEnabled: true are valid notification targets.
  • conversationStrategy — how the notification pipeline materializes conversations for the channel:
    • start_new_conversation — creates a fresh conversation per delivery (e.g. cadet desktop/mobile conversations)
    • continue_existing_conversation — intended to append to an existing channel-scoped conversation; currently materializes a background audit conversation per delivery (e.g. Telegram)
    • not_deliverable — channel cannot receive notifications (e.g. phone)

Helper functions: getDeliverableChannels(), getChannelPolicy(), isNotificationDeliverable(), getConversationStrategy().

Conversation Pairing and Conversation Routing

Every notification delivery materializes a conversation + seed message before the adapter sends it (conversation-pairing.ts). The pairing function now accepts a conversationAction from the decision engine:

  • reuse_existing: Looks up the target conversation. If valid (exists with source: 'notification'), the seed message is appended to the existing conversation. If invalid, falls back to creating a new conversation with conversationDecisionFallbackUsed: true.
  • start_new (default): Creates a fresh conversation per delivery.

This ensures:

  1. Every delivery has an auditable conversation trail in the conversations table
  2. The macOS client can deep-link directly into the notification conversation
  3. Delivery audit rows in notification_deliveries carry conversation_id, message_id, conversation_strategy, conversation_action, conversation_target_id, and conversation_fallback_used columns

The pairing function (pairDeliveryWithConversation) is resilient — errors are caught and logged without breaking the delivery pipeline.

Notification Conversation Materialization

The notification pipeline uses a single conversation materialization path across producers:

  1. Canonical pipeline (emitNotificationSignal → decision engine → broadcaster → conversation pairing → adapters): The broadcaster pairs each delivery with a conversation, then dispatches a notification_intent SSE event via the Cadet adapter. The payload includes deepLinkMetadata (e.g. { conversationId, messageId }) so the macOS client can deep-link to the relevant context when the user taps the notification. When messageId is present, the client scrolls to that specific message within the conversation (message-level anchoring).
  2. Guardian bookkeeping (dispatchGuardianQuestion): Guardian dispatch creates the gateway guardian_requests row and records guardian_request_deliveries derived from pipeline delivery results and the per-dispatch onConversationCreated callback — there is no separate conversation-creation path.

Conversation Surfacing via notification_conversation_created (Creation-Only)

The notification_conversation_created SSE event is emitted only when a brand-new conversation is created by the broadcaster. Reusing an existing conversation does not trigger this event — the macOS client already knows about the conversation from the original creation. This is enforced in broadcaster.ts by gating on pairing.createdNewConversation === true.

When a new cadet notification conversation is created (strategy start_new_conversation), the broadcaster emits the event immediately (before waiting for slower channel deliveries like Telegram). This pushes the conversation to the macOS client so it can display the notification conversation in the sidebar and deep-link to it.

Conversation-Created Events

An SSE push event surfaces new conversations in the macOS client sidebar:

  • notification_conversation_created — Emitted by broadcaster.ts when a notification delivery creates a new cadet conversation (strategy start_new_conversation, createdNewConversation: true). Not emitted when a conversation is reused. Payload: { conversationId, title, sourceEventName }.

The daemon persists the conversation and its initial message before broadcasting, so a client that receives the event can fetch the conversation immediately. A guardian-sensitive conversation is announced only to the guardian's own connections.

Conversation Routing Decision Flow

The decision engine produces per-channel conversation actions using a candidate-driven approach:

  1. Candidate generation (conversation-candidates.ts): Queries recent notification-sourced conversations (24-hour window, up to 5 per channel) and enriches them with guardian context (pending request counts).
  2. LLM decision: The candidate set is serialized into the system prompt. The LLM chooses start_new or reuse_existing (with a candidate conversationId) per channel.
  3. Strict validation (validateConversationActions): Reuse targets must exist in the candidate set. Invalid targets are downgraded to start_new.
  4. Pairing execution: pairDeliveryWithConversation executes the conversation action — appending to an existing conversation on reuse, creating a new one otherwise.
  5. Creation-only gating: notification_conversation_created fires only on actual creation, not on reuse.
  6. Audit trail: Conversation actions are persisted in both notification_decisions.validation_results and notification_deliveries columns (conversation_action, conversation_target_id, conversation_fallback_used).

Guardian Call Conversation Affinity

When a guardian question originates from an active phone call (callSessionId present on the signal), the decision engine enforces conversation affinity so all questions within the same call land in one cadet conversation:

  • First question in a call (no conversationAffinityHint): enforceGuardianCallConversationAffinity forces start_new for the cadet channel, creating a dedicated conversation for the call.
  • Subsequent questions in the same call (affinity hint already set by dispatchGuardianQuestion): The guard is a no-op, and enforceConversationAffinity routes to reuse_existing using the hint's conversationId.

This guard runs before enforceConversationAffinity in the post-decision chain so the two cooperate: the first dispatch creates the conversation, and subsequent dispatches reuse it via the affinity hint that dispatchGuardianQuestion sets after observing the first delivery's conversationId.

Guardian Multi-Request Disambiguation in Reused Conversations

When the decision engine routes multiple guardian questions to the same conversation (via reuse_existing), those questions share a single conversation. The guardian disambiguates which question they are answering using request-code prefixes:

  • Single pending delivery: Matched automatically (single-match fast path).
  • Multiple pending deliveries: The guardian must prefix their reply with the 6-char hex request code (e.g. A1B2C3 yes, allow it). Case-insensitive matching.
  • No match: A disambiguation message is sent listing all active request codes.

This invariant is enforced identically on mac/cadet (conversation-process.ts) and Telegram (inbound-message-handler.ts). All disambiguation messages are generated through the guardian action message composer (LLM with deterministic fallback).

Reminder Routing Metadata

Reminders carry optional routingIntent (single_channel | multi_channel | all_channels) and free-form routingHints metadata. When a reminder fires, this metadata flows through the notification signal into a post-decision enforcement step (enforceRoutingIntent() in decision-engine.ts) that overrides the LLM's channel selection to match the requested coverage. This enables single-reminder fanout: one reminder can produce multi-channel delivery without duplicate reminders. See assistant/docs/architecture/scheduling.md for the full trigger-time data flow.

Channel Delivery

Notifications are delivered to five channel types. The daemon sends to external channels itself, calling each provider's API through the send modules in src/messaging/providers/<channel>/; nothing goes through the gateway.

  • Cadet (always connected): SSE via the daemon's broadcast mechanism. The CadetAdapter emits a notification_intent message with rendered copy and optional deepLinkMetadata (includes conversationId for conversation navigation and messageId for message-level scroll anchoring).
  • Platform (always connected): the CloudPushAdapter posts the notification to the cloud's /v1/assistants/{id}/push/dispatch/ endpoint for native mobile push. Without platform credentials the delivery is recorded as failed.
  • Telegram (when the guardian has a chat ID): the TelegramAdapter calls the Telegram Bot API through telegram-bot/send.ts. Approval cards carry inline keyboard buttons and fall back to plain text with typed-reply instructions if the rich send fails.
  • Slack (when the guardian's chat is a DM): the SlackAdapter posts through slack/send.ts. Only D-prefixed DM channels qualify, so a binding made from a shared channel never receives notifications.
  • Discord (when the guardian binding names a user): the DiscordAdapter opens the guardian's DM through the Discord REST API and sends through discord/send.ts.

Connected channels are resolved at signal emission time by getConnectedChannels() in emit-signal.ts, from the guardian delivery list (getGuardianDelivery()): cadet and platform are always included, and Telegram, Slack, and Discord are included only when that list gives the guardian a deliverable endpoint on the channel.

Key modules:

ModulePurpose
assistant/src/channels/config.tsChannel policy registry — single source of truth for per-channel notification behavior
assistant/src/notifications/emit-signal.tsSingle entry point for all producers; orchestrates the full pipeline; runs the source-active pre-decision gate
assistant/src/notifications/decision-engine.tsLLM-based routing decisions with deterministic fallback
assistant/src/notifications/deterministic-checks.tsPost-decision hard invariant checks (schema, dedupe, channel availability, copy quality)
assistant/src/notifications/broadcaster.tsDispatches decisions to channel adapters; emits notification_conversation_created SSE event (creation-only)
assistant/src/notifications/conversation-pairing.tsMaterializes conversation + message per delivery; executes conversation reuse decisions
assistant/src/notifications/conversation-candidates.tsBuilds per-channel candidate set of recent conversations for the decision engine
assistant/src/notifications/adapters/macos.tsCadet adapter — broadcasts notification_intent via SSE with deep-link metadata
assistant/src/notifications/adapters/platform.tsPlatform adapter: posts to the cloud push-dispatch endpoint for native mobile push
assistant/src/notifications/adapters/telegram.tsTelegram adapter: calls the Telegram Bot API through messaging/providers/telegram-bot/send.ts
assistant/src/notifications/adapters/slack.tsSlack adapter: posts to the guardian's DM through messaging/providers/slack/send.ts
assistant/src/notifications/adapters/discord.tsDiscord adapter: sends to the guardian's DM through messaging/providers/discord/send.ts
assistant/src/notifications/destination-resolver.tsResolves per-channel endpoints from the guardian delivery list (Telegram and Slack chat ID, Discord user ID)
assistant/src/notifications/copy-composer.tsTemplate-based fallback copy when LLM copy is unavailable
assistant/src/notifications/preference-extractor.tsDetects preference statements in conversation messages
assistant/src/notifications/preferences-store.tsCRUD for user notification preferences
assistant/src/config/bundled-skills/messaging/tools/send-notification.tsExplicit producer tool for user-requested notifications; emits signals into the same routing pipeline
assistant/src/calls/guardian-dispatch.tsGuardian question dispatch that reuses canonical notification pairing and records guardian delivery bookkeeping from pipeline results

Audit trail (SQLite): notification_events → notification_decisions (with conversationActions in validation results) → notification_deliveries (with conversation_id, message_id, conversation_strategy, conversation_action, conversation_target_id, conversation_fallback_used)

Configuration: llm.callSites.notificationDecision (decision engine) and llm.callSites.preferenceExtraction (preference extractor) in config.json. Both fall back to their shipped call-site defaults when unset.


Storage Summary

WhatWhereFormatORM/DriverRetention
API keyCES / encrypted file storeEncrypted binaryCES API / secure-keys.tsPermanent
Credential secretsCES / encrypted file storeEncrypted binarysecure-keys.ts wrapperPermanent (until deleted via tool)
Credential recordsCES <cesDataRoot>/metadata.jsonJSONCES RPC/HTTP + atomic file writePermanent (until deleted via tool)
Integration OAuth tokensCES / encrypted file store (via secure-keys.ts)Encrypted binaryTokenManager auto-refreshUntil disconnected or revoked
User preferencesUserDefaultsplistFoundationPermanent
Session logs~/Library/.../logs/session-*.jsonJSON per sessionSwift CodableUnbounded
Conversations & messages$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLite + WALDrizzle ORM (Bun)Permanent
Memory segments$CADET_WORKSPACE_DIR/data/db/assistant-memory.dbSQLiteDrizzle ORMPermanent
Extracted facts$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent, deduped
Embeddings$CADET_WORKSPACE_DIR/data/db/assistant-memory.dbJSON float arraysDrizzle ORMPermanent
Async job queue$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMCompleted jobs persist
Attachments$CADET_WORKSPACE_DIR/data/db/assistant.dbBase64 in SQLiteDrizzle ORMPermanent
Sandbox filesystem$CADET_WORKSPACE_DIRReal filesystem treeNode FS APIsPersistent across sessions
Tool permission rules~/.cadet/protected/trust.jsonJSONFile I/OPermanent
Web users & assistantsPostgreSQLRelationalDrizzle ORM (pg)Permanent
Media embed settings$CADET_WORKSPACE_DIR/config.json (ui.mediaEmbeds)JSONWorkspaceConfigIO (atomic merge)Permanent
Media embed MIME cacheIn-memory (ImageMIMEProbe)NSCache (500 entries)HTTP HEADEphemeral; cleared on app restart
Tasks & task runs$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent
Work items (Task Queue)$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; archived items retained
Recurrence schedules & runs$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; supports cron and RRULE syntax
Watchers & events$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent, cascade on watcher delete
Proxy CA cert + key{dataDir}/proxy-ca/PEM files (ca.pem, ca-key.pem)openssl CLIPermanent (10-year validity)
Proxy leaf certs{dataDir}/proxy-ca/issued/PEM files per hostnameopenssl CLI, cached1-year validity, re-issued on CA change
Proxy sessionsIn-memory (SessionManager)Map<ProxySessionId, ManagedSession>Manual lifecycleEphemeral; 5min idle timeout, cleared on shutdown
Call sessions, events, pending questions$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent, cascade on session delete
Active call controllersIn-memory (CallState)Map<callSessionId, CallController>Manual lifecycleEphemeral; cleared on call end or destroy
Guardian bindings$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; revoked bindings retained
Channel verification sessions$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; consumed/expired sessions retained
Guardian approval requests$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; decision outcome retained
Contact invites$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; token hash stored, raw token never persisted
Contacts & channels$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; revoked/blocked contacts retained
Notification events$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; deduplicated by dedupeKey
Notification decisions$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; FK to notification_events
Notification deliveries$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; FK to notification_decisions
Notification preferences$CADET_WORKSPACE_DIR/data/db/assistant.dbSQLiteDrizzle ORMPermanent; per-assistant conversational preferences

Sensitive Tool Output Placeholder Substitution

Some tool outputs contain values that must reach the user's final reply but should never be visible to the LLM (e.g., invite tokens). The system handles this with a three-stage pipeline:

  1. Directive extraction (src/tools/sensitive-output-placeholders.ts): Tool output may include <cadet-sensitive-output kind="invite_code" value="<raw>" /> directives. The executor strips directives, replaces raw values with deterministic placeholders (CADET_ASSISTANT_INVITE_CODE_<shortId>), and attaches sensitiveBindings metadata to the tool result.

  2. Placeholder-only model context: The agent loop stores placeholder->value bindings in a per-run substitutionMap. Tool results sent to the LLM contain only placeholders — the model generates conversational text referencing them without ever seeing the real values.

  3. Post-generation substitution (src/agent/loop.ts): Before emitting streamed text_delta events and before building the final assistantMessage, all placeholders are deterministically replaced with their real values. The substitution is chunk-safe for streaming (buffering partial placeholder prefixes across deltas).

Key files: src/tools/sensitive-output-placeholders.ts, src/tools/executor.ts (extraction hook), src/agent/loop.ts (substitution), src/config/bundled-skills/contacts/SKILL.md (invite flow adoption).

Notifications

For full notification developer guidance and lifecycle details, see assistant/src/notifications/README.md.

Assistant Identity Boundary

The daemon uses a single fixed internal scope constant — DAEMON_INTERNAL_ASSISTANT_ID ('self'), exported from src/runtime/assistant-scope.ts — for all assistant-scoped storage and routing within the daemon process. Public/external assistant IDs (e.g., those assigned during hatch, invite links, or platform registration) are an edge concern owned by the gateway and platform layers.

Boundary rule: Daemon code must never derive internal scoping decisions from externally-provided assistant IDs. When a daemon path needs an assistant scope and none is provided, it defaults to DAEMON_INTERNAL_ASSISTANT_ID. The gateway is responsible for mapping public assistant IDs to internal routing before forwarding requests to the daemon.

Key files:

FilePurpose
src/runtime/assistant-scope.tsExports DAEMON_INTERNAL_ASSISTANT_ID constant
src/__tests__/assistant-id-boundary-guard.test.tsGuard tests enforcing the identity boundary

Canonical Trust-Context Model

The guardian trust system uses a three-valued TrustClass — 'guardian', 'trusted_contact', or 'unknown' — as the single vocabulary for actor trust classification across all channels and runtime paths. There is no legacy actorRole concept; all trust decisions flow through TrustClass.

TrustContext (in src/daemon/conversation-runtime-assembly.ts) is the single runtime carrier for trust state on channel-originated turns. It carries trustClass, guardian identity fields, and requester metadata. The guardianPrincipalId field is typed as ?: string (optional but non-nullable) — a principal ID is present when a guardian binding exists but is never null.

Explicit trust gates: trustClass is a required field in ToolContext (in src/tools/types.ts). Every tool execution must carry a trust classification — the field is not optional. This ensures trust-gated tool policies (guardian control-plane restrictions, host-tool blocking for untrusted actors) cannot be bypassed by omitting the classification.

Guardian bindings (in src/channels/channel-verification-sessions.ts) always carry guardianPrincipalId: string as a required, non-null field. A binding without a principal ID is invalid and cannot be created.

Strict retry sweep parsing: The channel retry sweep (src/runtime/channel-retry-sweep.ts) uses parseTrustRuntimeContext() which validates trustClass against the canonical three-value set. There is no fallback to a legacy actorRole field — stored payloads that lack a valid trustClass are rejected deterministically to prevent silent privilege escalation. When trustCtx is entirely absent from a stored payload (pre-guardian events), the sweep synthesizes an explicit trustClass: 'unknown' context so that replay never proceeds without a trust classification.

Rollout note — legacy actorRole payloads: Previously failed events stored with only actorRole (no trustClass) will be marked as failed on each retry attempt and eventually dead-lettered after exhausting RETRY_MAX_ATTEMPTS. This is an intentional security tradeoff: replaying these events with inferred trust would violate the explicit-trust model. If legacy events need to be recovered, they should be repaired (adding a canonical trustClass to the stored payload) before replay via replayDeadLetters().

Key files:

FilePurpose
src/daemon/conversation-runtime-assembly.tsTrustContext type definition
src/tools/types.tsToolContext.trustClass (required trust gate)
src/runtime/channel-retry-sweep.tsStrict trustClass parser for retry sweep
src/channels/channel-verification-sessions.tsGuardianBinding with required guardianPrincipalId
src/__tests__/trust-context-guards.test.tsGuard tests enforcing trust-context type invariants

TTS Provider Abstraction (services.tts)

All text-to-speech functionality (in-app message playback and phone call voice) routes through a catalog-driven, provider-agnostic TTS abstraction. The architecture consists of six layers: a canonical provider catalog, a config schema, a config resolver, a provider registry, an explicit call-strategy abstraction, and a top-level synthesis orchestrator.

Canonical provider catalog (provider-catalog.ts): The provider catalog is the single source of truth for TTS provider identity and metadata on the assistant side. Every provider the system supports is declared as a TtsProviderCatalogEntry in the CATALOG array. Each entry captures the provider's unique ID (TtsProviderId), display name, telephony call mode (TtsCallMode: "native-twilio" or "synthesized-play"), static capabilities (supportsStreaming, supportedFormats), and secret requirements (credential store keys, display names, setup commands). Downstream modules query the catalog via getCatalogProvider(), listCatalogProviders(), or listCatalogProviderIds() instead of hardcoding provider IDs.

A parallel client artifact (meta/tts-provider-catalog.json) captures the subset of provider metadata needed by the native macOS client for display and setup UX. The client artifact must list exactly the same provider IDs as the assistant catalog. A CI consistency guard test (src/tts/__tests__/provider-catalog-consistency.test.ts) compares the two sets and fails if they drift.

Config schema (services.tts): The canonical config block lives at services.tts in the assistant config. The set of valid provider IDs and provider-specific config objects is catalog-driven — the Zod schema reads from the catalog rather than maintaining a separate hardcoded enum. It contains:

FieldTypeDefaultDescription
services.tts.providerenum"elevenlabs"Active TTS provider (must be a catalog-known provider ID); "cadet" = cloud-managed
services.tts.providers.<id>object(defaults)Provider-specific settings, one block per catalog entry

Provider-specific config is nested under services.tts.providers.<id>. All legacy top-level keys (elevenlabs.*, fishAudio.*) were removed by workspace migration 032 — only canonical services.tts paths are supported at runtime.

Config resolver (tts-config-resolver.ts): resolveTtsConfig(config) reads services.tts.provider to determine the active provider and returns a ResolvedTtsConfig containing the provider ID and its provider-specific config object from services.tts.providers.<id>. No legacy fallback logic exists.

Provider registry (provider-registry.ts): A runtime registry where provider adapters self-register at startup via registerTtsProvider(). Callers resolve a provider by ID with getTtsProvider(), which throws for unknown IDs so misconfiguration surfaces immediately. Built-in providers are registered in providers/register-builtins.ts during daemon initialization. The registration is catalog-checked — register-builtins.ts validates that each adapter's ID exists in the catalog.

Provider interface (types.ts): Every provider implements the TtsProvider interface:

  • id — unique provider identifier (matches TtsProviderId)
  • capabilities — static capability advertisement (supportsStreaming, supportedFormats)
  • synthesize(request) — buffer-oriented synthesis (required for all providers)
  • synthesizeStream?(request, onChunk) — optional chunk-level streaming for real-time use cases

The TtsUseCase discriminator ("phone-call" or "message-playback") lets providers tailor format, latency, and quality trade-offs per product surface.

Synthesis orchestrator (synthesize-text.ts): synthesizeText() is the top-level entry point. It resolves the globally configured provider via the config resolver, looks up the adapter in the registry, and delegates synthesis. Provider selection is always global — per-use-case policy only gates capabilities (e.g. format checks), never overrides the chosen provider.

Call strategy abstraction (tts-call-strategy.ts): The call strategy layer determines how a TTS provider integrates with the telephony path. Instead of inferring call behavior from runtime capabilities, resolveCallStrategy(config) reads the provider's callMode from the canonical catalog and returns a TtsCallStrategy with the provider ID and call mode. Two modes exist:

  • native-twilio — the text-token path: spoken text is sent via sendTextToken(), which the media-stream transport re-synthesizes through daemon TTS. Collapsing this mode into synthesized-play is a documented deferred follow-up.
  • synthesized-play — The assistant synthesises audio via the provider's HTTP API and streams it through the audio store / sendPlayUrl() path.

Phone call integration: Phone calls run on the media-stream transport, where the daemon synthesises speech via the effective TTS provider (the configured one after cloud-speech defaulting, resolveEffectiveSpeechProviders, the same substitution live voice makes) and transcodes it to mu-law 8 kHz frames (media-stream-output.ts). Each catalog entry declares mediaStreamPlayback.outputFormat (pcm, wav, or none); resolveTelephonyTtsCapability() (src/calls/telephony-tts-capability.ts) combines that field with credential availability into a playable / not-playable verdict, and the call TTS resolver falls back to a credentialed playable provider rather than producing silence.

Adding a new TTS provider (catalog-first checklist):

  1. Catalog entry — Add a new TtsProviderCatalogEntry to the CATALOG array in src/tts/provider-catalog.ts. Declare the provider's ID, display name, call mode, capabilities, and secret requirements.
  2. Client artifact — Add a corresponding entry to meta/tts-provider-catalog.json with the same provider ID, display name, and client-facing metadata (subtitle, setup mode, setup hint). The CI consistency guard will fail if this is skipped.
  3. Config schema — Add a new Zod object under TtsProvidersSchema in src/config/schemas/tts.ts for the provider's settings. The valid provider ID enum is catalog-driven.
  4. Provider adapter — Create src/tts/providers/<id>-provider.ts implementing TtsProvider with the appropriate capabilities and synthesize/synthesizeStream methods.
  5. Register the adapter — Add a factory entry for the provider to the providerFactories map in src/tts/providers/index.ts. The register-builtins.ts module iterates the catalog at startup and looks up each ID in this map — a missing entry is a fatal error.

No hardcoded enum edits are required — the TtsProviderId union in types.ts uses an open string union ((string & {})), the config schema reads valid IDs from the catalog, and the call strategy dispatches based on the catalog's callMode field. The registry, resolver, orchestrator, and call strategy all automatically pick up the new provider when selected via services.tts.provider.

Key source files:

FilePurpose
src/tts/provider-catalog.tsCanonical provider catalog: single source of truth for provider IDs and metadata
src/tts/types.tsCore domain types: TtsProvider, TtsProviderId, TtsCallMode, TtsUseCase, capabilities
src/tts/provider-registry.tsRuntime provider registry: register, lookup, list
src/tts/tts-config-resolver.tsConfig resolver: resolveTtsConfig() reads services.tts and returns resolved
src/tts/synthesize-text.tsTop-level orchestrator: synthesizeText() entry point
src/tts/providers/register-builtins.tsStartup registration of built-in providers (catalog-checked)
src/tts/providers/elevenlabs-provider.tsElevenLabs adapter implementation
src/tts/providers/fish-audio-provider.tsFish Audio adapter implementation
src/config/schemas/tts.tsZod schema for services.tts config block (catalog-driven valid provider IDs)
src/calls/tts-call-strategy.tsExplicit call strategy: resolves call mode from catalog
meta/tts-provider-catalog.jsonClient artifact: provider metadata for macOS settings UI
src/tts/__tests__/provider-catalog-consistency.test.tsCI guard: catalog vs client artifact provider ID consistency
src/workspace/migrations/032-tts-provider-unification.tsMigration that materialised canonical services.tts fields

Cloud Profiler Runtime

Managed cloud assistants use Bun's built-in CPU and heap profiling to capture runtime performance data. The profiler subsystem consists of a persistent on-disk run store, a retention/pruning sweep, and HTTP routes for remote management.

Bun profiler flags: Managed containers activate profiling by setting CADET_PROFILER_MODE (e.g. cpu, heap, cpu+heap) and CADET_PROFILER_RUN_ID environment variables before boot. Bun writes profiler artifacts (.cpuprofile, .heapsnapshot, markdown summaries) into the run directory.

Directory contract:

<workspace>/data/profiler/
  runs/
    <runId>/
      manifest.json          — run metadata (status, timestamps, byte count)
      profile.cpuprofile     — Bun CPU profile output
      profile-summary.md     — Bun-generated markdown summary
      *.heapsnapshot          — Bun heap profile output (when heap mode active)

Each profiler run lives in its own sub-directory under <workspace>/data/profiler/runs/<runId>/. A manifest.json in each run directory records metadata: status (active or completed), creation/update timestamps, and total byte count. The active run (identified by CADET_PROFILER_RUN_ID) is never pruned.

Retention budgets: On daemon startup and after explicit cleanup operations, the profiler sweep enforces three budgets:

BudgetEnv varDefault
Max total bytes across all runsCADET_PROFILER_MAX_BYTES500 MB
Max number of completed runs retainedCADET_PROFILER_MAX_RUNS10
Minimum free disk spaceCADET_PROFILER_MIN_FREE_MB200 MB

Completed runs are pruned oldest-first until all budgets are satisfied. The active run is never deleted, even if it alone exceeds the byte budget.

Runtime HTTP endpoints (gateway-only, internal.write scope):

MethodEndpointDescription
GET/v1/profiler/runsList all profiler runs with manifest metadata, sorted newest-first
GET/v1/profiler/runs/:runIdDetail view: manifest metadata, Bun markdown summary, active/retention state
POST/v1/profiler/runs/:runId/exportPackage a single run directory as a tar.gz bundle (same size cap as log exports)
DELETE/v1/profiler/runs/:runIdDelete a completed run; rejects active run deletion; recalculates disk budget

These endpoints allow the cloud (via vembda proxy) to enumerate, inspect, export, and clean up profiler runs without opening a shell on the assistant pod.

Key files:

FilePurpose
src/daemon/profiler-run-store.tsManifest management, rescan, retention sweep
src/runtime/routes/profiler-routes.tsHTTP route handlers for profiler run management
src/runtime/routes/archive-utils.tsShared tar.gz creation and size-cap utilities
src/config/env-registry.tsProfiler env var accessors (getProfilerRunId, getProfilerMode, etc.)
src/util/layout.tsProfiler directory path helpers
src/__tests__/profiler-run-store.test.tsProfiler store unit tests
src/__tests__/profiler-routes.test.tsProfiler HTTP route tests

LLM Provider Transport — OpenAI Responses API

OpenAI inference uses the Responses API (client.responses.stream()), not the Chat Completions API. OpenAI-compatible providers (OpenRouter, Fireworks, Ollama) continue to use the chat-completions transport.

Transport split:

Provider keyTransport classAPI surface
openaiOpenAIResponsesProviderclient.responses.stream()
openrouterOpenRouterProviderchat.completions.create()
fireworksFireworksProviderchat.completions.create()
ollamaOllamaProviderchat.completions.create()

The registry (src/providers/registry.ts) imports OpenAIResponsesProvider from openai/client.ts and wires it to the openai key. The chat-completions transport (OpenAIChatCompletionsProvider) remains available for OpenAI-compatible providers that implement the Chat Completions API.

Both transports produce the same ProviderResponse contract so downstream code (agent loop, context management, conversation history) is transport-agnostic.

Key files:

FilePurpose
src/providers/openai/responses-provider.tsResponses API transport (streaming, tool calls, usage mapping)
src/providers/openai/chat-completions-provider.tsChat Completions transport (OpenAI-compatible providers)
src/providers/openai/client.tsRe-exports both transports + validateOpenAIApiKey()
src/providers/registry.tsProvider initialization (wires openai → OpenAIResponsesProvider)
src/__tests__/openai-responses-cutover-guard.test.tsCI guard preventing chat-completions regression in OpenAI path