This document owns assistant-runtime architecture details. The repo-level architecture index lives in /ARCHITECTURE.md.
conversation_create.transport (HTTP) or /channels/inbound (channelId, optional hints, optional uxBrief).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.<channel_onboarding_playbook> and <onboarding_mode> context before provider calls, then strips both from persisted conversation history.assistant/src/runtime/trust-context-resolver.ts./channels/inbound (Telegram/WhatsApp path) before run orchestration.routeSetup) to seed call-time actor context.trustContext, and conversation runtime assembly includes actor context in the unified <turn_context> block (via buildUnifiedTurnContextBlock()) injected into provider-facing prompts.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.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 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.
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.
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):
| Claim | Type | Description |
|---|---|---|
iss | 'cadet-auth' | Issuer — always cadet-auth |
aud | 'cadet-daemon' or 'cadet-gateway' | Audience — which service the token targets |
sub | string | Subject — encodes principal type and identity (see patterns below) |
scope_profile | string | Named permission bundle (see profiles below) |
exp | number | Expiry timestamp (seconds since epoch) |
policy_epoch | number | Policy version — stale tokens are rejected with refresh_required |
iat | number | Issued-at timestamp |
jti | string | Unique token ID |
Subject patterns:
| Pattern | Principal Type | Description |
|---|---|---|
actor:<assistantId>:<actorPrincipalId> | actor | Desktop or CLI client |
svc:gateway:<assistantId> | svc_gateway | Gateway service (ingress, webhooks) |
svc:internal:<assistantId>:<sessionId> | svc_internal | Internal service connections |
svc:daemon:<identifier> | svc_daemon | Daemon service token (local) |
local:<assistantId>:<conversationId> | local | Local session or single-route grant |
Scope profiles:
| Profile | Scopes | Used by |
|---|---|---|
actor_client_v1 | chat.{read,write}, approval.{read,write}, settings.{read,write}, attachments.{read,write}, calls.{read,write}, feature_flags.{read,write} | Desktop, CLI clients |
contact_client_v1 | chat.write, approval.write, attachments.write | Non-guardian contact clients |
gateway_ingress_v1 | ingress.write, internal.write | Gateway channel inbound + webhook forwarding |
gateway_service_v1 | chat.{read,write}, settings.{read,write}, attachments.{read,write}, internal.write | Gateway service-to-daemon calls |
local_v1 | local.all | Local (loopback) conversation sessions |
oauth_proxy_v1 | oauth.proxy | OAuth passthrough proxy route only |
speech_relay_v1 | speech.relay | Daemon dial of the gateway speech relay only |
ui_page_v1 | settings.read | Served UI pages |
Identity lifecycle:
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.
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.
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:
| File | Purpose |
|---|---|
src/runtime/auth/types.ts | Core type definitions: TokenClaims, AuthContext, ScopeProfile, Scope, PrincipalType |
src/runtime/auth/token-service.ts | JWT signing, verification, and policy epoch management |
src/runtime/auth/credential-service.ts | Credential pair minting (access token + refresh token) |
src/runtime/auth/scopes.ts | Scope profile resolver (resolveScopeProfile) |
src/runtime/auth/context.ts | AuthContext builder from JWT claims |
src/runtime/auth/subject.ts | Subject string parser (parseSub) |
src/runtime/auth/middleware.ts | JWT bearer auth middleware (authenticateRequest) |
src/runtime/auth/route-policy.ts | Route-level scope/principal enforcement |
src/runtime/routes/guardian-bootstrap-routes.ts | POST /v1/guardian/init (initial JWT issuance) |
src/runtime/routes/guardian-refresh-routes.ts | POST /v1/guardian/refresh (token rotation) |
src/runtime/local-actor-identity.ts | resolveLocalAuthContext — synthetic local AuthContext + guardian principal lookup |
src/runtime/local-principal-trust.ts | resolveLocalPrincipalTrustContext — local-principal trust from the gateway guardian binding |
src/channels/channel-verification-sessions.ts | Guardian binding types, verification session management |
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.
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:
| Function | Purpose |
|---|---|
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:
guardian_requests_decide).approve_always is downgraded to approve_once for guardian-on-behalf requests (guardians cannot permanently allowlist tools for requesters).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):
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".POST /v1/guardian-actions/decision), routed through applyGuardianDecision.apr:<requestId>:<action>).Text fallback path (always available):
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.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:
| File | Purpose |
|---|---|
src/approvals/guardian-decision-primitive.ts | Unified decision application: downgrade, approval info capture, handleChannelDecision, record update, grant minting |
src/runtime/guardian-decision-types.ts | Shared types: GuardianDecisionPrompt, GuardianDecisionAction, buildDecisionActions, buildPlainTextFallback, ApplyGuardianDecisionResult |
src/runtime/routes/guardian-action-routes.ts | HTTP route handlers for GET /v1/guardian-actions/pending and POST /v1/guardian-actions/decision |
src/runtime/channel-approval-types.ts | Channel-facing approval action types and toApprovalActionOptions bridge |
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:
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.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:
| File | Purpose |
|---|---|
src/runtime/conversation-approval-overrides.ts | In-memory store: setConversationMode, setTimedMode, getEffectiveMode, clearMode, hasActiveOverride, clearAll |
src/permissions/types.ts | UserDecision type (includes allow_10m, allow_conversation, temporary_override), isAllowDecision() helper |
src/runtime/guardian-decision-types.ts | buildDecisionActions() — controls which temporary options appear in approval prompts |
src/tools/permission-checker.ts | Permission pipeline integration — checks temporary overrides before prompting |
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:
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).
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.
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.
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.
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:
| File | Purpose |
|---|---|
src/channels/gateway-guardian-requests.ts | Typed IPC client for the gateway-owned request/delivery lifecycle |
gateway/src/approvals/guardian-request-service.ts | Gateway service: create/reads/decide (CAS + ACL outcome in one transaction) |
src/approvals/guardian-decision-primitive.ts | Decision primitive: applyGuardianDecision + scoped grant minting |
src/approvals/guardian-request-resolvers.ts | Resolver registry: kind-specific daemon side-effect dispatch after the decide |
src/runtime/guardian-reply-router.ts | Shared inbound router: callback -> code -> NL classification pipeline |
src/runtime/routes/guardian-action-routes.ts | HTTP endpoints for prompt listing and decision submission |
src/runtime/routes/guardian-expiry-sweep.ts | Request expiry sweep (gateway CAS + daemon fan-out) |
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:
| Endpoint | Method | Description |
|---|---|---|
/v1/channel-verification-sessions | POST | Create 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/resend | POST | Resend the verification code for an active outbound session. Body: { channel } |
/v1/channel-verification-sessions | DELETE | Cancel all active sessions (inbound + outbound) for a channel. Body: { channel } |
/v1/channel-verification-sessions/revoke | POST | Cancel all active sessions and revoke the guardian binding. Body: { channel? } |
/v1/channel-verification-sessions/status | GET | Check 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:
guardian-verify-setup skill via skill_load.curl.Key Source Files:
| File | Purpose |
|---|---|
src/runtime/verification-outbound-actions.ts | Shared business logic for start/resend/cancel outbound verification |
src/runtime/routes/channel-verification-routes.ts | HTTP route handlers for unified verification session API (/v1/channel-verification-sessions, /revoke, /status) |
src/config/bundled-skills/guardian-verify-setup/SKILL.md | Skill 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 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:
| File | Purpose |
|---|---|
src/channels/gateway-guardian-requests.ts | Gateway-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.ts | Periodic 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.ts | Voice timeout handling: expires the request, sends expiry notices, injects [GUARDIAN_TIMEOUT] instruction for the generated voice response |
src/calls/guardian-action-sweep.ts | Expiry notice delivery to guardian destinations (cadet conversation message or direct channel reply) |
src/runtime/guardian-action-message-composer.ts | 2-tier text generation: daemon-injected LLM generator with deterministic fallback templates |
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):
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.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.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./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.GatewayInboundEvent with sourceChannel: "whatsapp" and conversationExternalId set to the sender's WhatsApp phone number (E.164).StringDedupCache (24-hour TTL).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/):
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.api.ts reads phone_number_id and access_token from the secure store and sends through the Cloud API /{phoneNumberId}/messages endpoint.send.ts splits text into 4096-character chunks, preferring newline and then whitespace boundaries.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.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 Metaaccess_token: System User or temporary access tokenapp_secret: app secret for webhook signature verificationwebhook_verify_token: token for the Meta webhook subscription handshakeThe 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.
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):
| Endpoint | Method | Description |
|---|---|---|
/v1/integrations/slack/channel/config | GET | Returns current config status: hasBotToken, hasAppToken, hasUserToken, connected, plus workspace metadata (teamId, teamName, botUserId, botUsername) |
/v1/integrations/slack/channel/config | POST | Validates and stores credentials. Body: { botToken?: string, appToken?: string, userToken?: string } |
/v1/integrations/slack/channel/config | DELETE | Clears 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 key | Content |
|---|---|
credential/slack_channel/bot_token | Slack bot token (used for chat.postMessage and auth.test) |
credential/slack_channel/app_token | Slack app token (xapp-..., used for Socket Mode apps.connections.open) |
credential/slack_channel/user_token | Optional. 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:
| File | Purpose |
|---|---|
src/daemon/handlers/config-slack-channel.ts | Business logic for get/set/clear Slack channel config |
src/runtime/routes/integrations/slack/channel.ts | HTTP route handlers for /v1/integrations/slack/channel/config |
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:
inbound-message-handler.ts) rejects the message and emits an ingress.access_request notification signal to the guardian.guardian-approval-interception.ts).access-request-decision.ts → channel-verification-service.ts).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 notifiedingress.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 activeHTTP API (for management):
| Endpoint | Method | Description |
|---|---|---|
/v1/contacts | GET | List contacts (filterable by role, search by query/channel/etc.) |
/v1/contacts | POST | Create or update a contact |
/v1/contacts/:id | GET | Get a contact by ID |
/v1/contacts/merge | POST | Merge two contacts |
/v1/contact-channels/:contactChannelId | PATCH | Update a contact channel's status/policy |
Key source files:
| File | Purpose |
|---|---|
src/runtime/routes/inbound-message-handler.ts | Ingress ACL, unknown-contact rejection, verification code interception |
src/runtime/routes/access-request-decision.ts | Guardian decision → verification session creation |
src/runtime/routes/guardian-approval-interception.ts | Routes guardian decisions (button + conversational) to access request handler |
src/runtime/channel-verification-service.ts | Verification session lifecycle, identity binding, rate limiting |
src/runtime/routes/contact-routes.ts | HTTP/IPC handlers for contacts, channels, and invites (invite ops relay to gateway IPC) |
src/runtime/invite-service.ts | Daemon-owned invite presentation (share link, guardian instruction) over gateway-minted invites |
src/contacts/contact-store.ts | Contact read queries — lookup, search, list, and channel operations |
src/memory/guardian-approvals.ts | Approval request persistence |
src/channels/channel-verification-sessions.ts | Verification challenge persistence |
src/config/bundled-skills/contacts/SKILL.md | Unified skill for contact management, access control, and 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):
guardian-invite-intent.ts detects the intent and rewrites the message to force-load the contacts skill.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>./start iv_<token> to the bot./start iv_<token> message at ingress (before any daemon forwarding) and redeems the token against its canonical ingress_invites row.invite_redeemed) to mirror the contact info locally.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:
| Channel | Status | Prerequisites |
|---|---|---|
| Telegram | Shipped | Bot username resolved from credential metadata or config |
| Voice | Shipped | Identity-bound voice code redemption via DTMF/speech in the call setup flow. Always-on canonical behavior with personalized friend/guardian name prompts. |
| Slack | Deferred | Needs DM-safe ingress — Socket Mode handles channel messages but DM-initiated invite flows need routing |
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:
POST /v1/contacts/invites with sourceChannel: "phone" and expectedExternalUserId (E.164 phone).invites_create IPC), which generates a cryptographically random numeric code and stores only its SHA-256 hash on the canonical ingress_invites row.voiceCode is returned in the creation response. The raw token is NOT returned for voice invites — redemption uses the identity-bound code flow exclusively.Call-time redemption subflow (invite_redemption):
unknown, no pending guardian challenge.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).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.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.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:
expectedExternalUserId must match the caller's E.164 number. An attacker with the code but the wrong phone number cannot redeem.invalid_or_expired) to prevent oracle attacks.Key source files:
| File | Purpose |
|---|---|
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.ts | Transport adapter registry with buildShareableInvite / extractInboundToken interface |
src/runtime/channel-invite-transports/telegram.ts | Telegram adapter — t.me/<bot>?start=iv_<token> deep links, /start iv_<token> extraction |
src/runtime/channel-invite-transports/voice.ts | Voice transport adapter — code-based redemption metadata |
src/daemon/guardian-invite-intent.ts | Intent detection — routes create/list/revoke requests into the contacts skill |
src/runtime/invite-service.ts | Daemon-owned invite presentation (share link, guardian instruction, channel handle) over gateway-minted invites |
src/runtime/routes/contact-routes.ts | HTTP/IPC invite handlers — relay mint/list/revoke/redeem to the gateway's invite IPC routes |
src/calls/call-setup-flow.ts | Transport-agnostic call setup flow — invite_redemption sub-flow (always-on canonical behavior) |
src/calls/gateway-invite-reader.ts | Gateway IPC read of the active voice invite for a caller identity |
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:
capturing_name state and prompts the caller for their name with a timeout.notifyGuardianOfAccessRequest creates a guardian request (kind: 'access_request') in the gateway and notifies the guardian via the notification pipeline.GuardianWaitController (awaiting_guardian_decision), which speaks hold messaging and heartbeat updates while polling the request status via the gateway client.applyGuardianDecision, which commits via guardian_requests_decide and dispatches daemon side effects to the access_request resolver in guardian-request-resolvers.ts.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.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)activate_member ACL outcome inside the decide transaction (no verification session needed since the caller is already on the line)mint_outbound_session outcome mints the 6-digit verification session inside the decide transactionKey source files:
| File | Purpose |
|---|---|
src/calls/call-setup-router.ts | Inbound call decision tree (routeSetup) |
src/calls/call-setup-flow.ts | Name capture, verification, and invite sub-flows over the media-stream transport |
src/calls/guardian-wait-controller.ts | Guardian approval wait — hold messaging, heartbeats, poll/timeout |
src/runtime/access-request-helper.ts | Creates the access request in the gateway and notifies the guardian |
src/approvals/guardian-decision-primitive.ts | applyGuardianDecision — unified decision primitive |
src/approvals/guardian-request-resolvers.ts | access_request resolver — voice direct activation, text-channel verification session |
src/runtime/trust-verdict-consumer.ts | Gateway verdict → caller trust classification (actorTrustContextFromVerdict) |
src/channels/gateway-guardian-requests.ts | Gateway client for request persistence and the atomic decide |
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.
| Role | Consumers | Boundaries required |
|---|---|---|
liveVoice | src/live-voice/live-voice-session.ts, src/live-voice/live-voice-credential-preflight.ts | streaming |
telephony | src/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.ts | streaming + batch |
watch | /v1/watch/stream (src/runtime/routes/watch-routes.ts) | streaming |
batch | inbound audio (routes/inbound-stages/transcribe-audio.ts), POST /v1/stt/transcribe-file, media-processing/transcribe skills | batch |
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:
| Boundary | Runtime | Provider (current) | Adapter module | Caller |
|---|---|---|---|---|
| Telephony (media-stream) | Daemon process (Twilio Media Streams WebSocket) | telephony role, else services.stt.provider | src/calls/media-stream-stt-session.ts, src/providers/speech-to-text/resolve.ts, streaming provider adapters | src/calls/media-stream-server.ts |
| Daemon batch | Daemon process (REST API to provider) | batch role, else services.stt.provider | src/stt/daemon-batch-transcriber.ts | src/runtime/routes/inbound-stages/transcribe-audio.ts |
| Conversation streaming | Daemon process (WebSocket-based) | dictation role, else services.stt.provider | src/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.ts | Web/Electron dictation client via gateway WS proxy |
| Live voice channel | Assistant process (gateway-authenticated WebSocket) | liveVoice role, else services.stt.provider | src/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 adapters | Web/Electron live voice client via /v1/live-voice |
| Client service-first | Web/Electron via gateway → daemon | dictation (transcribe) / batch (transcribe-file) role, else services.stt.provider | src/runtime/routes/stt-routes.ts | Web/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:
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).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:
| Module | Purpose |
|---|---|
src/calls/twilio-routes.ts | Voice webhook handler; generates <Connect><Stream> TwiML, enforces the inbound credential preflight |
src/calls/telephony-credential-preflight.ts | Combined STT + TTS credential-readiness resolver |
src/calls/media-stream-parser.ts | Twilio Media Streams protocol parser |
src/calls/voice-metrics.ts | Per-turn latency marks, shared with live voice |
src/calls/media-turn-detector.ts | Energy-based VAD turn detector for raw audio (batch mode) |
src/calls/media-stream-stt-session.ts | STT session — streaming/batch mode selection and transcription via services.stt |
src/calls/media-stream-audio-transcode.ts | Mu-law ↔ PCM16 codecs and resampling |
src/calls/call-transport.ts | Transport interface decoupling CallController from wire protocol |
src/calls/media-stream-output.ts | Output adapter for sending TTS audio back via Media Streams |
src/calls/media-stream-server.ts | WebSocket server binding media-stream lifecycle to call sessions and setup flows |
gateway/src/http/routes/twilio-media-websocket.ts | Gateway 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:
| Provider | Adapter | Mode | Mechanism |
|---|---|---|---|
| Deepgram | src/providers/speech-to-text/deepgram-realtime.ts | realtime-ws | Opens 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 Flux | src/providers/speech-to-text/deepgram-flux-realtime.ts | realtime-ws | Opens 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 Gemini | src/providers/speech-to-text/google-gemini-live-stream.ts | realtime-ws | Opens 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 Cloud | src/providers/speech-to-text/cadet-cloud-realtime.ts | realtime-ws | Wraps 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. |
| xAI | src/providers/speech-to-text/xai-realtime.ts | realtime-ws | Opens a WebSocket to wss://api.x.ai/v1/stt and normalizes xAI's transcript.partial/transcript.done payloads into partial/final events. |
| OpenAI Whisper | src/providers/speech-to-text/openai-whisper-stream.ts | incremental-batch | Whisper 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:
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.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.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):
/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.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).start() method opens the provider session.ready event (with provider field) is sent to the client, signaling that audio frames are accepted.audio frames (binary WebSocket frames or base64-encoded JSON) and a stop event when recording ends.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.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:
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.error event (with a normalized SttErrorCategory) followed by closed. The client marks the stream as failed and defers to batch STT.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:
| Category | Deepgram close codes | Google Gemini close codes | Client action |
|---|---|---|---|
auth | 1008, 4001 | 1008, 4001 | Mark stream failed; batch fallback |
rate-limit | 1013 | 1013 | Mark stream failed; batch fallback |
timeout | N/A (inactivity) | N/A (inactivity) | Mark stream failed; batch fallback |
provider-error | All other codes | All other codes / Live session error | Mark stream failed; batch fallback |
Key source files:
| File | Purpose |
|---|---|
src/stt/types.ts | StreamingTranscriber interface, SttStreamClientEvent/SttStreamServerEvent discriminated unions, ConversationStreamingMode type |
src/stt/stt-stream-session.ts | Runtime session orchestrator: lifecycle management, idle timeout, event forwarding with seq ordering |
src/providers/speech-to-text/deepgram-realtime.ts | Deepgram realtime-ws adapter: WebSocket to Deepgram /v1/listen, is_final/speech_final normalization |
src/providers/speech-to-text/google-gemini-live-stream.ts | Google Gemini realtime-ws adapter: bidirectional Live API session, serverContent.inputTranscription normalization |
src/providers/speech-to-text/provider-catalog.ts | Provider catalog with conversationStreamingMode per entry (realtime-ws, incremental-batch, none) |
src/providers/speech-to-text/resolve.ts | resolveStreamingTranscriber(): credential-aware factory for streaming adapters; resolveConversationStreamingSttCapability(): capability validator |
src/runtime/http-server.ts | Runtime WebSocket upgrade handler for /v1/stt/stream, session registry (activeSttStreamSessions), graceful shutdown |
gateway/src/http/routes/stt-stream-websocket.ts | Gateway WebSocket proxy: authenticates client, opens upstream WS to daemon with service token |
clients/web/src/domains/chat/voice/dictation-stream.ts | Web streaming dictation client: WebSocket session, event parsing, failure reporting |
clients/web/src/domains/chat/voice/voice-recording-store.ts | Web 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/:
| File | Boundary |
|---|---|
protocol.ts | Provider-agnostic client/server frame types, validation, binary audio parsing, and monotonic server-frame sequencing |
live-voice-session-manager.ts | Single-active-session lock, session factory context, and dispatch/release lifecycle |
live-voice-session.ts | Session orchestration: streaming STT, push-to-talk release, voice turn bridge callbacks, assistant text deltas, TTS, archive, metrics, interrupt, and close |
live-voice-tts.ts | Streaming TTS helper that resolves services.tts, requires TtsProvider.synthesizeStream(), and forwards audio chunks as tts_audio frames |
live-voice-archive.ts | Audio artifact creation/linking for user utterance and assistant response message IDs |
../calls/voice-metrics.ts | Shared 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.
notConfigured, serviceUnavailable, and error, so callers can deterministically trigger their fallback path./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:
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.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.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.docs/stt-provider-onboarding.md.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):
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.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.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:
| File | Purpose |
|---|---|
src/home/home-content-refresh.ts | Single-flight on-demand revalidation coordinator + SSE notify |
src/home/home-greeting.ts | Greeting generation (BTW side-chain, homeGreeting call site) |
src/home/home-greeting-cache.ts | Checkpoint-backed greeting cache with identity-hash invalidation |
src/home/suggested-prompts.ts | Prompt generation + checkpoint-backed cache + OAuth invalidation |
src/runtime/routes/home-feed-routes.ts | GET /v1/home/feed — cached read + fire-and-forget revalidation trigger |
src/runtime/routes/conversation-starter-routes.ts | On-demand starter refresh via memory job enqueue |
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):
~/.cadet/protected/feature-flags.json overrides (local) or gateway HTTP API (Docker/containerized) — written by the gateway's PATCH endpointdefaultEnabled — from the unified registry (meta/feature-flags/feature-flag-registry.json, filtered to scope: "assistant")false — unknown/undeclared flags with no override fail closedStorage: 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 keyskillFlagKey(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 Point | Module | Effect |
|---|---|---|
| 1. Client skill list | resolveSkillStates() in config/skill-state.ts | Skills 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 seeding | seedSkillGraphNodes() in plugins/defaults/memory/graph/capability-seed.ts | Skills with flag OFF are excluded from capability memory seeding. The model cannot discover them via semantic recall. |
3. skill_load tool | executeSkillLoad() in tools/skills/load.ts | If 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 projection | projectSkillTools() in daemon/conversation-skill-tools.ts | Even 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 skills | executeSkillLoad() in tools/skills/load.ts | When 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 gate | installSkill() in daemon/handlers/skills.ts | When 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:
| File | Purpose |
|---|---|
src/config/assistant-feature-flags.ts | Canonical resolver: isAssistantFeatureFlagEnabled(), registry loader |
src/config/skill-state.ts | skillFlagKey(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.ts | seedSkillGraphNodes() — enforcement point 2 |
src/tools/skills/load.ts | executeSkillLoad() — enforcement points 3 and 5 |
src/daemon/conversation-skill-tools.ts | projectSkillTools() — enforcement point 4 |
src/config/schema.ts | AssistantConfig Zod schema definition (feature flag values are no longer stored here) |
src/daemon/handlers/skills.ts | listSkills() — uses resolveSkillStates() for client responses; installSkill() — enforcement point 6 |
meta/feature-flags/feature-flag-registry.json | Unified feature flag registry (repo root) — all declared flags with scope, label, default values, and descriptions |
src/config/feature-flag-registry.json | Bundled copy of the unified registry for compiled binary resolution |
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
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.
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.
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
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.
The daemon emits two distinct error message types via SSE:
| Message type | Scope | Purpose | Payload |
|---|---|---|---|
conversation_error | Conversation-scoped | Typed, actionable failures during conversation runtime (e.g., provider network error, rate limit, API failure) | conversationId, code (typed enum), userMessage, retryable, debugDetails? |
error | Global | Generic, 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.
| Code | Meaning | Retryable |
|---|---|---|
PROVIDER_NETWORK | Unable to reach the LLM provider (connection refused, timeout, DNS) | Yes |
PROVIDER_RATE_LIMIT | LLM provider rate-limited the request (HTTP 429) | Yes |
MANAGED_USAGE_LIMIT | Cadet Cloud inference usage limit or quota was exceeded (HTTP 429) | Yes |
PROVIDER_API | Provider returned a server error (5xx) or retryable 4xx | Yes |
PROVIDER_BILLING | Invalid/expired API key or insufficient credits (HTTP 401, billing 4xx) | No |
CONTEXT_TOO_LARGE | Request exceeds the model's context window (HTTP 413, token limit) | No |
CONVERSATION_ABORTED | Non-user abort interrupted the request | Yes |
CONVERSATION_PROCESSING_FAILED | Catch-all for unexpected processing failures | No |
REGENERATE_FAILED | Failed to regenerate a previous response | Yes |
UNKNOWN | Unrecognized error that does not match any specific category | No |
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:
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).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.
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
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.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.conversationError and displays an actionable toast with a category-specific icon and accent color:
retryable is true): calls retryAfterConversationError(), which clears the error and sends a regenerate message to the daemon.debugDetails is non-nil): copies structured debug information to the clipboard for bug reports.dismissConversationError() to clear the error without retrying.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.
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.
The reducer (context-overflow-reducer.ts) applies four monotonically more aggressive tiers, each idempotent:
| Tier | Reduction | Effect |
|---|---|---|
| 1. Forced compaction | Emergency maybeCompact() with force: true, minKeepRecentUserTurns: 0 | Summarizes older history more aggressively than normal compaction |
| 2. Tool-result truncation | truncateToolResultsAcrossHistory() at 4,000 chars per result | Shrinks verbose tool outputs (shell, file reads) across all retained messages |
| 3. Media/file stubbing | stripMediaPayloadsForRetry() | 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 downgrade | Sets 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).
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 Type | Config Policy | Action |
|---|---|---|
| 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.
All overflow recovery settings live under contextWindow.overflowRecovery in the assistant config schema:
| Config key | Default | Purpose |
|---|---|---|
enabled | true | Master switch for the overflow recovery pipeline |
safetyMarginRatio | 0.05 | Fraction of maxInputTokens reserved as safety margin for preflight budget |
maxAttempts | 3 | Maximum 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 |
| File | Purpose |
|---|---|
src/plugins/defaults/compaction/context-overflow-reducer.ts | Tiered reducer: four-tier pipeline with idempotent steps and cumulative state |
src/daemon/context-overflow-policy.ts | Overflow policy resolver: maps config + interactivity to concrete action |
src/daemon/conversation-agent-loop.ts | Integration: preflight budget check, convergence loop, emergency compaction |
src/config/core-schema.ts | ContextOverflowRecoveryConfigSchema with defaults and validation |
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
The text_qa system prompt includes an action execution hierarchy that guides tool selection toward the least invasive method:
| Priority | Method | Tool | When to use |
|---|---|---|---|
| BEST | Sandboxed filesystem/shell | file_*, bash | Work that can stay isolated in sandbox filesystem |
| BETTER | Explicit host filesystem/shell | host_file_*, host_bash | Host reads/writes/commands that must touch the real machine |
| GOOD | Headless browser | browser_* (bundled browser skill) | Web automation, form filling, scraping (background) |
| LAST RESORT | Foreground computer use | computer_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.
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
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_bash, host_file_read, host_file_write, and host_file_edit always execute directly on the host regardless of deployment mode.file_* and bash execute within $CADET_WORKSPACE_DIR.host_file_read, host_file_write, host_file_edit, and host_bash are separate tools.computer_use_* skill-projected actions default to ask unless a trust rule allowlists/denylists them.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.executionTarget (sandbox or host) so clients can label where the action will run.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:
/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.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/<uuid>"]
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/<id>/<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.default or run with signature (input: unknown) => unknown | Promise<unknown>.SKILL.md files.SKILL.md set. The next turn continues in the same conversation.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.The Skills page in the macOS client can author managed skills through the daemon HTTP API without going through the agent loop:
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.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.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:
<loaded_skill> marker.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 File | Purpose |
|---|---|
assistant/src/skills/include-graph.ts | indexCatalogById(), getImmediateChildren(), validateIncludes(), validateIncludeCycles(), traverseIncludes() |
assistant/src/tools/skills/load.ts | Include resolution integration in skill_load execute path |
assistant/src/config/skills.ts | includes field parsing from SKILL.md frontmatter |
assistant/src/skills/cloud-store.ts | includes emission in buildSkillMarkdown() |
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.
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:
bash + canonical CLI surfaces (assistant config get and domain read commands where available).curl + manual bearer headers are for control-plane writes/actions, not retrieval reads.security find-generic-password, secret-tool) for retrieval.host_bash is not used for Cadet CLI retrieval commands unless intentionally allowlisted.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.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
The following capabilities ship as bundled skills in assistant/src/config/bundled-skills/:
| Skill ID | Tools | Purpose |
|---|---|---|
browser | browser_navigate, browser_snapshot, browser_screenshot, browser_close, browser_click, browser_type, browser_press_key, browser_wait_for, browser_extract, browser_fill_credential | Headless browser automation: web scraping, form filling, interaction (previously core-registered as headless-browser; now skill-provided with default allow rules) |
gmail | Gmail search, archive, send, etc. | Email management via OAuth2 integration |
computer-use | computer_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_respond | Computer-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. |
weather | get-weather | Fetch current weather data |
app-builder | app_create, app_delete, app_refresh, app_generate_icon | Dynamic 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 |
graph TB
subgraph "Activation Sources"
MARKER["<loaded_skill id="..." /><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 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"]
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"]
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.
The !command``syntax is parsed byparseInlineCommandExpansions() from the SKILL.md body after frontmatter extraction. The parser:
!command`` tokens outside fenced code blocks (documentation examples in fenced blocks are ignored)placeholderId (0-indexed encounter order)InlineCommandExpansionError entries rather than best-effort expansionsWhen 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:
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.
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:
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:
skill_load_dynamic:<skill-id>@<transitive-hash> — version-pinned (most specific)skill_load_dynamic:<skill-id> — any-versiontools/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.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"]
Inline commands are executed through runInlineCommand(), a purpose-built sandbox runner with strict security constraints:
networkMode: "off" — no outbound network connectionsbuildSanitizedEnv() — no API keys, tokens, credentials, gateway URLs, or workspace paths in the environmentbash tool, there is no fallback to host execution when the sandbox is unavailable[output truncated] marker)The runner returns a deterministic InlineCommandResult with machine-readable failure reasons (timeout, non_zero_exit, binary_output, spawn_failure) — raw stderr is never surfaced.
The renderInlineCommands() function processes expansions sequentially (not in parallel) to maintain deterministic order. Each !command`` token is replaced with an XML-wrapped result:
<inline_skill_command index="N">...output...</inline_skill_command><inline_skill_command index="N">[inline command unavailable: <reason>]</inline_skill_command>Rendering applies at two levels during skill_load:
<loaded_skill> marker).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.
Every layer in the pipeline defaults to rejection rather than silent degradation:
| Layer | Failure mode | Behavior |
|---|---|---|
| Parser | Malformed token (empty, nested backtick, unmatched) | Logged as error, not expanded |
| Feature flag | Flag disabled | skill_load returns error, no <loaded_skill> marker |
| Source check | extra or unknown source | skill_load returns error, no <loaded_skill> marker |
| Transitive hash | Missing child or cycle in include graph | TransitiveHashError thrown, permission check fails |
| Permission | No trust rule and non-interactive | Denied (never silently auto-approved) |
| Sandbox runner | Timeout, non-zero exit, binary output, spawn failure | Deterministic stub rendered, no raw stderr |
| Renderer (root) | Feature flag off or ineligible source | Error returned from skill_load |
| Renderer (child) | Exception during render | Raw body used, sibling rendering continues |
| File | Role |
|---|---|
assistant/src/skills/inline-command-expansions.ts | parseInlineCommandExpansions() — parser for !command`` tokens |
assistant/src/skills/inline-command-runner.ts | runInlineCommand() — sandbox-only command executor |
assistant/src/skills/inline-command-render.ts | renderInlineCommands() — token replacement and XML wrapping |
assistant/src/skills/transitive-version-hash.ts | computeTransitiveSkillVersionHash() — hash covering root + included children |
assistant/src/tools/skills/load.ts | skill_load execute path — feature flag check, source check, render integration |
assistant/src/permissions/checker.ts | skill_load_dynamic:* candidate emission, allowlist options, High-risk elevation |
assistant/src/tools/permission-checker.ts | Non-interactive denial of uncovered dynamic loads (isDynamicSkillLoadInvocation) |
meta/feature-flags/feature-flag-registry.json | inline-skill-commands flag definition |
| File | Role |
|---|---|
assistant/src/config/skills.ts | Skill 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.ts | TOOLS.json parser and validator |
assistant/src/skills/active-skill-tools.ts | deriveActiveSkills() — scans history for <loaded_skill> markers |
assistant/src/skills/include-graph.ts | Include graph builder: indexCatalogById(), validateIncludes(), validateIncludeCycles() |
assistant/src/daemon/conversation-skill-tools.ts | projectSkillTools() — per-turn projection, register/unregister lifecycle |
assistant/src/tools/skills/skill-tool-factory.ts | createSkillToolsFromManifest() — manifest entries to Tool objects |
assistant/src/tools/skills/skill-script-runner.ts | Host runner: dynamic import + run() call |
assistant/src/tools/skills/sandbox-runner.ts | Sandbox runner: isolated subprocess execution |
assistant/src/tools/registry.ts | registerSkillTools() / unregisterSkillTools() — global tool registry |
assistant/src/permissions/checker.ts | Skill-origin default-ask permission policy |
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.
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 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.
autoApproveUpTo | Low-risk tools | Medium-risk tools | High-risk tools |
|---|---|---|---|
"none" | Prompted | Prompted | Prompted |
"low" (default) | Auto-allowed | Prompted | Prompted |
"medium" | Auto-allowed | Auto-allowed | Prompted |
"high" | Auto-allowed | Auto-allowed | Auto-allowed |
When set to "none", every tool invocation requires explicit approval. Explicit deny and ask rules always take precedence over the threshold.
Rules are stored in ~/.cadet/protected/trust.json with version 3. Each rule can include the following fields:
| Field | Type | Purpose |
|---|---|---|
id | string | Unique identifier (UUID for user rules, default:* for system defaults) |
tool | string | Tool name to match (e.g., bash, file_write, skill_load) |
pattern | string | Minimatch glob pattern for the command/target string |
scope | string | Path prefix or everywhere — restricts where the rule applies |
decision | allow | deny | ask | What to do when the rule matches |
priority | number | Higher priority wins; deny wins ties at equal priority |
executionTarget | string? | sandbox or host — restricts by execution context |
Missing optional fields act as wildcards. A rule with no executionTarget matches any target.
The classifyRisk() function determines the risk level for each tool invocation:
| Tool | Risk level | Notes |
|---|---|---|
file_read, web_search, skill_load | Low | Read-only or informational |
file_write, file_edit | Medium (default) | Filesystem mutations |
file_write, file_edit targeting skill source paths | High | isSkillSourcePath() detects managed/bundled/workspace/extra skill roots |
host_file_write, host_file_edit targeting skill source paths | High | Same path classification, host variant |
bash, host_bash | Varies | Parsed via tree-sitter: low-risk programs = Low, high-risk programs = High, unknown = Medium |
scaffold_cloud_skill, delete_cloud_skill | High | Skill lifecycle mutations always high-risk |
evaluate_typescript_code | High | Arbitrary code execution |
| Skill-origin tools with no matching rule | Prompted regardless of risk | Even 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.
The skill_load tool generates version-aware command candidates for rule matching:
skill_load:<skill-id>@<version-hash> — matches version-pinned rulesskill_load:<skill-id> — matches any-version rulesskill_load:<raw-selector> — matches the raw user-provided selectorWhen 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).
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:
| Rule | Tool | Pattern |
|---|---|---|
file_read | file_read | file_read:** |
glob | glob | glob:** |
grep | grep | grep:** |
list_directory | list_directory | list_directory:** |
web_search | web_search | web_search:** |
web_fetch | web_fetch | web_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).
In addition to the opt-in starter bundle, the permission system seeds unconditional default allow rules at priority 100 for two categories:
| Rule ID | Tool | Pattern | Rationale |
|---|---|---|---|
default:allow-skill_load-global | skill_load | skill_load:* | Loading any skill is globally allowed — no prompt for activating bundled, managed, or workspace skills |
default:allow-browser_navigate-global | browser_navigate | browser_navigate:* | Browser tools migrated from core to the bundled browser skill; default allow preserves frictionless UX |
default:allow-browser_snapshot-global | browser_snapshot | browser_snapshot:* | (same) |
default:allow-browser_screenshot-global | browser_screenshot | browser_screenshot:* | (same) |
default:allow-browser_close-global | browser_close | browser_close:* | (same) |
default:allow-browser_click-global | browser_click | browser_click:* | (same) |
default:allow-browser_type-global | browser_type | browser_type:* | (same) |
default:allow-browser_press_key-global | browser_press_key | browser_press_key:* | (same) |
default:allow-browser_wait_for-global | browser_wait_for | browser_wait_for:* | (same) |
default:allow-browser_extract-global | browser_extract | browser_extract:* | (same) |
default:allow-browser_fill_credential-global | browser_fill_credential | browser_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.
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:
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).|, 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.
When a permission prompt is sent to the client (via confirmation_request SSE event), it includes:
| Field | Content |
|---|---|
toolName | The tool being invoked |
input | Redacted tool input (sensitive fields removed) |
riskLevel | low, medium, or high |
executionTarget | sandbox or host — where the action will execute |
allowlistOptions | Suggested patterns for "always allow" rules |
scopeOptions | Suggested 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.
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.
| File | Role |
|---|---|
assistant/src/permissions/types.ts | TrustRule, PolicyContext, RiskLevel, UserDecision types |
assistant/src/permissions/checker.ts | classifyRisk(), check(), buildCommandCandidates(), allowlist/scope generation |
assistant/src/permissions/shell-identity.ts | analyzeShellCommand(), deriveShellActionKeys(), buildShellCommandCandidates(), buildShellAllowlistOptions() — parser-based shell command identity and action key derivation |
assistant/src/permissions/trust-store.ts | Rule persistence, findHighestPriorityRule(), execution-target matching, starter bundle |
assistant/src/permissions/prompter.ts | Prompt flow: confirmation_request (SSE) → confirmation_response (HTTP POST) |
assistant/src/permissions/defaults.ts | Default rule templates (system ask rules for host tools, CU, etc.) |
assistant/src/skills/version-hash.ts | computeSkillVersionHash() — deterministic SHA-256 of skill source files |
assistant/src/skills/path-classifier.ts | isSkillSourcePath(), normalizeFilePath(), skill root detection |
assistant/src/tools/executor.ts | ToolExecutor — orchestrates risk classification, permission check, and execution |
assistant/src/daemon/handlers/config.ts | handleToolPermissionSimulate() — dry-run simulation handler |
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:
toolName, input, and optional context overrides (workingDir, isInteractive).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.allow or deny decision does not persist any state. No trust rules are created or modified.add_trust_rule message that persists the rule to trust.json, identical to the existing confirmation flow.isInteractive is false, prompt decisions are converted to deny (no client available to approve).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
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.
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
Every event published through the hub is wrapped in an AssistantEvent (defined in runtime/assistant-event.ts):
| Field | Type | Description |
|---|---|---|
id | string (UUID) | Globally unique event identifier |
assistantId | string | Logical assistant identifier ("self" for HTTP runs) |
conversationId | string? | Resolved conversation ID when available |
emittedAt | string (ISO-8601) | Server-side timestamp |
message | ServerMessage | The outbound message payload |
event: assistant_event\n
id: <uuid>\n
data: <JSON-serialised AssistantEvent>\n
\n
Keep-alive heartbeats (every 30 s by default):
: heartbeat\n
\n
| Event | Action |
|---|---|
GET /v1/events received | Hub subscribes eagerly before ReadableStream is created |
| Client disconnects / aborts | req.signal abort listener disposes subscription and closes stream |
| Client cancels reader | ReadableStream.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 |
| File | Role |
|---|---|
assistant/src/runtime/assistant-event.ts | AssistantEvent type, buildAssistantEvent() factory, SSE framing helpers |
assistant/src/runtime/assistant-event-hub.ts | AssistantEventHub class and process-level singleton |
assistant/src/runtime/assistant-event-targeting.ts | matchesTargeting(), the targeting check live fanout and replay share |
assistant/src/runtime/assistant-stream-state.ts | Per-assistant seq counter and ring buffer behind reconnect and events/tail replay |
assistant/src/runtime/routes/events-routes.ts | handleSubscribeAssistantEvents(), the SSE route handler |
assistant/src/daemon/server.ts | Session event paths that publish to the hub (send → publishAssistantEvent) |
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)
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().
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:
notification_deliveries carry conversation_id, message_id, conversation_strategy, conversation_action, conversation_target_id, and conversation_fallback_used columnsThe pairing function (pairDeliveryWithConversation) is resilient — errors are caught and logged without breaking the delivery pipeline.
The notification pipeline uses a single conversation materialization path across producers:
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).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.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.
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.
The decision engine produces per-channel conversation actions using a candidate-driven approach:
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).start_new or reuse_existing (with a candidate conversationId) per channel.validateConversationActions): Reuse targets must exist in the candidate set. Invalid targets are downgraded to start_new.pairDeliveryWithConversation executes the conversation action — appending to an existing conversation on reuse, creating a new one otherwise.notification_conversation_created fires only on actual creation, not on reuse.notification_decisions.validation_results and notification_deliveries columns (conversation_action, conversation_target_id, conversation_fallback_used).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:
conversationAffinityHint): enforceGuardianCallConversationAffinity forces start_new for the cadet channel, creating a dedicated conversation for the call.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.
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:
A1B2C3 yes, allow it). Case-insensitive matching.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).
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.
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.
CadetAdapter emits a notification_intent message with rendered copy and optional deepLinkMetadata (includes conversationId for conversation navigation and messageId for message-level scroll anchoring).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.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.SlackAdapter posts through slack/send.ts. Only D-prefixed DM channels qualify, so a binding made from a shared channel never receives notifications.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:
| Module | Purpose |
|---|---|
assistant/src/channels/config.ts | Channel policy registry — single source of truth for per-channel notification behavior |
assistant/src/notifications/emit-signal.ts | Single entry point for all producers; orchestrates the full pipeline; runs the source-active pre-decision gate |
assistant/src/notifications/decision-engine.ts | LLM-based routing decisions with deterministic fallback |
assistant/src/notifications/deterministic-checks.ts | Post-decision hard invariant checks (schema, dedupe, channel availability, copy quality) |
assistant/src/notifications/broadcaster.ts | Dispatches decisions to channel adapters; emits notification_conversation_created SSE event (creation-only) |
assistant/src/notifications/conversation-pairing.ts | Materializes conversation + message per delivery; executes conversation reuse decisions |
assistant/src/notifications/conversation-candidates.ts | Builds per-channel candidate set of recent conversations for the decision engine |
assistant/src/notifications/adapters/macos.ts | Cadet adapter — broadcasts notification_intent via SSE with deep-link metadata |
assistant/src/notifications/adapters/platform.ts | Platform adapter: posts to the cloud push-dispatch endpoint for native mobile push |
assistant/src/notifications/adapters/telegram.ts | Telegram adapter: calls the Telegram Bot API through messaging/providers/telegram-bot/send.ts |
assistant/src/notifications/adapters/slack.ts | Slack adapter: posts to the guardian's DM through messaging/providers/slack/send.ts |
assistant/src/notifications/adapters/discord.ts | Discord adapter: sends to the guardian's DM through messaging/providers/discord/send.ts |
assistant/src/notifications/destination-resolver.ts | Resolves per-channel endpoints from the guardian delivery list (Telegram and Slack chat ID, Discord user ID) |
assistant/src/notifications/copy-composer.ts | Template-based fallback copy when LLM copy is unavailable |
assistant/src/notifications/preference-extractor.ts | Detects preference statements in conversation messages |
assistant/src/notifications/preferences-store.ts | CRUD for user notification preferences |
assistant/src/config/bundled-skills/messaging/tools/send-notification.ts | Explicit producer tool for user-requested notifications; emits signals into the same routing pipeline |
assistant/src/calls/guardian-dispatch.ts | Guardian 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.
| What | Where | Format | ORM/Driver | Retention |
|---|---|---|---|---|
| API key | CES / encrypted file store | Encrypted binary | CES API / secure-keys.ts | Permanent |
| Credential secrets | CES / encrypted file store | Encrypted binary | secure-keys.ts wrapper | Permanent (until deleted via tool) |
| Credential records | CES <cesDataRoot>/metadata.json | JSON | CES RPC/HTTP + atomic file write | Permanent (until deleted via tool) |
| Integration OAuth tokens | CES / encrypted file store (via secure-keys.ts) | Encrypted binary | TokenManager auto-refresh | Until disconnected or revoked |
| User preferences | UserDefaults | plist | Foundation | Permanent |
| Session logs | ~/Library/.../logs/session-*.json | JSON per session | Swift Codable | Unbounded |
| Conversations & messages | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite + WAL | Drizzle ORM (Bun) | Permanent |
| Memory segments | $CADET_WORKSPACE_DIR/data/db/assistant-memory.db | SQLite | Drizzle ORM | Permanent |
| Extracted facts | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent, deduped |
| Embeddings | $CADET_WORKSPACE_DIR/data/db/assistant-memory.db | JSON float arrays | Drizzle ORM | Permanent |
| Async job queue | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Completed jobs persist |
| Attachments | $CADET_WORKSPACE_DIR/data/db/assistant.db | Base64 in SQLite | Drizzle ORM | Permanent |
| Sandbox filesystem | $CADET_WORKSPACE_DIR | Real filesystem tree | Node FS APIs | Persistent across sessions |
| Tool permission rules | ~/.cadet/protected/trust.json | JSON | File I/O | Permanent |
| Web users & assistants | PostgreSQL | Relational | Drizzle ORM (pg) | Permanent |
| Media embed settings | $CADET_WORKSPACE_DIR/config.json (ui.mediaEmbeds) | JSON | WorkspaceConfigIO (atomic merge) | Permanent |
| Media embed MIME cache | In-memory (ImageMIMEProbe) | NSCache (500 entries) | HTTP HEAD | Ephemeral; cleared on app restart |
| Tasks & task runs | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent |
| Work items (Task Queue) | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; archived items retained |
| Recurrence schedules & runs | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; supports cron and RRULE syntax |
| Watchers & events | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent, cascade on watcher delete |
| Proxy CA cert + key | {dataDir}/proxy-ca/ | PEM files (ca.pem, ca-key.pem) | openssl CLI | Permanent (10-year validity) |
| Proxy leaf certs | {dataDir}/proxy-ca/issued/ | PEM files per hostname | openssl CLI, cached | 1-year validity, re-issued on CA change |
| Proxy sessions | In-memory (SessionManager) | Map<ProxySessionId, ManagedSession> | Manual lifecycle | Ephemeral; 5min idle timeout, cleared on shutdown |
| Call sessions, events, pending questions | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent, cascade on session delete |
| Active call controllers | In-memory (CallState) | Map<callSessionId, CallController> | Manual lifecycle | Ephemeral; cleared on call end or destroy |
| Guardian bindings | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; revoked bindings retained |
| Channel verification sessions | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; consumed/expired sessions retained |
| Guardian approval requests | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; decision outcome retained |
| Contact invites | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; token hash stored, raw token never persisted |
| Contacts & channels | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; revoked/blocked contacts retained |
| Notification events | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; deduplicated by dedupeKey |
| Notification decisions | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; FK to notification_events |
| Notification deliveries | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; FK to notification_decisions |
| Notification preferences | $CADET_WORKSPACE_DIR/data/db/assistant.db | SQLite | Drizzle ORM | Permanent; per-assistant conversational preferences |
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:
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.
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.
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).
For full notification developer guidance and lifecycle details, see assistant/src/notifications/README.md.
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:
| File | Purpose |
|---|---|
src/runtime/assistant-scope.ts | Exports DAEMON_INTERNAL_ASSISTANT_ID constant |
src/__tests__/assistant-id-boundary-guard.test.ts | Guard tests enforcing the identity boundary |
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:
| File | Purpose |
|---|---|
src/daemon/conversation-runtime-assembly.ts | TrustContext type definition |
src/tools/types.ts | ToolContext.trustClass (required trust gate) |
src/runtime/channel-retry-sweep.ts | Strict trustClass parser for retry sweep |
src/channels/channel-verification-sessions.ts | GuardianBinding with required guardianPrincipalId |
src/__tests__/trust-context-guards.test.ts | Guard tests enforcing trust-context type invariants |
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:
| Field | Type | Default | Description |
|---|---|---|---|
services.tts.provider | enum | "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 casesThe 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):
TtsProviderCatalogEntry to the CATALOG array in src/tts/provider-catalog.ts. Declare the provider's ID, display name, call mode, capabilities, and secret requirements.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.TtsProvidersSchema in src/config/schemas/tts.ts for the provider's settings. The valid provider ID enum is catalog-driven.src/tts/providers/<id>-provider.ts implementing TtsProvider with the appropriate capabilities and synthesize/synthesizeStream methods.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:
| File | Purpose |
|---|---|
src/tts/provider-catalog.ts | Canonical provider catalog: single source of truth for provider IDs and metadata |
src/tts/types.ts | Core domain types: TtsProvider, TtsProviderId, TtsCallMode, TtsUseCase, capabilities |
src/tts/provider-registry.ts | Runtime provider registry: register, lookup, list |
src/tts/tts-config-resolver.ts | Config resolver: resolveTtsConfig() reads services.tts and returns resolved |
src/tts/synthesize-text.ts | Top-level orchestrator: synthesizeText() entry point |
src/tts/providers/register-builtins.ts | Startup registration of built-in providers (catalog-checked) |
src/tts/providers/elevenlabs-provider.ts | ElevenLabs adapter implementation |
src/tts/providers/fish-audio-provider.ts | Fish Audio adapter implementation |
src/config/schemas/tts.ts | Zod schema for services.tts config block (catalog-driven valid provider IDs) |
src/calls/tts-call-strategy.ts | Explicit call strategy: resolves call mode from catalog |
meta/tts-provider-catalog.json | Client artifact: provider metadata for macOS settings UI |
src/tts/__tests__/provider-catalog-consistency.test.ts | CI guard: catalog vs client artifact provider ID consistency |
src/workspace/migrations/032-tts-provider-unification.ts | Migration that materialised canonical services.tts fields |
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:
| Budget | Env var | Default |
|---|---|---|
| Max total bytes across all runs | CADET_PROFILER_MAX_BYTES | 500 MB |
| Max number of completed runs retained | CADET_PROFILER_MAX_RUNS | 10 |
| Minimum free disk space | CADET_PROFILER_MIN_FREE_MB | 200 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):
| Method | Endpoint | Description |
|---|---|---|
GET | /v1/profiler/runs | List all profiler runs with manifest metadata, sorted newest-first |
GET | /v1/profiler/runs/:runId | Detail view: manifest metadata, Bun markdown summary, active/retention state |
POST | /v1/profiler/runs/:runId/export | Package a single run directory as a tar.gz bundle (same size cap as log exports) |
DELETE | /v1/profiler/runs/:runId | Delete 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:
| File | Purpose |
|---|---|
src/daemon/profiler-run-store.ts | Manifest management, rescan, retention sweep |
src/runtime/routes/profiler-routes.ts | HTTP route handlers for profiler run management |
src/runtime/routes/archive-utils.ts | Shared tar.gz creation and size-cap utilities |
src/config/env-registry.ts | Profiler env var accessors (getProfilerRunId, getProfilerMode, etc.) |
src/util/layout.ts | Profiler directory path helpers |
src/__tests__/profiler-run-store.test.ts | Profiler store unit tests |
src/__tests__/profiler-routes.test.ts | Profiler HTTP route tests |
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 key | Transport class | API surface |
|---|---|---|
openai | OpenAIResponsesProvider | client.responses.stream() |
openrouter | OpenRouterProvider | chat.completions.create() |
fireworks | FireworksProvider | chat.completions.create() |
ollama | OllamaProvider | chat.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:
| File | Purpose |
|---|---|
src/providers/openai/responses-provider.ts | Responses API transport (streaming, tool calls, usage mapping) |
src/providers/openai/chat-completions-provider.ts | Chat Completions transport (OpenAI-compatible providers) |
src/providers/openai/client.ts | Re-exports both transports + validateOpenAIApiKey() |
src/providers/registry.ts | Provider initialization (wires openai → OpenAIResponsesProvider) |
src/__tests__/openai-responses-cutover-guard.test.ts | CI guard preventing chat-completions regression in OpenAI path |