@vellumai/vellum-gateway 0.12.3-staging.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +1 -1
  2. package/ARCHITECTURE.md +34 -38
  3. package/README.md +29 -31
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +2 -1
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  10. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +20 -0
  11. package/node_modules/@vellumai/gateway-client/src/index.ts +4 -1
  12. package/node_modules/@vellumai/gateway-client/src/outbound-contract.ts +38 -7
  13. package/node_modules/@vellumai/gateway-client/src/trust-verdict-contract.ts +5 -6
  14. package/node_modules/@vellumai/service-contracts/package.json +2 -1
  15. package/node_modules/@vellumai/service-contracts/src/index.ts +1 -0
  16. package/node_modules/@vellumai/service-contracts/src/integration-categories.ts +36 -0
  17. package/node_modules/@vellumai/slack-text/src/index.ts +4 -8
  18. package/package.json +1 -1
  19. package/src/__tests__/config.test.ts +0 -1
  20. package/src/__tests__/handle-inbound-invite-intercept.test.ts +36 -41
  21. package/src/__tests__/pair-origin-allowlist.test.ts +20 -1
  22. package/src/__tests__/reply-path.test.ts +36 -0
  23. package/src/__tests__/runtime-client.test.ts +0 -84
  24. package/src/__tests__/text-verification-reply.test.ts +131 -0
  25. package/src/__tests__/verification-bootstrap-purpose.test.ts +126 -0
  26. package/src/channels/transport-hints.ts +9 -5
  27. package/src/config.ts +1 -2
  28. package/src/db/__tests__/session-store.test.ts +18 -4
  29. package/src/db/connection.ts +10 -0
  30. package/src/db/contact-store.ts +1 -3
  31. package/src/db/session-store.ts +21 -15
  32. package/src/feature-flag-registry.json +16 -0
  33. package/src/http/browser-auth-cookies.ts +1 -5
  34. package/src/http/middleware/cors.ts +4 -2
  35. package/src/http/routes/log-export.ts +1 -1
  36. package/src/http/routes/telegram-webhook.test.ts +0 -1
  37. package/src/http/routes/telegram-webhook.ts +0 -10
  38. package/src/http/routes/twilio-media-websocket.ts +4 -19
  39. package/src/http/routes/whatsapp-webhook.test.ts +0 -3
  40. package/src/http/routes/whatsapp-webhook.ts +1 -4
  41. package/src/index.ts +3 -3
  42. package/src/ipc/debug-export-handlers.test.ts +103 -0
  43. package/src/ipc/debug-export-handlers.ts +81 -0
  44. package/src/risk/bash-risk-classifier.test.ts +2 -1
  45. package/src/risk/bash-risk-classifier.ts +1 -3
  46. package/src/risk/command-registry/commands/assistant.ts +1 -0
  47. package/src/runtime/client.ts +0 -122
  48. package/src/telegram/api.ts +0 -33
  49. package/src/telegram/send.test.ts +16 -117
  50. package/src/telegram/send.ts +3 -157
  51. package/src/velay/binary-websocket.test.ts +53 -0
  52. package/src/velay/binary-websocket.ts +46 -0
  53. package/src/velay/client.test.ts +73 -1
  54. package/src/velay/client.ts +11 -1
  55. package/src/velay/protocol.ts +18 -0
  56. package/src/velay/websocket-bridge.test.ts +181 -56
  57. package/src/velay/websocket-bridge.ts +21 -3
  58. package/src/verification/invite-redemption.ts +11 -17
  59. package/src/verification/reply-delivery.ts +49 -64
  60. package/src/verification/session-service.ts +15 -5
  61. package/src/verification/text-verification.ts +2 -2
  62. package/src/whatsapp/api.ts +0 -145
  63. package/src/whatsapp/send.ts +1 -198
  64. package/src/__tests__/telegram-send-attachments.test.ts +0 -502
package/AGENTS.md CHANGED
@@ -221,7 +221,7 @@ Two orthogonal axes, do not conflate them:
221
221
  - **Admission** (above) — _who gets in the door_. `TRUST_CLASS_RANK` vs `ADMISSION_FLOOR`, enforced across gateway + runtime.
222
222
  - **Capabilities** — _what an actor may do once admitted_. Resolved in the runtime, never on the gateway.
223
223
 
224
- **Trust classes** (`TrustClass` in `assistant/src/runtime/trust-class.ts`, re-exported from `actor-trust-resolver.ts`) are the _role_, ranked by `TRUST_CLASS_RANK`:
224
+ **Trust classes** (`TrustClass` in `packages/gateway-client/src/trust-verdict-contract.ts`; the daemon imports it and never redeclares it) are the _role_, ranked by `TRUST_CLASS_RANK`:
225
225
 
226
226
  | Class | Rank | Meaning |
227
227
  | -------------------- | ---- | ------------------------------------------------------------------------ |
package/ARCHITECTURE.md CHANGED
@@ -282,7 +282,7 @@ Channel bindings follow a three-phase lifecycle:
282
282
 
283
283
  1. **Bind** — An inbound message from an external channel (e.g., Telegram chat) arrives at the gateway, which normalizes it and forwards it to the runtime's `/v1/channels/inbound` endpoint. The runtime creates or reuses a conversation, establishing the channel binding (`sourceChannel` metadata on the conversation).
284
284
 
285
- 2. **Route**: Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Slack and Telegram are thread-scoped: a message that arrives in a Slack thread (including every message in a Slack agent DM, which Slack always delivers in a thread) or a Telegram topic resolves to that thread's own conversation, keyed on the chat plus the thread id (`assistant/src/persistence/delivery-crud.ts`, `buildScopedConversationKey`); a thread-less message resolves to the chat's base conversation. Replies from the assistant are delivered back through the gateway's `/deliver/telegram` endpoint. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
285
+ 2. **Route**: Subsequent messages on the same external chat are routed to the same conversation via the channel binding. Slack and Telegram are thread-scoped: a message that arrives in a Slack thread (including every message in a Slack agent DM, which Slack always delivers in a thread) or a Telegram topic resolves to that thread's own conversation, keyed on the chat plus the thread id (`assistant/src/persistence/delivery-crud.ts`, `buildScopedConversationKey`); a thread-less message resolves to the chat's base conversation. Replies from the assistant go out through the daemon's channel transport for that channel (`assistant/src/messaging/providers`), which calls the provider's API directly; they never pass back through the gateway. The desktop client filters out channel-bound conversations during conversation restoration (`ConversationRestorer`) so they never appear in the desktop conversation list.
286
286
 
287
287
  3. **Rebind** — If a message arrives on an external chat whose conversation was previously deleted, the channel inbound handler treats it as a new conversation and establishes a fresh binding. The external chat ID is reused, but the conversation is new.
288
288
 
@@ -371,18 +371,18 @@ Telegram messages follow three paths through the system:
371
371
  Inbound (user → assistant):
372
372
  Telegram → Gateway POST /webhooks/telegram → verify secret → normalize → route
373
373
  → Runtime POST /v1/assistants/:id/channels/inbound
374
- (replyCallbackUrl = ${gatewayInternalBaseUrl}/deliver/telegram)
374
+ (replyCallbackUrl = ${gatewayInternalBaseUrl}/deliver/telegram[?threadId=<topic>])
375
375
 
376
376
  Outbound reply (assistant → user, triggered by inbound):
377
- Runtime callback → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage/sendPhoto/sendDocument/sendChatAction
377
+ Daemon Telegram transport → Telegram Bot API sendMessage/sendPhoto/sendDocument/sendChatAction
378
378
 
379
- Outbound proactive (assistant → user, initiated by messaging provider):
380
- Runtime messaging provider → Gateway POST /deliver/telegram (bearer auth) → Telegram sendMessage/sendChatAction
379
+ Outbound proactive (assistant → user, messaging tool or POST /v1/channels/send):
380
+ Daemon Telegram transport → Telegram Bot API sendMessage
381
381
  ```
382
382
 
383
- The `replyCallbackUrl` included in the inbound forward is built from the `gatewayInternalBaseUrl` config field, which is always derived from `GATEWAY_PORT` as `http://127.0.0.1:${GATEWAY_PORT}` (default port `7830`). Both the hostname (`127.0.0.1`) and port derivation are hardcoded in `gateway/src/config.ts`, so the gateway and runtime must be co-located (same host, `--network host`, or Docker Compose with shared networking) for callbacks to reach the gateway. Separate-host deployments are not currently supported.
383
+ The gateway does not serve `/deliver/telegram`. The `replyCallbackUrl` it attaches to the inbound forward is an addressing token: the daemon resolves its path to the channel's transport (`channelForCallback` in `assistant/src/messaging/providers/callback-routing.ts`) and reads per-channel parameters from its query (the Telegram transport reads `threadId` to reply into the same topic). The daemon never dials the URL's host and port, which come from `gatewayInternalBaseUrl` (`http://127.0.0.1:${GATEWAY_PORT}`, `gateway/src/config.ts`). The gateway's own replies to invite and verification codes it intercepts at ingress go out 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, which resolves the transport from that URL as it does for any reply. The gateway never sends those replies to a provider itself. The daemon's Telegram transport (`assistant/src/messaging/providers/telegram-bot/`) reads the bot token from credential storage and calls the Bot API itself.
384
384
 
385
- The `/deliver/telegram` endpoint requires bearer auth unconditionally (fail-closed). If no bearer token is configured and the dev-only bypass flag (`telegram.deliverAuthBypass` in `workspace/config.json`) is not set, the endpoint returns 503 rather than allowing unauthenticated access. The bypass requires `APP_VERSION=0.0.0-dev`.
385
+ The gateway sends to Telegram on its own only for notices it composes while handling the webhook, before or instead of a runtime turn: the `/start` acknowledgement, the "not fully set up" routing-rejection notice, a setup-hiccup notice when the forward fails, and the denial text the runtime returns when ingress ACL rejects the sender. These go through `sendTelegramReply` in `gateway/src/telegram/send.ts`.
386
386
 
387
387
  **Bot-account limitations:** The Telegram Bot API only supports sending messages to chats that have previously interacted with the bot. Bots cannot enumerate chats, read message history, or search messages. A future MTProto user-account session track may lift some of these restrictions.
388
388
 
@@ -403,8 +403,7 @@ The run transitions to `NeedsConfirmation` when the agent loop emits a `confirma
403
403
  ```
404
404
  Runtime detects needs_confirmation
405
405
  → runtime builds approval prompt + UI metadata
406
- → POST /deliver/telegram with `approval` payload
407
- → gateway renders inline keyboard (buttons: Approve once, Approve always, Reject)
406
+ → daemon Telegram transport sends the prompt with an inline keyboard (buttons: Approve once, Approve always, Reject)
408
407
  → user clicks button → Telegram callback_query
409
408
  → gateway normalizes callback_query into inbound event (callbackData field)
410
409
  → runtime parses callback data (format: apr:<requestId>:<action>)
@@ -431,19 +430,19 @@ Runtime detects needs_confirmation
431
430
 
432
431
  **Key modules:**
433
432
 
434
- | Module | Purpose |
435
- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
436
- | `assistant/src/runtime/approval-conversation-turn.ts` | Conversational approval turn engine: LLM-based intent classification (structured output) for pending approval follow-ups, with fail-closed safety |
437
- | `assistant/src/runtime/approval-message-composer.ts` | Centralized approval message composition: layered source selection (assistant preface → deterministic fallback) for all approval/guardian/verification user-facing copy |
438
- | `assistant/src/runtime/channel-approvals.ts` | Orchestration: detect pending confirmations, build prompts (including guardian-aware prompts), apply decisions, plain-text fallback selection |
439
- | `assistant/src/runtime/channel-approval-types.ts` | Shared types: actions, prompts, UI metadata, decisions |
440
- | `assistant/src/runtime/routes/channel-routes.ts` | Integration point: approval interception, actor role resolution, guardian approval routing, deliver-once guard, fail-closed prompt delivery |
441
- | `assistant/src/runtime/channel-verification-service.ts` | Guardian binding lookups: `isGuardian()`, `getGuardianBinding()` |
442
- | `assistant/src/memory/delivery-channels.ts` | `claimRunDelivery()` — in-memory deliver-once guard for terminal reply idempotency |
443
- | `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_list_expired_pending` / `_expire`) |
444
- | `assistant/src/runtime/gateway-client.ts` | `deliverApprovalPrompt()` — sends approval payload to gateway |
445
- | `gateway/src/telegram/send.ts` | `buildInlineKeyboard()` — renders approval actions as Telegram inline buttons |
446
- | `gateway/src/telegram/normalize.ts` | `callback_query` normalization into `GatewayInboundEvent` (private chats, groups, and supergroups; drops callbacks without data) |
433
+ | Module | Purpose |
434
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
435
+ | `assistant/src/runtime/approval-conversation-turn.ts` | Conversational approval turn engine: LLM-based intent classification (structured output) for pending approval follow-ups, with fail-closed safety |
436
+ | `assistant/src/runtime/approval-message-composer.ts` | Centralized approval message composition: layered source selection (assistant preface → deterministic fallback) for all approval/guardian/verification user-facing copy |
437
+ | `assistant/src/runtime/channel-approvals.ts` | Orchestration: detect pending confirmations, build prompts (including guardian-aware prompts), apply decisions, plain-text fallback selection |
438
+ | `assistant/src/runtime/channel-approval-types.ts` | Shared types: actions, prompts, UI metadata, decisions |
439
+ | `assistant/src/runtime/routes/channel-routes.ts` | Integration point: approval interception, actor role resolution, guardian approval routing, deliver-once guard, fail-closed prompt delivery |
440
+ | `assistant/src/runtime/channel-verification-service.ts` | Guardian binding lookups: `isGuardian()`, `getGuardianBinding()` |
441
+ | `assistant/src/memory/delivery-channels.ts` | `claimRunDelivery()`: in-memory deliver-once guard for terminal reply idempotency |
442
+ | `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_list_expired_pending` / `_expire`) |
443
+ | `assistant/src/runtime/gateway-client.ts` | `deliverApprovalPrompt()`: hands the approval prompt to the channel transport the callback URL names |
444
+ | `assistant/src/messaging/providers/telegram-bot/send.ts` | `buildInlineKeyboard()`: renders approval actions as Telegram inline buttons |
445
+ | `gateway/src/telegram/normalize.ts` | `callback_query` normalization into `GatewayInboundEvent` (private chats, groups, and supergroups; drops callbacks without data) |
447
446
 
448
447
  ### Approval Message Composer
449
448
 
@@ -562,19 +561,19 @@ sequenceDiagram
562
561
  GW->>Daemon: POST /v1/channels/inbound (JWT auth)
563
562
  Daemon->>Daemon: Detect non-guardian, set forcePromptSideEffects
564
563
  Daemon->>Daemon: Tool needs confirmation → create GuardianApprovalRequest
565
- Daemon->>GW: POST /deliver/telegram (approval prompt + inline keyboard)
566
- GW->>Guardian: sendMessage (approval prompt)
567
- Daemon->>GW: POST /deliver/telegram (requester notification)
568
- GW-->>NG: "Waiting for guardian approval..."
564
+ Daemon->>TG: sendMessage via Telegram transport (approval prompt + inline keyboard)
565
+ TG-->>Guardian: Approval prompt
566
+ Daemon->>TG: sendMessage via Telegram transport (requester notification)
567
+ TG-->>NG: "Waiting for guardian approval..."
569
568
  Guardian->>TG: Approve / Deny (callback_query or text)
570
569
  TG->>GW: POST /webhooks/telegram (callback_query)
571
570
  GW->>Daemon: POST /v1/channels/inbound (JWT auth)
572
571
  Daemon->>Daemon: Validate guardian identity, update approval decision
573
572
  Daemon->>Daemon: Apply decision to pending run
574
- Daemon->>GW: POST /deliver/telegram (outcome notification)
575
- GW-->>NG: "Guardian approved/denied your request"
576
- Daemon->>GW: POST /deliver/telegram (confirmation)
577
- GW-->>Guardian: Confirmation of decision
573
+ Daemon->>TG: sendMessage via Telegram transport (outcome notification)
574
+ TG-->>NG: "Guardian approved/denied your request"
575
+ Daemon->>TG: sendMessage via Telegram transport (confirmation)
576
+ TG-->>Guardian: Confirmation of decision
578
577
  ```
579
578
 
580
579
  Approval state lives in the gateway's `guardian_requests` table (kind `tool_approval`), with per-surface card deliveries in `guardian_request_deliveries`. Each request records the requester, guardian, tool name, risk level, and decision outcome; decisions commit atomically via the gateway's `guardian_requests_decide` IPC route.
@@ -732,14 +731,11 @@ The Slack channel enables inbound and outbound messaging via Slack's Socket Mode
732
731
  3. Events are deduplicated by a compound key in the SQLite-backed `slack_seen_events` table: every event records its Slack `event_id`, and message-shaped events additionally record `msg:${channel}:${ts}` so the live and reconnect-replay paths dedup symmetrically. Entries TTL out after 24h; a periodic cleanup sweep evicts expired rows.
733
732
  4. The `normalizeSlackAppMention()` function strips leading bot-mention tokens (`<@U...>`) from the message text and produces a `GatewayInboundEvent` with `sourceChannel: "slack"`, using the Slack channel ID as `conversationExternalId` and the sender's user ID as `actorExternalId`.
734
733
  5. Routing uses the standard `resolveAssistant()` chain (conversation_id -> actor_id -> default/reject). Events that cannot be routed are dropped.
735
- 6. The normalized event is forwarded to the runtime via `POST /v1/channels/inbound` with a `replyCallbackUrl` pointing to `/deliver/slack`.
734
+ 6. The normalized event is forwarded to the runtime via `POST /v1/channels/inbound` with a `replyCallbackUrl` of `/deliver/slack?channel=<id>`, plus `threadTs` for a threaded message or `messageTs` for a thread-less message.
736
735
 
737
- **Egress** (`POST /deliver/slack`):
736
+ **Egress:**
738
737
 
739
- 1. The runtime calls the gateway's `/deliver/slack` endpoint with `{ chatId, text }` or `{ to, text }` (alias). The `chatId` field maps to the Slack channel ID where the reply should be posted.
740
- 2. The gateway authenticates the request via bearer token (same fail-closed model as other deliver endpoints).
741
- 3. The gateway posts the message via `POST https://slack.com/api/chat.postMessage` using the bot token.
742
- 4. Threading is supported via a `threadTs` query parameter on the deliver URL. When present, replies are posted as thread replies to the specified message timestamp.
738
+ The gateway does not serve `/deliver/slack`; for the daemon the callback URL only addresses the reply (the gateway's intercepted-code replies reach the same transport through the daemon, see Telegram Messaging Flow). The daemon's Slack transport (`assistant/src/messaging/providers/slack/`) calls the Slack Web API itself with the bot token: `chat.postMessage` and `chat.update` for whole messages, and `chat.startStream` / `chat.appendStream` / `chat.stopStream` for streamed replies. It reads `threadTs` from the callback URL to reply in the thread and `messageTs` to anchor the busy indicator on a thread-less message.
743
739
 
744
740
  **Credential management:**
745
741
 
@@ -773,7 +769,7 @@ Any persistent-stream transport that does not buffer events for disconnected cli
773
769
  | `gateway/src/slack/socket-mode.ts` | `SlackSocketModeClient`: WebSocket lifecycle, ACK, dedup, auto-reconnect, reconnect catch-up |
774
770
  | `gateway/src/slack/slack-web.ts` | `conversations.history` / `conversations.replies` helpers for reconnect catch-up |
775
771
  | `gateway/src/slack/message-normalizer.ts` | Normalizers per event family (`normalizeSlackAppMention()`, DM, group DM, channel message) with bot-mention stripping |
776
- | `gateway/src/index.ts` | `/deliver/slack` route: outbound message delivery via `chat.postMessage`, thread and message ts on the callback URL |
772
+ | `gateway/src/index.ts` | Builds the `/deliver/slack` callback URL (channel, thread ts, message ts) the daemon's Slack transport replies to |
777
773
 
778
774
  **What the ingress does not do:** it never forwards the bot's own posts to the daemon (except a deletion of one, which the daemon records), and it never reads history on the daemon's behalf beyond the bounded reconnect catch-up above; the daemon's inbound-triggered backfill hydrates context.
779
775
 
@@ -846,7 +842,7 @@ sequenceDiagram
846
842
  Ctrl->>CallStore: createPendingQuestion()
847
843
  Ctrl->>GuardianDispatch: dispatchGuardianQuestion()
848
844
  GuardianDispatch->>Mac: notification_conversation_created SSE
849
- GuardianDispatch->>TG: POST /deliver/{channel}
845
+ GuardianDispatch->>TG: sendMessage via notification pipeline
850
846
  Note over Mac,TG: First channel to respond wins
851
847
  Mac/TG->>Routes: guardian answer
852
848
  Routes->>CallDomain: answerCall()
package/README.md CHANGED
@@ -58,19 +58,6 @@ For manual setup (or reference), register the webhook with Telegram using the `s
58
58
 
59
59
  See the [Telegram Bot API docs](https://core.telegram.org/bots/api#setwebhook) for the full API reference.
60
60
 
61
- ## Telegram Deliver Endpoint Security
62
-
63
- The `/deliver/telegram` endpoint requires bearer auth by default (fail-closed). The security behavior is:
64
-
65
- | Condition | Result |
66
- | ----------------------------------------------------------------------------------------- | -------------------------- |
67
- | Bearer token configured + valid `Authorization` header | Request allowed |
68
- | Bearer token configured + missing/invalid `Authorization` header | 401 Unauthorized |
69
- | No bearer token configured + `telegram.deliverAuthBypass=true` in `workspace/config.json` | Request allowed (dev-only) |
70
- | No bearer token configured + bypass not set | 503 Service Not Configured |
71
-
72
- This ensures that misconfiguration cannot expose an unauthenticated public message-send surface. In production, ensure JWT authentication is properly configured. The `telegram.deliverAuthBypass` config flag (in `workspace/config.json`) is intended for local development only and requires `APP_VERSION=0.0.0-dev`.
73
-
74
61
  ## Voice Ingress — Inbound Calls (Twilio)
75
62
 
76
63
  The `/webhooks/twilio/voice` endpoint handles both outbound and inbound voice calls. For **outbound** calls (initiated by the assistant via `call_start`), the voice webhook URL includes a `callSessionId` query parameter that identifies the pre-created session. For **inbound** calls (someone dialing the assistant's Twilio phone number), no `callSessionId` is present — the gateway resolves the target assistant and the runtime creates a session on the fly.
@@ -112,9 +99,9 @@ These fields are forwarded to the runtime in the `/channels/inbound` payload alo
112
99
 
113
100
  ## Approval Buttons and Inline Keyboard
114
101
 
115
- The `/deliver/telegram` endpoint accepts an optional `approval` field in the request body. When present, the gateway renders Telegram inline keyboard buttons below the message text.
102
+ The gateway does not send approval prompts. The assistant's Telegram transport (`assistant/src/messaging/providers/telegram-bot/`) sends them to the Bot API directly, and when the reply payload carries an `approval` field it renders Telegram inline keyboard buttons below the message text. The gateway's part is the return trip: it normalizes the button press (`callback_query`) and forwards it as described above.
116
103
 
117
- **Approval payload shape:**
104
+ **Approval reply payload shape:**
118
105
 
119
106
  ```json
120
107
  {
@@ -134,7 +121,7 @@ The `/deliver/telegram` endpoint accepts an optional `approval` field in the req
134
121
 
135
122
  **Inline keyboard format:** Each action is rendered as a single-button row. The callback data uses the compact format `apr:<requestId>:<action>` (e.g., `apr:request-uuid:approve_once`) so the runtime can parse it back when the button is clicked.
136
123
 
137
- **Fallback behavior:** For non-rich channels that do not support inline keyboards, the runtime substitutes the `plainTextFallback` string for the structured `promptText` before calling the delivery endpoint. The fallback includes plain-text instructions so the user can respond via text. The `supportsInlineOptions` channel capability (`channelSupportsInlineOptions()`) in the runtime determines which format to use. Free-text responses are classified by the conversational approval engine.
124
+ **Fallback behavior:** For non-rich channels that do not support inline keyboards, the runtime substitutes the `plainTextFallback` string for the structured `promptText` before handing the prompt to the channel transport. The fallback includes plain-text instructions so the user can respond via text. The `supportsInlineOptions` channel capability (`channelSupportsInlineOptions()`) in the runtime determines which format to use. Free-text responses are classified by the conversational approval engine.
138
125
 
139
126
  ## Public Ingress Routes
140
127
 
@@ -145,7 +132,6 @@ Control-plane routes are listed with their flat paths. Clients emit assistant-sc
145
132
  | Route | Method | Description |
146
133
  | ----------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
147
134
  | `/webhooks/telegram` | POST | Telegram bot webhook (validated via `TELEGRAM_WEBHOOK_SECRET`) |
148
- | `/deliver/telegram` | POST | Internal endpoint for the assistant runtime to deliver outbound messages/attachments to Telegram chats |
149
135
  | `/webhooks/twilio/voice` | POST | Twilio voice webhook (validated via HMAC-SHA1 signature) |
150
136
  | `/webhooks/twilio/status` | POST | Twilio status callback (validated via HMAC-SHA1 signature) |
151
137
  | `/webhooks/twilio/media-stream/:callSessionId/:token` | WS | Twilio Media Streams WebSocket (bidirectional proxy to runtime; handshake metadata in URL path segments) |
@@ -304,15 +290,13 @@ curl -i -X POST http://localhost:7830/webhooks/telegram
304
290
 
305
291
  ## Outbound Attachments (Telegram)
306
292
 
307
- When the assistant includes attachments in a reply, the gateway downloads each attachment from the runtime API and delivers it to the Telegram chat:
293
+ The gateway does not send attachments. When a reply carries attachments, the assistant's Telegram transport (`assistant/src/messaging/providers/telegram-bot/`) reads each one from the assistant's attachment store and uploads it to the Bot API itself:
308
294
 
309
- - **Images** (`image/*` MIME types) are sent via `sendPhoto` (multipart form upload).
310
- - **Other files** are sent via `sendDocument` (multipart form upload).
311
- - **Oversized** attachments (exceeding the hardcoded max attachment size, default 20 MB) are skipped and included in the partial-failure notice.
312
- - **Partial failures** are handled gracefully: each attachment is attempted independently. If any fail, a single summary notice is sent to the chat listing the undelivered filenames.
313
- - **Concurrency** is controlled by a hardcoded max concurrency limit (default 3).
295
+ - **Images** (`image/jpeg`, `image/png`, `image/gif`, `image/webp`) within the Bot API's 10 MB photo upload limit are sent via `sendPhoto`; **larger images and other files** via `sendDocument`.
296
+ - **Oversized** attachments (over the 50 MB `sendDocument` limit) are skipped.
297
+ - **Partial failures**: each attachment is attempted on its own, and if any fail, one summary notice lists the undelivered filenames.
314
298
 
315
- Text and attachments are sent separately — the text reply goes first via `sendMessage`, then each attachment follows.
299
+ The text reply goes first via `sendMessage`, then each attachment follows in order.
316
300
 
317
301
  ## Health & Readiness Probes
318
302
 
@@ -385,10 +369,24 @@ See [`benchmarking/gateway/README.md`](../benchmarking/gateway/README.md) for lo
385
369
 
386
370
  ### Guardian-Specific Troubleshooting
387
371
 
388
- | Symptom | Cause | Resolution |
389
- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
390
- | Guardian verification code reply gets no response | The verification message did not reach the runtime, or the challenge expired | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
391
- | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The runtime is fail-closed for unverified channels. | Set up a guardian by running the verification flow from the desktop UI. |
392
- | Approval prompt not delivered to guardian | The `replyCallbackUrl` may be unreachable, or the guardian's chat ID is stale | Verify `GATEWAY_PORT` is correct and the gateway is reachable at `http://127.0.0.1:<GATEWAY_PORT>` from the runtime (requires co-located networking in containerized deployments). Re-verify the guardian if the chat ID has changed. |
393
- | Guardian approval expired | The 30-minute TTL elapsed without a decision. A proactive sweep (every 60s) auto-denied the approval and notified both the requester and guardian. | The non-guardian user must re-trigger the action. |
394
- | "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `actorExternalId` matches the approval request can approve or deny. |
372
+ | Symptom | Cause | Resolution |
373
+ | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
374
+ | Guardian verification code reply gets no response | The code never reached the gateway, the challenge expired, or the assistant could not send the gateway's reply | Ensure the gateway is running, the bot token is valid, and the Telegram webhook is registered. Search the gateway log for `Verification reply`: a reply the assistant could not send does not undo the verification, so check the binding with the `channel-verification-sessions status` CLI command. Challenges expire after 10 minutes -- generate a new one via the desktop UI. |
375
+ | Non-guardian actions auto-denied with "no guardian configured" | No guardian binding exists for the channel. The runtime is fail-closed for unverified channels. | Set up a guardian by running the verification flow from the desktop UI. |
376
+ | Approval prompt not delivered to guardian | The assistant's channel transport could not send it (for example a missing or invalid bot token), or the guardian's chat ID is stale | Check the assistant log for the transport's delivery error and confirm the channel's bot token is configured. For a channel with a transport the assistant never dials the `replyCallbackUrl`, so gateway reachability is not the cause. Re-verify the guardian if the chat ID has changed. |
377
+ | Guardian approval expired | The 30-minute TTL elapsed without a decision. A proactive sweep (every 60s) auto-denied the approval and notified both the requester and guardian. | The non-guardian user must re-trigger the action. |
378
+ | "Only the verified guardian can approve or deny" | A non-guardian sender attempted to respond to a guardian approval prompt | Only the guardian whose `actorExternalId` matches the approval request can approve or deny. |
379
+
380
+ ### Binary virtual desktop tunnel
381
+
382
+ The gateway advertises `X-Vellum-Velay-Binary-WebSocket: 1` when registering.
383
+ It uses binary tunnel messages only when Velay sends `binary_messages: true`
384
+ on a `/v1/desktop/stream` open frame. The envelope is byte `0x01`, 32 lowercase
385
+ ASCII hex connection-ID bytes, then the unchanged payload (including empty
386
+ payloads). Control and text messages remain JSON. Other routes and older relays
387
+ retain JSON/base64 framing. Deploy Velay support before updating gateways;
388
+ older gateways can continue using the upgraded relay.
389
+
390
+ Malformed tunnel messages retain the existing log-and-ignore behavior. Raw binary
391
+ frames addressed to streams without desktop negotiation are ignored, preserving
392
+ other active streams on the shared tunnel.
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }
@@ -43,6 +43,26 @@ export type GatewayLogsTailRouteParams = z.infer<
43
43
  typeof GatewayLogsTailRouteParamsSchema
44
44
  >;
45
45
 
46
+ /**
47
+ * `gateway_debug_export`: a consistent copy of the gateway database plus its
48
+ * recent log files, as one gzipped tar, for a debug bundle. No params.
49
+ */
50
+ export const GatewayDebugExportIpcParamsSchema = z
51
+ .object({})
52
+ .strict()
53
+ .default({});
54
+
55
+ export const GatewayDebugExportIpcResponseSchema = z.object({
56
+ ok: z.literal(true),
57
+ /** `tar.gz` bytes, base64. Contains gateway.sqlite and gateway-logs/. */
58
+ archive_base64: z.string(),
59
+ size_bytes: z.number().int().nonnegative(),
60
+ });
61
+
62
+ export type GatewayDebugExportIpcResponse = z.infer<
63
+ typeof GatewayDebugExportIpcResponseSchema
64
+ >;
65
+
46
66
  export const GatewayLogsTailIpcResponseSchema = z.object({
47
67
  lines: z.array(z.record(z.string(), z.unknown())),
48
68
  truncated: z.boolean(),
@@ -24,13 +24,15 @@ export {
24
24
  PersistentIpcClient,
25
25
  } from "./ipc-client.js";
26
26
 
27
- // Outbound delivery contract (daemon → gateway) — Zod schemas + derived types
27
+ // Outbound delivery contract: Zod schemas + derived types
28
28
  export {
29
29
  ApprovalActionOptionSchema,
30
30
  ApprovalUIMetadataSchema,
31
31
  AttachmentMetadataSchema,
32
32
  ChannelDeliveryResultSchema,
33
33
  ChannelReplyPayloadSchema,
34
+ DELIVER_GATEWAY_REPLY_IPC_METHOD,
35
+ GatewayReplyRequestSchema,
34
36
  MessageAudienceSchema,
35
37
  PermissionRequestDetailsSchema,
36
38
  StreamOpSchema,
@@ -45,6 +47,7 @@ export type {
45
47
  AttachmentMetadata,
46
48
  ChannelDeliveryResult,
47
49
  ChannelReplyPayload,
50
+ GatewayReplyRequest,
48
51
  MessageAudience,
49
52
  PermissionRequestDetails,
50
53
  StreamOp,
@@ -1,13 +1,17 @@
1
1
  /**
2
- * Daemon → gateway outbound delivery contract.
2
+ * Outbound channel delivery contract.
3
3
  *
4
- * Zod schemas defining the wire format for channel replies delivered from
5
- * the daemon to the gateway via `POST /deliver/{channel}`. Both services
6
- * import from here so the contract is enforced at compile time.
4
+ * Zod schemas for a reply to a channel chat. The daemon constructs these
5
+ * payloads in `deliverChannelReply()` and `deliverApprovalPrompt()` and hands
6
+ * them to the channel transport its callback URL names
7
+ * (`messaging/providers`). `/deliver/{channel}` is only that callback URL's
8
+ * addressing form: no service serves it. A callback URL no
9
+ * transport owns (a managed callback carrying a `callback_token`) is POSTed
10
+ * over HTTP by `http-delivery.ts` instead.
7
11
  *
8
- * The daemon constructs these payloads in `deliverChannelReply()` and
9
- * `deliverApprovalPrompt()`; the gateway validates and dispatches them
10
- * to the target channel provider.
12
+ * The gateway sends through the same transports, never to a provider itself:
13
+ * a reply it composes for a message it answered at ingress goes to the daemon
14
+ * as a {@link GatewayReplyRequest}.
11
15
  */
12
16
 
13
17
  import type { KnownBlock } from "@slack/types";
@@ -279,6 +283,33 @@ export const ChannelReplyPayloadSchema = z.object({
279
283
 
280
284
  export type ChannelReplyPayload = z.infer<typeof ChannelReplyPayloadSchema>;
281
285
 
286
+ // ---------------------------------------------------------------------------
287
+ // Gateway reply: gateway to daemon
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Daemon IPC method that delivers a {@link GatewayReplyRequest}. IPC-only:
292
+ * it has no HTTP route.
293
+ */
294
+ export const DELIVER_GATEWAY_REPLY_IPC_METHOD = "deliver_gateway_reply";
295
+
296
+ /**
297
+ * A text reply the gateway composed for an inbound message it answered
298
+ * itself (a verification code, an invite redemption), which the daemon
299
+ * delivers through the channel transport `callbackUrl` names. The callback
300
+ * URL is the one the inbound message carried, so the reply lands in the chat
301
+ * and thread the person wrote from.
302
+ */
303
+ export const GatewayReplyRequestSchema = ChannelReplyPayloadSchema.pick({
304
+ chatId: true,
305
+ assistantId: true,
306
+ }).extend({
307
+ callbackUrl: z.string().min(1),
308
+ text: z.string().min(1),
309
+ });
310
+
311
+ export type GatewayReplyRequest = z.infer<typeof GatewayReplyRequestSchema>;
312
+
282
313
  // ---------------------------------------------------------------------------
283
314
  // Channel delivery result — gateway response
284
315
  // ---------------------------------------------------------------------------
@@ -3,9 +3,8 @@
3
3
  *
4
4
  * The gateway resolves this verdict from its ACL DB and stamps it onto the
5
5
  * inbound payload's `sourceMetadata`; the runtime consumes it. Keeping the
6
- * type here avoids the runtime importing from `gateway/src` and lets the
7
- * daemon's `trustClass` union (`actor-trust-resolver.ts`) converge on this
8
- * one source of truth.
6
+ * type here avoids the runtime importing from `gateway/src`, and the daemon
7
+ * imports {@link TrustClass} from here rather than declaring its own.
9
8
  *
10
9
  * This contract carries ACL + identity keys + minimal labels only — never
11
10
  * info fields (notes, userFile, contactType). `status` / `policy` / `type`
@@ -19,9 +18,9 @@ import { z } from "zod";
19
18
  import { AdmissionPolicySchema } from "./admission-policy-contract.js";
20
19
 
21
20
  /**
22
- * Verification-purpose trust classification. Mirrors the daemon's
23
- * `TrustClass` union (`actor-trust-resolver.ts`), ordered most- to
24
- * least-trusted.
21
+ * Trust classification of an inbound actor, ordered most- to least-trusted.
22
+ * The one declaration of the vocabulary: the gateway classifies with it and
23
+ * the daemon derives capabilities from it.
25
24
  */
26
25
  export const TRUST_CLASS_VALUES = [
27
26
  "guardian",
@@ -27,7 +27,8 @@
27
27
  "./reactions": "./src/reactions.ts",
28
28
  "./guardian-requests": "./src/guardian-requests.ts",
29
29
  "./platform-credential": "./src/platform-credential.ts",
30
- "./plan-credit": "./src/plan-credit.ts"
30
+ "./plan-credit": "./src/plan-credit.ts",
31
+ "./integration-categories": "./src/integration-categories.ts"
31
32
  },
32
33
  "scripts": {
33
34
  "typecheck": "bunx tsc --noEmit",
@@ -29,6 +29,7 @@ export * from "./trust-rules.js";
29
29
  export * from "./ingress.js";
30
30
  export * from "./no-response.js";
31
31
  export * from "./plan-credit.js";
32
+ export * from "./integration-categories.js";
32
33
  export * from "./platform-credential.js";
33
34
  export * from "./remote-web-pairing.js";
34
35
  export * from "./twilio-ingress.js";
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Category vocabulary for the integrations catalog, shared by the assistant
3
+ * daemon (which stamps it on OAuth providers and marketplace MCP entries) and
4
+ * the clients (which draw the category filter from it).
5
+ *
6
+ * The order here is the order clients show the categories in. The slug is the
7
+ * wire value; the user-facing label is the client's, so it can be translated.
8
+ */
9
+ export const INTEGRATION_CATEGORIES = [
10
+ "productivity",
11
+ "communication",
12
+ "meetings",
13
+ "sales",
14
+ "marketing",
15
+ "finance",
16
+ "commerce",
17
+ "engineering",
18
+ "knowledge",
19
+ "recruiting",
20
+ ] as const;
21
+
22
+ export type IntegrationCategory = (typeof INTEGRATION_CATEGORIES)[number];
23
+
24
+ const INTEGRATION_CATEGORY_SET: ReadonlySet<string> = new Set(
25
+ INTEGRATION_CATEGORIES,
26
+ );
27
+
28
+ /**
29
+ * Whether `value` names a category this build knows. A client reading a newer
30
+ * catalog treats an unknown slug as uncategorized rather than failing.
31
+ */
32
+ export function isIntegrationCategory(
33
+ value: unknown,
34
+ ): value is IntegrationCategory {
35
+ return typeof value === "string" && INTEGRATION_CATEGORY_SET.has(value);
36
+ }