# Vellum Assistant Runtime

Bun + TypeScript assistant runtime that owns conversation history, attachment storage, and channel delivery state in a local SQLite database. Exposes an HTTP+SSE API for native clients (macOS, iOS) and the gateway.

## Architecture

```
CLI / macOS app / iOS app
        │
        ▼
   RuntimeHttpServer (HTTP + SSE)
        │
        ├── Conversation Manager (in-memory pool, stale eviction)
        │       ├── Anthropic Claude (primary)
        │       ├── OpenAI (secondary)
        │       ├── Google Gemini (secondary)
        │       └── Ollama (local models)
        │
        ├── Memory System (Qdrant Hybrid Search)
        ├── Skill Tool System (bundled + managed + workspace)
        ├── Script Proxy (credential injection + MITM)
        └── Tracing (per-session event emitter)
```

For assistant architecture deep dives, see [`ARCHITECTURE.md`](ARCHITECTURE.md) and [`docs/architecture/`](docs/architecture/).

## Setup

```bash
cd assistant
bun install   # installs the whole workspace
cp .env.example .env
# Edit .env with your API keys
```

## Configuration

| Variable            | Required | Default                     | Description                                       |
| ------------------- | -------- | --------------------------- | ------------------------------------------------- |
| `ANTHROPIC_API_KEY` | Yes      | —                           | Anthropic Claude API key                          |
| `OPENAI_API_KEY`    | No       | —                           | OpenAI API key                                    |
| `GEMINI_API_KEY`    | No       | —                           | Google Gemini API key                             |
| `OLLAMA_API_KEY`    | No       | —                           | API key for authenticated Ollama deployments      |
| `OLLAMA_BASE_URL`   | No       | `http://127.0.0.1:11434/v1` | Ollama base URL                                   |
| `RUNTIME_HTTP_PORT` | No       | —                           | Enable the HTTP server (required for gateway/web) |
| `RUNTIME_HTTP_HOST` | No       | `127.0.0.1`                 | HTTP server bind address                          |

## Usage

### Lifecycle management (recommended)

Use the `vellum` CLI to manage assistant and gateway processes:

```bash
vellum wake    # start assistant + gateway from current checkout
vellum ps      # list assistants and per-assistant process status
vellum sleep   # stop assistant + gateway (directory-agnostic)
```

> **Note:** `vellum wake` requires a hatched assistant. Run `vellum hatch` first, or launch the macOS app which handles hatching automatically.

### Development: raw bun commands

For low-level development (e.g., working on the assistant runtime itself):

```bash
bun run src/index.ts daemon start   # start daemon only
bun run src/index.ts                # interactive CLI session
```

### CLI commands

| Command                                            | Description                                      |
| -------------------------------------------------- | ------------------------------------------------ |
| `vellum wake`                                      | Start assistant + gateway from current checkout  |
| `vellum sleep`                                     | Stop assistant + gateway processes               |
| `vellum ps`                                        | List assistants and per-assistant process status |
| `assistant`                                        | Launch interactive CLI session                   |
| `assistant conversations list\|search\|new\|export\|clear` | Manage conversations                             |
| `assistant config set\|get\|list`                  | Manage configuration                             |
| `assistant keys set\|list\|delete`                 | Manage API keys in secure storage                |
| `assistant trust list\|add\|update\|remove`        | Manage trust rules                               |

## Project Structure

```
assistant/
├── src/
│   ├── index.ts              # CLI entrypoint (commander)
│   ├── cli.ts                # Interactive REPL client
│   ├── daemon/               # Daemon server, session management
│   ├── agent/                # Agent loop and LLM interaction
│   ├── providers/            # LLM provider integrations (Anthropic, OpenAI, Gemini, Ollama)
│   ├── memory/               # Conversation store, memory indexer, recall (Qdrant hybrid search)
│   ├── skills/               # Skill catalog, loading, and tool factory
│   ├── tools/                # Built-in tool definitions
│   ├── permissions/          # Trust rules and permission system
│   ├── security/             # Secure key storage, credential broker
│   ├── config/               # Configuration loader and schema
│   ├── runtime/              # HTTP runtime server
│   ├── messaging/            # Message processing pipeline
│   ├── context/              # Context assembly and compaction
│   ├── playbooks/            # Channel onboarding playbooks
│   ├── hooks/                # Git-style lifecycle hooks
│   ├── media/                # Media processing and attachments
│   ├── schedule/             # Reminders and recurrence scheduling (cron + RRULE)
│   ├── tasks/                # Task management
│   ├── workspace/            # Workspace file operations
│   ├── events/               # Domain event bus
│   ├── export/               # Session export (markdown/JSON)
│   ├── util/                 # Shared utilities
│   └── __tests__/            # Test suites
├── drizzle/                  # Database migrations
├── drizzle.config.ts         # Drizzle ORM config (SQLite)
├── docs/                     # Internal documentation
├── scripts/                  # Test runners and message codegen
├── Dockerfile                # Production container image
└── package.json
```

## Channel Approval Flow

When the assistant needs tool-use confirmation during a channel session (e.g., Telegram), the approval flow intercepts the run and surfaces an interactive prompt to the user. This approval-aware path is always enabled whenever orchestrator + callback context are available.

### How it works

1. **Detection** — When a channel inbound message triggers an agent loop, the runtime polls the run status. If the run transitions to `needs_confirmation`, the runtime sends an approval prompt to the gateway with inline keyboard metadata.
2. **Interception** — Subsequent inbound messages on the same conversation are intercepted before normal processing. The handler checks for a pending approval and attempts to extract a decision from either callback data (button clicks) or plain text.
3. **Decision** — The user's decision is mapped to the permission system (`allow` or `deny`) and applied to the pending run. For `approve_always`, a trust rule is persisted so future invocations of the same tool are auto-approved.
4. **Reminder** — If the user sends a non-decision message while an approval is pending, a reminder prompt is re-sent with the approval buttons.

### Delivery Semantics

**Single final output guarantee (deliver-once guard):** Both the main poll (`processChannelMessageWithApprovals`) and the post-decision poll (`schedulePostDecisionDelivery`) race to deliver the final assistant reply when a run reaches terminal state. The `claimRunDelivery()` function in `delivery-channels.ts` ensures at-most-one delivery per run using an in-memory `Set<string>`. The first caller to claim the run ID proceeds with delivery; the other silently skips. This guard is sufficient because both racing pollers execute within the same process.

**Stale callback blocking:** When inbound callback data (e.g., a Telegram button press) does not match any pending approval, the runtime returns `stale_ignored` and does not process the payload as a regular message. This prevents stale button presses from old approval prompts from triggering unrelated agent loops.

### Prompt Delivery Failure Policy (Fail-Closed)

All approval prompt delivery paths use a **fail-closed** policy -- if the prompt cannot be delivered, the run is auto-denied rather than left in a silent wait state:

- **Standard (self-approval) prompt:** If `deliverApprovalPrompt()` fails, the run is immediately auto-denied via `handleChannelDecision(reject)`. No silent `needs_confirmation` hang.
- **Guardian-routed prompt:** If the approval prompt cannot be delivered to the guardian's chat, the guardian approval record is marked `denied`, the underlying run is rejected, and the requester is notified that the action was denied because the prompt could not reach the guardian.
- **Unverified channel (no guardian binding):** Sensitive actions are auto-denied immediately without attempting prompt delivery. The requester is notified that no guardian has been configured.

### Plain-Text Fallback for Non-Rich Channels

Channels that do not support rich inline approval UI (e.g., inline keyboards) receive plain-text instructions embedded in the message body. The `supportsInlineOptions` channel capability (`channelSupportsInlineOptions()`) determines whether to send the structured `promptText` (for channels whose adapter renders inline buttons like Telegram) or the `plainTextFallback` string (for all other channels). The fallback text includes instructions so the user can respond via text; the conversational approval engine then classifies the free-text response.

### Key modules

| File                                    | Purpose                                                                                                                 |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `src/runtime/channel-approvals.ts`      | Orchestration: `getChannelApprovalPrompt`, `buildApprovalUIMetadata`, `handleChannelDecision`, `buildReminderPrompt`    |
| `src/runtime/channel-approval-types.ts` | Shared types: `ApprovalAction`, `ChannelApprovalPrompt`, `ApprovalUIMetadata`, `ApprovalDecisionResult`                 |
| `src/runtime/routes/channel-routes.ts`  | Integration point: `handleApprovalInterception` and `processChannelMessageWithApprovals` in the channel inbound handler |
| `src/runtime/gateway-client.ts`         | `deliverApprovalPrompt()` — sends the approval payload (text + UI metadata) to the gateway for rendering                |
| `src/memory/runs-store.ts`              | `getPendingConfirmationsByConversation` — queries runs in `needs_confirmation` state                                    |

### Enabling

Channel approvals are always enabled for channel traffic when orchestrator + callback context are available.

### Guardian-Specific Behavior

Guardian actor-role _classification_ (determining whether a sender is guardian, non-guardian, or unverified) runs unconditionally. Guardian _enforcement_ for non-guardian/unverified actors (fail-closed denial for unverified channels and approval prompt routing to guardians) is always active when orchestrator + callback context are available.

| Flag / Behavior                | Description                                                                                                                                                                                                                                                                         |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Fail-closed no-binding**     | When no guardian binding exists for a channel, the sender is classified as `unverified_channel`. Any sensitive action is auto-denied with a notice that no guardian has been configured.                                                                                            |
| **Fail-closed no-identity**    | When `actorExternalId` is absent, the actor is classified as `unverified_channel` (even if no guardian binding exists yet).                                                                                                                                                         |
| **Guardian-only approval**     | Non-guardian senders cannot approve their own pending actions. Only the verified guardian can approve or deny.                                                                                                                                                                      |
| **Expired approval auto-deny** | A proactive sweep runs every 60 seconds to find expired guardian approval requests (30-minute TTL). Expired approvals are auto-denied, and both the requester and guardian are notified. If a non-guardian interacts before the sweep runs, the expiry is also detected reactively. |

### Ingress Boundary Guarantees (Gateway-Only Mode)

The runtime operates in **gateway-only mode**: all public-facing webhook paths are blocked at the runtime level. Direct access to Twilio webhook routes (`/webhooks/twilio/voice`, `/webhooks/twilio/status`) and their legacy equivalents (`/v1/calls/twilio/*`) returns `410 GATEWAY_ONLY`. This ensures external webhook traffic can only reach the runtime through the gateway, which performs signature validation before forwarding.

Internal forwarding routes (`/v1/internal/twilio/*`) are unaffected — these accept pre-validated payloads from the gateway over the private network.

### Gateway-Origin Ingress Contract

The `/channels/inbound` endpoint requires a JWT with the `svc_gateway` principal type and `ingress.write` scope to prove the request originated from the gateway. This ensures channel messages can only arrive via the gateway (which performs webhook-level verification) and not via direct HTTP calls that bypass signature checks.

- **JWT-based enforcement:** The route policy in `route-policy.ts` restricts `/channels/inbound` to the `svc_gateway` principal type with `ingress.write` scope. Actor and local principals are rejected with 403.
- **Auth bypass:** When `DISABLE_HTTP_AUTH=true` is set (platform-managed deployments), JWT verification is skipped and a synthetic context is used.

## Twilio Setup Primitive

Twilio is the telephony provider for voice calls. Configuration is managed through HTTP control-plane endpoints exposed by the runtime and proxied by the gateway.

### Twilio HTTP Control-Plane Endpoints

The runtime exposes a RESTful HTTP API for Twilio configuration, credential management, and phone number operations:

| Method | Path                                        | Description                                                                                                                                 |
| ------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/v1/integrations/twilio/config`            | Returns current state: `hasCredentials` (boolean) and `phoneNumber` (if assigned)                                                           |
| POST   | `/v1/integrations/twilio/credentials`       | Validates and stores Account SID and Auth Token in secure storage (CES / encrypted file store)                                              |
| DELETE | `/v1/integrations/twilio/credentials`       | Removes stored credentials. Preserves the phone number in config so re-entering credentials resumes working without reassigning the number. |
| GET    | `/v1/integrations/twilio/numbers`           | Lists all incoming phone numbers on the Twilio account with their capabilities                                                              |
| POST   | `/v1/integrations/twilio/numbers/provision` | Purchases a new phone number. Accepts optional `areaCode` and `country`. Auto-assigns and configures webhooks when ingress is available.    |
| POST   | `/v1/integrations/twilio/numbers/assign`    | Assigns an existing Twilio phone number (E.164) and auto-configures webhooks when ingress is available                                      |
| POST   | `/v1/integrations/twilio/numbers/release`   | Releases a phone number from the Twilio account and clears local references                                                                 |

All endpoints are JWT-authenticated (require a valid JWT with appropriate scopes). Skills and clients should call the gateway URL (default `http://localhost:7830`) rather than the runtime port directly, as the gateway proxies all `/v1/integrations/twilio/*` routes.

### Ingress Webhook Reconciliation

When the public ingress URL is changed via the Settings UI (`ingress_config` set action), the assistant automatically reconciles Twilio webhooks in addition to triggering a Telegram webhook reconcile on the gateway. If all of the following conditions are met, the assistant pushes updated webhook URLs (voice, status callback) to Twilio:

1. Ingress is being **enabled** (not disabled)
2. Twilio **credentials** are configured (Account SID + Auth Token in secure storage)
3. A phone number is **assigned** (persisted in `twilio.phoneNumber` config)

This reconciliation is **best-effort and fire-and-forget** -- failures are logged but do not block the ingress config save or produce an error response. This ensures that changing a tunnel URL (e.g., restarting ngrok) automatically updates Twilio's webhook routing without requiring manual re-assignment of the phone number.

### Single-Number-Per-Assistant Model

Each assistant is assigned a single Twilio phone number used for voice calls. The number is stored in the assistant's config at `twilio.phoneNumber`.

#### Assistant-Scoped Phone Numbers

When `assistantId` is provided in the Twilio control-plane request, the provision and assign endpoints persist the phone number into a per-assistant mapping at `twilio.assistantPhoneNumbers` (a `Record<string, string>` keyed by assistant ID). The `twilio.phoneNumber` field is always updated as well.

The config endpoint (`GET /v1/integrations/twilio/config`), when called with `assistantId`, resolves the phone number by checking `twilio.assistantPhoneNumbers[assistantId]` first, falling back to `twilio.phoneNumber`. This allows multiple assistants to have distinct phone numbers while preserving existing behavior for single-assistant setups.

The per-assistant mapping is propagated to the gateway via the config file watcher, enabling phone-number-based routing at the gateway boundary (see Gateway README).

### Phone Number Resolution Order

At runtime, `getTwilioConfig()` resolves the phone number from **`twilio.phoneNumber` in config** — the primary source of truth, written by `provision_number` and `assign_number`.

If no number is found, an error is thrown.

### Assistant-Scoped Guardian State

Guardian bindings, verification challenges, and approval requests are all scoped to an `(assistantId, channel)` pair. The `assistantId` parameter flows through `handleChannelInbound`, `validateAndConsumeVerification`, `isGuardian`, `getGuardianBinding`, and `createApprovalRequest`. This means each assistant has its own independent guardian binding per channel -- verifying as guardian on one assistant does not grant guardian status on another.

### Channel-Aware Guardian Challenges

The channel guardian service generates verification challenge instructions with channel-appropriate wording. The `channelLabel()` function maps `sourceChannel` values to human-readable labels (e.g., `"telegram"` -> `"Telegram"`, `"phone"` -> `"Phone"`), so challenge prompts reference the correct channel name.

### Operator Notes

- **Verification input format:** Channel verification accepts a bare code reply only (6-digit numeric for identity-bound sessions; 64-char hex for unbound inbound/bootstrap compatibility).
- **Rebind requirement:** Creating a new guardian challenge when a binding already exists requires `rebind: true` in the HTTP request. Without it, the assistant returns `already_bound`. This prevents accidental guardian replacement.
- **Takeover prevention:** Verification is rejected when an active binding exists for a different external user. Same-user re-verification is allowed.

### Vellum Guardian Identity (Actor Tokens)

The vellum channel (macOS) uses JWTs to bind guardian identity to HTTP requests. This enables identity-based authentication for the local desktop channel, paralleling how external channels (Telegram) use `actorExternalId` for guardian identity. The CLI authenticates using its bearer token obtained during `hatch`.

- **Bootstrap**: After hatch, the macOS client calls `POST /v1/guardian/init` with `{ platform, deviceId }`. Returns `{ guardianPrincipalId, accessToken, accessTokenExpiresAt, refreshToken, refreshTokenExpiresAt, refreshAfter, isNew }`. The endpoint is idempotent -- repeated calls with the same device return the same principal but mint fresh credentials. The CLI does not bootstrap separately; it uses the bearer token minted during `hatch`.
- **Local identity**: Local connections get a server-side synthetic `AuthContext` via `resolveLocalAuthContext()` (`runtime/local-actor-identity.ts`) without requiring a JWT; trust derives from the gateway guardian binding via `resolveLocalPrincipalTrustContext()` (`runtime/local-principal-trust.ts`), failing closed to unknown.
- **HTTP enforcement**: All vellum HTTP routes require a valid JWT via the `Authorization: Bearer <jwt>` header. The JWT carries identity claims (`sub` with principal type and ID) and scope permissions. Route-level enforcement in `route-policy.ts` checks scopes and principal types.
- **Startup migration**: On gateway start, `ensureVellumGuardianBinding()` in `gateway/src/auth/guardian-bootstrap.ts` backfills a vellum guardian binding for existing installations so the identity system works without requiring a manual bootstrap step.

## Guardian Verification and Ingress ACL

This section documents the end-to-end flow from guardian verification through ingress membership enforcement, showing how the two systems work together to gate channel access.

### Guardian Verification Flow

Guardian verification establishes a cryptographic trust binding between a human identity and an `(assistantId, channel)` pair. The flow is:

1. **Challenge creation** — The owner initiates verification from the desktop UI, which sends a channel_verification_session request (`create_session` action) to the assistant. The assistant generates a random secret (32-byte hex for unbound inbound/bootstrap sessions, 6-digit numeric for identity-bound sessions), hashes it with SHA-256, stores the hash with a 10-minute TTL, and returns the raw secret to the desktop.
2. **Code sharing** — The desktop displays the code and instructs the owner to reply with that code in the target channel conversation (e.g., Telegram).
3. **Verification** — When the message arrives at `/channels/inbound`, the handler intercepts valid verification-code replies before normal message processing. It hashes the provided code, looks up a matching pending challenge, validates expiry, and consumes the challenge (preventing replay).
4. **Binding** — On success, any existing active binding for the `(assistantId, channel)` pair is revoked, and a new guardian binding is created with the verifier's `actorExternalId` and `chatId` (DB columns: `externalUserId`, `chatId`). The verifier receives a confirmation message.

Rate limiting protects against brute-force attempts: 5 invalid attempts within 15 minutes trigger a 30-minute lockout per `(assistantId, channel, actor)` tuple. The same generic failure message is returned for both invalid codes and rate-limited attempts to avoid leaking state.

### Ingress ACL Enforcement

The ingress ACL runs at the top of the channel inbound handler, before guardian role resolution and message processing. When `actorExternalId` is present, the handler enforces this decision chain:

1. **Contact lookup** — Look up the sender in the contacts table via `findContactChannel` by `(channelType, externalUserId)` or `(channelType, externalChatId)`.
2. **Non-member denial** — If no member record exists, the message is denied with `not_a_member`.
3. **Status check** — If the member exists but is not `active` (e.g., `revoked` or `blocked`), the message is denied.
4. **Policy check** — The member's `policy` field determines routing:
   - `allow` — Message proceeds to normal agent processing.
   - `deny` — Message is rejected with `policy_deny`.

### How the Systems Connect

Guardian verification and ingress contact management are complementary but independent systems:

- **Guardian verification** establishes _who controls the assistant_ on a given channel. The guardian can approve sensitive actions and is the trust anchor.
- **Ingress contacts** control _who can interact with the assistant_ on a given channel. Contacts are created via invite redemption, not via guardian verification.

### Key Modules

| File                                                | Purpose                                                                                                                          |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `src/runtime/channel-verification-service.ts`       | Verification lifecycle: `createInboundVerificationSession`, `validateAndConsumeVerification`, `getGuardianBinding`, `isGuardian` |
| `src/runtime/trust-context-resolver.ts`             | Actor role classification: guardian / non-guardian / unverified_channel                                                          |
| `src/runtime/routes/inbound-message-handler.ts`     | Ingress ACL enforcement, verification-code intercept                                                                             |
| `src/contacts/contact-store.ts`                     | Contact + channel CRUD: `findContactChannel`, `upsertContact`, `updateChannelStatus`, `searchContacts`                           |
| `src/memory/channel-verification-sessions.ts`       | Guardian binding types and verification challenge persistence                                                                    |
| `src/memory/guardian-approvals.ts`                  | Approval request persistence                                                                                                     |
| `src/runtime/verification-outbound-actions.ts`      | Shared business logic for outbound verification (start/resend/cancel)                                                            |
| `src/runtime/routes/channel-verification-routes.ts` | HTTP route handlers for outbound guardian verification endpoints                                                                 |

### Chat-Initiated Guardian Verification

Guardian verification can also be initiated through normal desktop chat. When the user asks the assistant to set up guardian verification, the conversational routing layer loads the `guardian-verify-setup` skill, which guides the flow:

1. Confirm which channel to verify (voice or Telegram).
2. Collect the destination (phone number or Telegram handle/chat ID).
3. Call the outbound HTTP endpoints to start, resend, or cancel verification.
4. Guide the user through the verification lifecycle conversationally.

**Outbound HTTP Endpoints** (exposed via the gateway API and forwarded to the runtime):

| Endpoint                                   | Method | Description                                                                                                                                                                                                                                       |
| ------------------------------------------ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/v1/channel-verification-sessions`        | POST   | Create a verification session. Supports guardian (default), outbound (with `destination`), and trusted contact (with `purpose: "trusted_contact"` + `contactChannelId`). Body: `{ channel?, destination?, rebind?, purpose?, contactChannelId? }` |
| `/v1/channel-verification-sessions/resend` | POST   | Resend 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>`                                                                                                                                                                                        |

These endpoints share the same business logic as the HTTP-based verification flow via `verification-outbound-actions.ts`. Skills and clients should call the gateway URL (default `http://localhost:7830`) rather than the runtime port directly.

**Security constraint:** Guardian verification control-plane endpoints are restricted to guardian and desktop (trusted) actors only. Non-guardian and unverified-channel actors cannot invoke these endpoints conversationally via tools. Attempts are denied with a message explaining that guardian verification actions are restricted to guardian users.

## Channel Readiness

Channel readiness is exposed via HTTP control-plane endpoints that provide a unified way to check whether a channel (Telegram, Voice, etc.) is fully configured and operational. Local checks (credential presence, phone number assignment, ingress config) run synchronously; remote checks (API reachability) run by default and are cached with a 5-minute TTL. Remote checks can be disabled by passing `includeRemote=false`.

### Channel Readiness HTTP Endpoints

| Method | Path                             | Description                                                                                                                                                                                                                                                                 |
| ------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET    | `/v1/channels/readiness`         | Returns readiness snapshots for the specified channel (query param `channel`, optional) or all channels. Local checks always run; remote checks run by default (`includeRemote=true`) and use a cached result when fresh. Pass `includeRemote=false` to skip remote checks. |
| POST   | `/v1/channels/readiness/refresh` | Invalidates the cache for the specified channel (or all channels), then returns fresh snapshots. Body: `{ channel?: ChannelId, includeRemote?: boolean }`. `includeRemote` defaults to `true`.                                                                              |

All endpoints are bearer-authenticated. Skills and clients should call the gateway URL (default `http://localhost:7830`) rather than the runtime port directly, as the gateway proxies all `/v1/channels/readiness*` routes.

### Built-in Channel Probes

- **Voice**: Checks Twilio credentials, phone number assignment, and public ingress URL.
- **Telegram**: Checks bot token, webhook secret, and public ingress URL.
- **Email**: Checks AgentMail API key, invite policy, public ingress URL, and verifies an inbox address is available (remote check).
- **WhatsApp**: Checks Meta WhatsApp Business API credentials (phoneNumberId, accessToken, appSecret, webhookVerifyToken), display phone number (`whatsapp.phoneNumber`), invite policy, and public ingress URL.
- **Slack**: Checks bot token and app token.

### Key modules

| File                                             | Purpose                                                                                         |
| ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| `src/runtime/channel-readiness-types.ts`         | Shared types: `ChannelId`, `ReadinessCheckResult`, `ChannelReadinessSnapshot`, `ChannelProbe`   |
| `src/runtime/channel-readiness-service.ts`       | Service class with probe registration, cached readiness evaluation, and built-in channel probes |
| `src/runtime/routes/channel-readiness-routes.ts` | HTTP route handlers for `/v1/channels/readiness` and `/v1/channels/readiness/refresh`           |

## Ingress Membership

Secure cross-user messaging allows external users (non-guardians) to interact with the assistant through channels (Telegram) under the owner's control. Access is governed by an invite-based membership system with per-member policy enforcement.

### Invite-Based Onboarding

External users join through **invite tokens**. There are two invite flows:

1. **Manual** — The owner creates an invite via the HTTP API, obtains the raw token, and shares it manually. The external user redeems the token by sending it as a channel message.
2. **Guardian-initiated invite links (Telegram)** — The guardian asks the assistant to create an invite link via desktop chat. The assistant creates an invite, builds a channel-specific deep link, and presents it for sharing. The invitee clicks the link and is automatically granted access.

#### Guardian-Initiated Invite Link Flow (Telegram)

1. **Guardian requests invite** — The guardian asks the assistant (via desktop chat) to create a Telegram invite link. The `guardian-invite-intent.ts` module detects the intent and routes the request into the `contacts` skill.
2. **Invite creation** — The skill creates an invite token via the ingress HTTP API, looks up the Telegram bot username from the integration config endpoint, and constructs a shareable deep link: `https://t.me/<bot>?start=iv_<token>`.
3. **Guardian shares link** — The guardian copies the deep link and shares it with the invitee through any messaging channel.
4. **Invitee redeems** — The invitee clicks the link, which opens Telegram and sends `/start iv_<token>` to the bot. The gateway intercepts the token at ingress, redeems it against its canonical invite row, activates the member channel in the gateway ACL, and notifies the daemon to mirror the contact info.
5. **Access granted** — The invitee receives a welcome message and all subsequent messages pass the ingress ACL.

The `iv_` prefix distinguishes invite tokens from `gv_` (guardian verification) tokens, which use the same Telegram `/start` deep-link mechanism.

#### Invite Redemption Architecture

The invite redemption system uses a three-layer architecture:

- **Core redemption engine** (gateway-native, `gateway/src/verification/invite-redemption.ts`) — Channel-agnostic business logic that validates tokens and codes, enforces expiry/use-count/channel-match constraints, handles member reactivation, and returns a discriminated-union `InviteRedemptionOutcome`. Deterministic reply templates map each outcome to a user-facing message without passing through the LLM.
- **Channel transport adapters** (`channel-invite-transport.ts` + `channel-invite-transports/`) — A registry of per-channel adapters that know how to build shareable links (`buildShareLink`) and extract inbound tokens (`extractInboundToken`). Adapters are implemented for Telegram, Voice, Email, WhatsApp, and Slack.
- **Conversational orchestration** (`guardian-invite-intent.ts`) — Pattern-based intent detection that intercepts guardian invite management requests (create, list, revoke) in the session pipeline and forces immediate entry into the `contacts` skill, bypassing the normal agent loop.

Redemption auto-creates a **member** record with an access policy:

- **`allow`** — Messages are processed normally through the agent pipeline.
- **`deny`** — Messages are rejected with a refusal notice.

Non-members (senders with no invite redemption) are denied by default. Contacts can be listed, updated, revoked, or blocked via the HTTP API (`/v1/contacts` and `/v1/contact-channels`).

### HTTP API

| Endpoint         | Actions                      | Description                                                              |
| ---------------- | ---------------------------- | ------------------------------------------------------------------------ |
| `ingress_invite` | create, list, revoke, redeem | Manage invite tokens (SHA-256 hashed, raw token returned once on create) |

### Key Modules

| File                                                      | Purpose                                                                                                          |
| --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `src/contacts/contact-store.ts`                           | Contact + channel CRUD with policy enforcement                                                                   |
| `src/runtime/routes/contact-routes.ts`                    | HTTP/IPC invite handlers — relay mint/list/revoke/redeem to the gateway's invite IPC routes                      |
| `src/runtime/routes/inbound-message-handler.ts`           | ACL enforcement point — member lookup, policy check                                                              |
| `gateway/src/verification/invite-redemption.ts` (gateway) | Core redemption engine — token/code validation, atomic claim, ACL activation, discriminated-union outcomes       |
| `src/runtime/channel-invite-transport.ts`                 | Transport adapter registry — `buildShareableInvite` / `extractInboundToken` per channel                          |
| `src/runtime/channel-invite-transports/telegram.ts`       | Telegram adapter — builds `t.me/<bot>?start=iv_<token>` deep links, extracts `iv_` tokens from `/start` commands |
| `src/daemon/guardian-invite-intent.ts`                    | Intent detection — routes guardian invite management requests into the `contacts` skill                          |
| `src/runtime/invite-service.ts`                           | Daemon-owned invite presentation (share link, guardian instruction) over gateway-minted invites                  |

## Database

SQLite via Drizzle ORM, stored at `$VELLUM_WORKSPACE_DIR/data/db/assistant.db`. Key tables include conversations, messages, tool invocations, attachments, memory segments, memory items, reminders, and recurrence schedules (cron + RRULE).

> **Note:** The recurrence schedule system supports both cron expressions and iCalendar RRULE syntax. Use the `expression` field with an explicit `syntax` discriminator. See [`docs/architecture/scheduling.md`](docs/architecture/scheduling.md) for details.

Run migrations:

```bash
bun run db:generate   # Generate migration SQL
bun run db:push       # Apply migrations
```

## Docker

```bash
# Build production image
docker build -f assistant/Dockerfile -t vellum-assistant:local .

# Run
docker run --rm -p 3001:3001 \
  -e ANTHROPIC_API_KEY=... \
  vellum-assistant:local
```

The image exposes port `3001` and bundles the `assistant` CLI binary.

## Troubleshooting

### Guardian and gateway-origin issues

| Symptom                                | Cause                                                                                              | Resolution                                                                                                               |
| -------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| 403 `FORBIDDEN` on `/channels/inbound` | JWT does not have `svc_gateway` principal type or `ingress.write` scope                            | Ensure the gateway is minting JWTs with the `gateway_ingress_v1` scope profile when forwarding channel inbound requests. |
| Non-guardian actions silently denied   | No guardian binding for the channel. The system is fail-closed for unverified channels.            | Run the guardian verification flow from the desktop UI to bind a guardian.                                               |
| Guardian approval expired              | The 30-minute TTL elapsed. The proactive sweep auto-denied the approval and notified both parties. | The requester must re-trigger the action.                                                                                |

### Invalid RRULE set expressions

If `schedule_create` rejects an RRULE expression, check the following:

- **Missing DTSTART** — Every RRULE expression must include a `DTSTART` line (e.g., `DTSTART:20250101T090000Z`).
- **No inclusion rule** — At least one `RRULE:` or `RDATE` line is required. An expression with only `EXDATE` or `EXRULE` lines and no inclusion has no occurrences to schedule.
- **Unsupported lines** — Only `DTSTART`, `RRULE:`, `RDATE`, `EXDATE`, and `EXRULE` prefixes are recognized. Any other line (e.g., `VTIMEZONE`, `VEVENT`) will be rejected.
- **Newline encoding** — When passing multi-line RRULE expressions through JSON, use literal `\n` between lines. The engine normalizes escaped newlines automatically.

## Development

```bash
cd assistant
bun install
bun run typecheck   # TypeScript type check (tsc --noEmit)
bun run lint        # ESLint
bun run test        # Run test suite
```
