npm.io
0.12.6 • Published 1 week agoCLI

@vellumai/vellum-gateway

Licence
MIT
Version
0.12.6
Deps
17
Size
72.4 MB
Vulns
2
Weekly
30.8K
Install scriptsThis package runs scripts during installation (preinstall/install/postinstall)

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):

  1. conversation_id match — explicit conversation:<conversation_id> entry in routing config
  2. actor_id match — explicit actor:<actor_id> entry in routing config
  3. Unmapped policy — reject (drop with message) or default (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_SECRET env 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:

  1. resolveAssistantByPhoneNumber(config, To) — Reverse lookup of the inbound To number against assistantPhoneNumbers. If the dialed number matches an assistant's configured phone number, that assistant handles the call.
  2. Fallback to resolveAssistant(From, From) — If no phone number match is found, the standard routing chain is used: conversation_id match, actor_id match, then the local assistant.
  3. 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 the phone admission policy is no_one, which is checked before routing runs.
  4. Forward with assistantId — When routing succeeds, the gateway forwards the voice webhook to the runtime at POST /v1/internal/twilio/voice-webhook with a JSON body containing { params, originalUrl, assistantId }. The runtime calls createInboundVoiceSession() 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 ID
  • callbackData — 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).

  1. Start your tunnel (e.g. ngrok, Cloudflare Tunnel, or similar) targeting http://127.0.0.1:7830
  2. Copy the public URL provided by the tunnel service (e.g. https://abc123.ngrok-free.app)
  3. Set the URL as ingress.publicBaseUrl in the Settings UI (Public Ingress section).
  4. 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:

  1. In vellum-assistant-platform, start the local Velay service:

    vel up velay
  2. Ensure 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:8501

    Hosted environments should use their environment's deployed Velay URL instead.

  3. Start or complete Twilio setup in the workspace so the gateway is allowed to connect the tunnel.

  4. Re-hatch or restart the assistant so the gateway process receives VELAY_BASE_URL.

  5. Confirm the gateway logs include Velay tunnel connected followed by Velay tunnel registered. Registration publishes the returned Velay URL to ingress.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 host header 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 via sendPhoto; larger images and other files via sendDocument.
  • Oversized attachments (over the 50 MB sendDocument limit) 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 auth
  • GCP_SERVICE_ACCOUNT — Service account with push permissions
  • GCP_PROJECT_ID — GCP project ID
  • GATEWAY_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.