@vellumai/vellum-gateway
Vellum Gateway
Standalone service that serves as the public ingress boundary for all external webhooks and callbacks. It owns Telegram integration end-to-end, routes Twilio voice webhooks, handles OAuth callbacks, and acts as an authenticated reverse proxy for the assistant runtime.
Architecture
Telegram → gateway/ → Assistant Runtime (/v1/assistants/:id/channels/inbound) → gateway/ → Telegram
Client → gateway/ (Bearer auth) → Assistant Runtime (any path)
The web app is not in the Telegram request path. All non-Telegram requests that don't match a dedicated gateway route are forwarded to the assistant runtime with bearer token authentication.
For ingress and channel architecture details, see ARCHITECTURE.md.
Setup
cd gateway
bun install
cp .env.example .env
# Edit .env with your configuration
bun run dev
Configuration
| Variable | Required | Default | Description |
|---|---|---|---|
TELEGRAM_BOT_TOKEN |
No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: CES HTTP API first (when CES_CREDENTIAL_URL is configured), then the encrypted file store (~/.vellum/protected/keys.enc). |
TELEGRAM_WEBHOOK_SECRET |
No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as TELEGRAM_BOT_TOKEN. |
GATEWAY_PORT |
No | 7830 |
Port for the gateway HTTP server |
Most gateway behavior is now configured via hardcoded defaults or workspace config ($VELLUM_WORKSPACE_DIR/config.json) rather than environment variables. Channel operational settings (Telegram API base URL, timeouts, deliver auth bypass flags, runtime base URL, routing, proxy settings, attachment limits, shutdown drain) are managed via workspace/config.json through ConfigFileCache. See the channel-specific sections in ARCHITECTURE.md for details.
Routing
v1 uses deterministic settings-based routing (no database):
- conversation_id match — explicit
conversation:<conversation_id>entry in routing config - actor_id match — explicit
actor:<actor_id>entry in routing config - Unmapped policy —
reject(drop with message) ordefault(forward to the configured default assistant)
Routing is configured via workspace config. See ARCHITECTURE.md for details.
Setting up the Telegram webhook
Webhook registration is now handled automatically by the gateway. On startup, the gateway reconciles the Telegram webhook by registering it at ${ingress.publicBaseUrl}/webhooks/telegram with the configured secret and allowed updates. This also runs whenever the credential watcher detects changes to the bot token or webhook secret (e.g., secret rotation). If the ingress URL changes (e.g., tunnel restart), the config file watcher detects the change and triggers webhook reconciliation directly — no daemon involvement or gateway restart is needed.
A managed platform pod owns no ingress of its own, so with the velay-webhooks flag off it registers a Django-hosted callback route instead of using an ingress URL. With the flag on, a pod claims /webhooks/telegram in the webhook ingress route registry and registers its published Velay URL, keeping the Django callback route as the fallback; flipping the flag re-runs the reconcile in either direction. See ARCHITECTURE.md for the full resolution order.
For manual setup (or reference), register the webhook with Telegram using the setWebhook API method. Pass:
url— your gateway URL, e.g.https://your-host/webhooks/telegram- The verify value matching your
TELEGRAM_WEBHOOK_SECRETenv var allowed_updates—["message", "edited_message", "callback_query"]
See the Telegram Bot API docs for the full API reference.
Voice Ingress — Inbound Calls (Twilio)
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.
Inbound voice routing
When the voice webhook is called without a callSessionId query parameter, the gateway treats it as an inbound call and resolves the assistant using the standard routing chain:
resolveAssistantByPhoneNumber(config, To)— Reverse lookup of the inboundTonumber againstassistantPhoneNumbers. If the dialed number matches an assistant's configured phone number, that assistant handles the call.- Fallback to
resolveAssistant(From, From)— If no phone number match is found, the standard routing chain is used:conversation_idmatch,actor_idmatch, then the local assistant. - Admission, not routing, denies a call — Routing resolves every identified caller (and anonymous callers too; voice lines are intentionally open). A call is refused with
<Reject reason="rejected"/>TwiML only when thephoneadmission policy isno_one, which is checked before routing runs. - Forward with assistantId — When routing succeeds, the gateway forwards the voice webhook to the runtime at
POST /v1/internal/twilio/voice-webhookwith a JSON body containing{ params, originalUrl, assistantId }. The runtime callscreateInboundVoiceSession()to bootstrap a session keyed by CallSid, then returns<Connect><Stream>TwiML pointing Twilio to the gateway's media-stream WebSocket proxy (after a daemon-side STT/TTS credential preflight; calls that fail it get<Say>setup-required copy plus<Hangup/>).
Inbound call lifecycle (gateway perspective)
Caller → Twilio → Gateway /webhooks/twilio/voice (no callSessionId)
→ phone admission floor (`no_one` → TwiML Reject)
→ resolveAssistantByPhoneNumber(To) || resolveAssistant(From) || local assistant
→ forward to runtime /v1/internal/twilio/voice-webhook (JSON: { params, originalUrl, assistantId })
→ runtime returns TwiML (<Connect><Stream> media-stream connect)
→ Twilio opens WebSocket → Gateway /webhooks/twilio/media-stream/<callSessionId>/<token> → Runtime /v1/calls/media-stream
→ media-stream server detects inbound (`initiatedFromConversationId == null`), runs the call setup flow (verification / invite / name-capture sub-flows as routed), then receptionist-style LLM greeting
Callback Query Handling
The gateway normalizes Telegram callback_query updates (inline button clicks) into the same GatewayInboundEvent format used for regular messages. When a callback_query is present in the webhook payload, the normalizer extracts:
callbackQueryId— the Telegram callback query IDcallbackData— the opaque data string attached to the button (e.g.,apr:<requestId>:<action>)content— set to the callback data string (so the runtime always has content to process)
These fields are forwarded to the runtime in the /channels/inbound payload alongside the standard conversationExternalId, externalMessageId, and actor metadata. The runtime uses callbackData to route the click to the appropriate approval handler.
Normalization constraints: Callback queries from private chats, groups, and supergroups are processed; a tap on a keyboard the bot posted is addressed to the bot by construction, and who may press it is the runtime's decision. Callbacks from broadcast channels are dropped and acknowledged with answerCallbackQuery so the Telegram button spinner clears. Callback queries with no data field or no associated message are also dropped. Every drop is logged with its reason before the update is acknowledged (telegram/drop-log.ts).
Stale callback blocking: When the runtime receives callbackData that does not match any pending approval (e.g., a button from an old prompt), it returns stale_ignored and does not process the payload as a regular message. This is enforced regardless of whether the callback has non-empty content. The gateway sends a best-effort answerCallbackQuery acknowledgment for normalized callback updates (including stale, rejected, and forward-failure paths) so the button spinner clears promptly. Transient forwarding failures may still return 500 so Telegram retries update delivery.
Approval Buttons and Inline Keyboard
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.
Approval reply payload shape:
{
"chatId": "123456",
"text": "The assistant wants to use the tool \"bash\". Do you want to allow this?",
"approval": {
"requestId": "request-uuid",
"actions": [
{ "id": "approve_once", "label": "Approve once" },
{ "id": "approve_always", "label": "Approve always" },
{ "id": "reject", "label": "Reject" }
],
"plainTextFallback": "Reply \"yes\" to approve once, \"always\" to approve always, or \"no\" to reject."
}
}
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.
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.
Public Ingress Routes
The gateway serves as the single public ingress point for all external callbacks. The following routes are handled directly by the gateway before any proxy forwarding:
Control-plane routes are listed with their flat paths. Clients emit assistant-scoped URLs (/v1/assistants/{id}/...) because multi-tenant cloud routing needs the id; the deployment boundary strips the prefix before the request reaches the gateway — cloud's Django RuntimeProxyView strips it for every route, and the self-hosted web client's ingress rewrite (rewriteForSelfHostedIngress in clients/web/src/lib/api-interceptors.ts) flattens the contact family (contacts, contact-channels) while forwarding other segments scoped, verbatim. Contacts are therefore served flat-only, while gateway-global families that self-hosted clients still reach with scoped paths (trust-rules, channel-admission-policy, channel-permission-overrides, backups) also register assistant-scoped variants that match and discard the id. The flattening spans the whole contacts/contact-channels segment family: self-hosted invites requests land on the gateway's flat edge-scoped invite routes (the same invite engine the assistant relays to), and assistant-only subpaths (contacts/search, contacts/prompt) fall through the runtime-proxy catch-all to the assistant verbatim. Never remove a flat control-plane route — the flat family is the load-bearing cloud path.
| Route | Method | Description |
|---|---|---|
/webhooks/telegram |
POST | Telegram bot webhook (validated via TELEGRAM_WEBHOOK_SECRET) |
/webhooks/twilio/voice |
POST | Twilio voice webhook (validated via HMAC-SHA1 signature) |
/webhooks/twilio/status |
POST | Twilio status callback (validated via HMAC-SHA1 signature) |
/webhooks/twilio/media-stream/:callSessionId/:token |
WS | Twilio Media Streams WebSocket (bidirectional proxy to runtime; handshake metadata in URL path segments) |
/webhooks/oauth/callback |
GET | OAuth2 callback endpoint — receives authorization codes from OAuth providers (Google, Slack, etc.) and forwards them to the assistant runtime |
/oauth/client-metadata.json |
GET | Public Client ID Metadata Document for MCP authorization servers that support URL-based client identifiers |
/v1/channel-verification-sessions |
POST | Authenticated control-plane proxy for creating verification sessions (inbound challenge or outbound verification) |
/v1/channel-verification-sessions |
DELETE | Authenticated control-plane proxy for cancelling active verification sessions |
/v1/channel-verification-sessions/resend |
POST | Authenticated control-plane proxy for resending outbound verification code |
/v1/channel-verification-sessions/status |
GET | Authenticated control-plane proxy for verification binding status |
/v1/channel-verification-sessions/revoke |
POST | Authenticated control-plane proxy for revoking verification binding (cancels sessions and removes binding) |
/v1/integrations/telegram/config |
GET/POST/DELETE | Authenticated control-plane proxy for Telegram integration config |
/v1/integrations/telegram/commands |
POST | Authenticated control-plane proxy for Telegram command registration |
/v1/integrations/telegram/setup |
POST | Authenticated control-plane proxy for Telegram setup orchestration |
/v1/contacts |
GET/POST | Gateway-native contact list and upsert (search-filtered lists relay through the assistant runtime with a gateway ACL overlay) |
/v1/contacts/:id |
GET/DELETE | Gateway-native contact retrieval and deletion |
/v1/contacts/merge |
POST | Gateway-native transactional contact merge |
/v1/contacts/prompt/submit |
POST | Contact info submitted in response to a contact_request prompt — gateway-first upsert, then IPC to unblock the waiting flow |
/v1/contacts/guardian/channel |
POST | Guardian-authenticated guardian channel creation (platform auto-verify) |
/v1/contact-channels/:contactChannelId |
PATCH | Gateway-native contact-channel status/policy update |
/v1/contact-channels/:contactChannelId/verify |
POST | Guardian-authenticated manual channel verification |
/v1/contacts/invites |
GET/POST | Gateway-native invite list/create against the gateway DB's ingress_invites table |
/v1/contacts/invites/:id |
DELETE | Gateway-native invite revoke |
/v1/contacts/invites/:id/call |
POST | Gateway-native invite-call relay — validates the invite row, then delegates the provider call to the assistant |
/v1/contacts/invites/redeem |
POST | Gateway-native invite redemption (voice code and link token) |
/v1/health |
GET | Authenticated runtime health proxy (/v1/health on runtime) |
/healthz |
GET | Liveness probe |
/readyz |
GET | Readiness probe |
/schema |
GET | Returns the OpenAPI 3.1 schema for this gateway |
Webhook Ingress Route Registry
Which of the /webhooks/* routes above an assistant actually answers through the Velay tunnel is a per-assistant allowlist, gated on the velay-webhooks feature flag. With the flag off the whole namespace is reachable, as it always was; with it on an assistant answers exactly the paths it has claimed.
Three layers admit a request, each narrower than the last: Velay drops anything matching none of the RE2 rules the gateway advertises on the tunnel upgrade, the gateway's Velay bridge re-checks the registry as the frame arrives, and the route itself runs its own authentication (Telegram's secret_token, Twilio's signature, a plugin webhook's token).
Claims are rows in webhook_ingress_routes in gateway.sqlite, which lives in GATEWAY_SECURITY_DIR and therefore survives a pod restart. The daemon claims over IPC for plugin webhooks; the gateway claims in-process for Telegram and Twilio. A claim that is unavailable, because the flag is off, no tunnel URL is published, or the write failed, falls back to registering a Django-hosted callback route, which is also the flag-off path. A new claim triggers a debounced tunnel reconnect so the edge rules catch up, so a path is always claimed before its URL is given to a provider.
The /webhooks/twilio/ subtree is still admitted by a static prefix rule, because the media-stream path carries per-call segments an exact-match row cannot express. See ARCHITECTURE.md for the full lifecycle.
Tunnel Setup
To receive external callbacks during local development, point a tunnel service at the local gateway (default http://127.0.0.1:7830) and configure the resulting public URL. Ngrok, Cloudflare Tunnel, and other custom HTTPS/WSS tunnels remain supported.
Test Gateway Source Changes Locally (No Release Needed)
Use this flow when you are changing files under gateway/ and need to validate immediately without publishing @vellumai/vellum-gateway.
# Terminal 1: restart assistant runtime HTTP server
cd assistant
bun run assistant:restart:http
# Terminal 2: run gateway from local source
cd gateway
bun run dev
If 7830 is already in use, start the gateway on another port:
cd gateway
GATEWAY_PORT=7840 bun run dev
Then point your tunnel to that same local target (for example http://127.0.0.1:7840).
- Start your tunnel (e.g. ngrok, Cloudflare Tunnel, or similar) targeting
http://127.0.0.1:7830 - Copy the public URL provided by the tunnel service (e.g.
https://abc123.ngrok-free.app) - Set the URL as
ingress.publicBaseUrlin the Settings UI (Public Ingress section). - Use the Settings UI "Local Gateway Target" value as the source of truth for tunnel destination (it reflects
GATEWAY_PORT).
In local tunnel setups, updating ingress.publicBaseUrl in Settings is typically live for Twilio inbound validation (no manual gateway restart required) because the gateway also validates signatures against forwarded public URL headers.
The assistant runtime uses this URL to construct all webhook and OAuth callback URLs automatically.
Velay for Twilio Testing
Velay is a managed ingress transport for assistant-hosted HTTP and WebSocket traffic. The gateway starts the Velay tunnel only after Twilio setup has been started in the workspace, or when existing Twilio config shows it was set up before. When Velay registration succeeds, the gateway writes the registered public assistant URL to ingress.publicBaseUrl and marks it with ingress.publicBaseUrlManagedBy: "velay". Twilio URL builders use that public base URL for voice, status, and media-stream endpoints.
Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades through the platform-managed tunnel:
In
vellum-assistant-platform, start the local Velay service:vel up velayEnsure vembda injects the Velay endpoint into assistant gateway containers. For local Docker-hosted assistants, the gateway container must dial the Velay service running on the host:
VELAY_BASE_URL=http://host.docker.internal:8501Hosted environments should use their environment's deployed Velay URL instead.
Start or complete Twilio setup in the workspace so the gateway is allowed to connect the tunnel.
Re-hatch or restart the assistant so the gateway process receives
VELAY_BASE_URL.Confirm the gateway logs include
Velay tunnel connectedfollowed byVelay tunnel registered. Registration publishes the returned Velay URL toingress.publicBaseUrl.
For an HTTP bridge smoke test, send a request to the registered Velay public URL and confirm it reaches the loopback gateway, for example:
curl -i "$VELAY_PUBLIC_BASE_URL/<assistant-id>/healthz"
curl -i "$VELAY_PUBLIC_BASE_URL/<assistant-id>/schema"
When testing a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm the gateway logs or handler response show the request reached the loopback listener.
For a synthetic Twilio WebSocket smoke test, connect a local WebSocket client to the Velay public URL using one of the gateway Twilio WebSocket paths, such as:
bun -e 'const ws = new WebSocket(process.argv[1]); ws.onopen = () => { console.log("open"); ws.close(); }; ws.onerror = (event) => console.error(event);' \
"wss://<velay-host>/<assistant-id>/webhooks/twilio/media-stream/session-123/<edge-token>"
For a real Twilio call, expose local Velay with a public HTTPS/WSS tunnel and configure the platform Velay service with that origin as VELAY_PUBLIC_BASE_URL. After the assistant re-registers, Twilio should fetch /webhooks/twilio/voice and open /webhooks/twilio/media-stream/... through the Velay URL. Use ngrok or another custom tunnel in ingress.publicBaseUrl only for local/self-hosted workflows that are not routed through Velay.
Ingress Boundary Guarantees
The gateway is the sole public ingress point for all external webhooks. The assistant runtime never directly accepts public webhook traffic — all Twilio and Telegram webhook routes on the runtime return 410 GATEWAY_ONLY when accessed directly.
Signature URL Tightening
When the ingress public base URL is configured (via ingress.publicBaseUrl in workspace config, read through ConfigFileCache), the gateway prioritizes it as the canonical URL for Twilio signature validation. If the signature only validates against the raw local request URL (fallback), a warning is logged indicating potential drift between the configured ingress URL and the actual webhook registration. The raw URL fallback is preserved for local-dev operability.
Runtime Proxy
The gateway acts as the single ingress point for all traffic. Dedicated gateway routes (webhooks, control-plane proxies, health checks) are matched first; any request that doesn't match a specific route is forwarded to the assistant runtime via a catch-all proxy.
Auth behavior
By default, proxied requests must include a valid Authorization: Bearer <jwt> header with a JWT signed by the shared signing key. Setting RUNTIME_PROXY_REQUIRE_AUTH=false in the gateway process environment drops that requirement; workspace config is not consulted.
OPTIONS requests are always allowed without auth (CORS preflight). Telegram webhook requests use their own secret-based verification and are not affected by proxy auth.
Examples
# Unauthorized (expect 401 when auth required)
curl -i http://localhost:7830/v1/assistants/test/health
# Authorized with JWT (expect 200)
curl -i \
-H "Authorization: Bearer <jwt>" \
http://localhost:7830/v1/assistants/test/health
# Telegram still uses webhook secret flow, not bearer auth
curl -i -X POST http://localhost:7830/webhooks/telegram
Proxy details
- Method, path, query string, headers, and body are forwarded to upstream.
- Hop-by-hop headers (
connection,keep-alive,transfer-encoding, etc.) are stripped from both request and response. - The
hostheader is not forwarded to upstream. - Upstream connection failures return
502 Bad Gateway.
Outbound Attachments (Telegram)
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:
- Images (
image/jpeg,image/png,image/gif,image/webp) within the Bot API's 10 MB photo upload limit are sent viasendPhoto; larger images and other files viasendDocument. - Oversized attachments (over the 50 MB
sendDocumentlimit) are skipped. - Partial failures: each attachment is attempted on its own, and if any fail, one summary notice lists the undelivered filenames.
The text reply goes first via sendMessage, then each attachment follows in order.
Health & Readiness Probes
| Endpoint | Method | Behavior |
|---|---|---|
/v1/health |
GET | Authenticated proxy to runtime health (/v1/health) |
/healthz |
GET | Always returns 200 while the process is alive |
/readyz |
GET | Returns 200 while accepting traffic; 503 during graceful shutdown drain |
On SIGTERM the gateway enters drain mode: /readyz begins returning 503 so the load balancer stops sending new traffic. After the hardcoded shutdown drain window (default 5 s) the process exits.
Docker
# Build
docker build -t vellum-gateway:local gateway
# Run (pass required env vars)
docker run --rm -p 7830:7830 \
-e TELEGRAM_BOT_TOKEN=... \
-e TELEGRAM_WEBHOOK_SECRET=... \
vellum-gateway:local
The image runs as non-root user gateway (uid 1001) and exposes port 7830.
The runtime base URL is derived from RUNTIME_HTTP_PORT as http://localhost:${RUNTIME_HTTP_PORT} (default port 7821). The gateway internal base URL is always derived from GATEWAY_PORT as http://127.0.0.1:${GATEWAY_PORT} (default 7830). Both hosts are hardcoded to localhost — the gateway and runtime must be co-located (e.g., same host, --network host, or Docker Compose with shared networking). Separate-host deployments are not currently supported.
Development
cd gateway
bun install
bun run typecheck # TypeScript type check (tsc --noEmit)
bun run test # Run test suite
Both checks run in CI on every pull request touching gateway/.
CI/CD
| Workflow | Trigger | What it does |
|---|---|---|
ci-gateway.yml |
PR (gateway/**) |
Typecheck + tests |
ci-gateway-image.yml |
PR (gateway/**) |
Build Docker image + smoke check |
cd-gateway-image.yml |
Push to main (gateway/**) |
Build + push image to GCR |
The CD workflow requires these GitHub repository variables:
GCP_WORKLOAD_IDENTITY_PROVIDER— OIDC provider for keyless authGCP_SERVICE_ACCOUNT— Service account with push permissionsGCP_PROJECT_ID— GCP project IDGATEWAY_IMAGE_NAME— Image name (e.g.vellum-gateway)GCP_REGISTRY_HOST— Registry host (e.g.gcr.io)
Load Testing
See benchmarking/gateway/README.md for load-test scripts and throughput targets.
Troubleshooting
| Symptom | Check |
|---|---|
| Telegram messages not arriving | Is the webhook registered? curl https://api.telegram.org/bot<TOKEN>/getWebhookInfo |
| 401 on webhook | Does TELEGRAM_WEBHOOK_SECRET match the secret_token in setWebhook? |
| Inbound messages silently dropped | Routing only drops events with no conversation and no actor id ("No routable identity" in the routing logger). Anything else resolves; if it still never reaches the assistant, check the channel's admission policy floor. |
| Runtime errors | Is the assistant runtime reachable? Check runtime logs. |
| No reply from assistant | Is the assistant runtime processing messages? Check that the runtime HTTP server is running. |
| 403 on channel inbound | The runtime rejected the request because JWT authentication failed. Ensure the gateway and runtime share the same signing key (~/.vellum/protected/actor-token-signing-key). |
Guardian-Specific Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
| 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. |
| 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. |
| 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. |
| 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. |
| "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. |
Binary virtual desktop tunnel
The gateway advertises X-Vellum-Velay-Binary-WebSocket: 1 when registering.
It uses binary tunnel messages only when Velay sends binary_messages: true
on a /v1/desktop/stream open frame. The envelope is byte 0x01, 32 lowercase
ASCII hex connection-ID bytes, then the unchanged payload (including empty
payloads). Control and text messages remain JSON. Other routes and older relays
retain JSON/base64 framing. Deploy Velay support before updating gateways;
older gateways can continue using the upgraded relay.
Malformed tunnel messages retain the existing log-and-ignore behavior. Raw binary frames addressed to streams without desktop negotiation are ignored, preserving other active streams on the shared tunnel.