On this page

WebSocket connections provide persistent, bidirectional communication between agents. They solve the NAT traversal problem: an agent behind NAT can connect outbound to a reachable peer, establishing a pipe for message delivery in both directions.

WebSockets are a transport layer carrying two kinds of frames. Text frames default to ALF messages (one ALF message per frame), and inbox rows are identical regardless of transport. Binary frames carry arbitrary bytes (Uint8Array), so a connection is not limited to ALF — agents can stream raw data, proxy protocols, or move large payloads. Combined with backpressure-aware sends and the hot-path lambda, this lets agents operate as web-scale infrastructure rather than just message endpoints.

Configuration

Inbound Connections (Receiving)

To accept inbound WebSocket connections, add a WS route to serving.api:

{
  "serving": {
    "api": [
      {
        "method": "WS",
        "path": "/ws",
        "lambda": "lib/ws-handler.ts:onEvent"
      }
    ]
  }
}

WS routes require a lambda handler. A WS route lives in the agent’s own URL namespace and is reached at its declared path — there is no fixed WebSocket path. When an agent has a WS route, its agent card includes a ws endpoint derived from that path (here the route path is /ws):

{
  "endpoints": {
    "inbox": "http://127.0.0.1:7295/agents/my-agent/inbox",
    "card": "http://127.0.0.1:7295/agents/my-agent/card",
    "health": "http://127.0.0.1:7295/agents/my-agent/health",
    "ws": "ws://127.0.0.1:7295/agents/my-agent/ws"
  }
}

Clients connect to the ws endpoint. After the authentication handshake, all frames are dispatched to the lambda handler.

Outbound Connections (Connecting)

To connect outbound to another agent’s WebSocket endpoint, add entries to ws_connections:

{
  "ws_connections": [
    {
      "id": "relay",
      "url": "wss://relay.example.com/my-agent/ws",
      "did": "did:key:z6MkRelay...",
      "enabled": true,
      "lambda": "lib/ws-handler.ts:onEvent",
      "auto_reconnect": true,
      "reconnect_delay_ms": 5000,
      "keepalive_interval_ms": 30000
    }
  ]
}
FieldRequiredDefaultDescription
idYes—Unique identifier for this connection
urlYes—WebSocket URL to connect to
didNo—Expected remote DID (verified during auth)
enabledYes—Whether to auto-connect on agent start
lambdaNo—Lambda handler for hot-path events
authNoautoAuth mode: auto (auth if key available), required (always auth), none (skip auth)
auto_reconnectNotrueReconnect on unexpected close
reconnect_delay_msNo5000Base delay between reconnection attempts
keepalive_interval_msNo30000Interval for ping/pong keepalive

Agents can add entries themselves — HIL-gated (your principal approves); see sys_update_config:

sys_update_config({ path: "ws_connections", action: "append", value: { id: "relay", url: "wss://relay.example.com/my-agent/ws", enabled: true, auth: "auto" } })

Existing ws_connections elements are addressed by numeric index, not name — ws_connections.0.enabled, never ws_connections.relay.enabled (entries are keyed by id, and name-based path segments do not resolve for them).

Hot Path vs Cold Path

Hot Path (Lambda)

When a lambda is configured, all WebSocket events are dispatched to the lambda function. The lambda receives a WsLambdaEvent:

interface WsLambdaEvent {
  type: 'open' | 'message' | 'close' | 'error'
  connection_id: string
  remote_did?: string
  data?: string          // on 'message'
  code?: number          // on 'close'
  reason?: string        // on 'close' / 'error'
  error?: string         // on 'error'
  timestamp: number
}

Example lambda handler:

export async function onEvent(event: WsLambdaEvent) {
  if (event.type === 'message') {
    const msg = JSON.parse(event.data!)
    // Process the message
    await adf.ws_send({ connection_id: event.connection_id, data: JSON.stringify({ ack: true }) })
  }
  if (event.type === 'open') {
    console.log(`Connected to ${event.remote_did}`)
  }
}

The lambda sandbox is persistent (warm) for the lifetime of the agent — module-level state is shared across all WS events. Isolation hazard: because the sandbox stays warm across the agent’s whole lifetime, module-level (top-of-file) state is shared across all connections, not scoped per-connection. Two concurrent clients hitting the same WS route execute in the same module scope — a module-level variable set for one connection is visible to every other. Key any per-connection state by connection_id (e.g. a Map<connection_id, state>) rather than storing it in a bare module-level variable.

Cold Path (Inbox)

When no lambda is configured on an outbound connection, incoming text frames are validated as ALF messages and processed through the standard ingress pipeline (signature verification, inbox middleware, inbox storage, triggers). The message appears in the inbox identically to one received via HTTP POST.

Inbound WS connections always use the hot path (lambda is required by schema).

Authentication

Connections use mutual DID authentication via Ed25519 signatures. See the ALF protocol WebSocket handshake for the wire protocol.

Inbound Auth

The agent’s security.allow_unsigned setting (owner-only — a guard path, not agent-writable; ask your principal) controls whether inbound clients must authenticate. When allow_unsigned: true, clients may optionally send an auth frame to claim a DID, but it is accepted without cryptographic verification and stamped as identity_verified: false. Messages from unverified connections show an amber “unverified” badge in the inbox UI.

Outbound Auth

Outbound auth is controlled per-connection via the auth field (not by the agent’s allow_unsigned setting):

  • auto (default) — Authenticate if a private key is available. This ensures connections to auth-requiring servers work even if the local agent has allow_unsigned: true.
  • required — Always authenticate. Fails immediately if no private key is available.
  • none — Skip authentication entirely.

Identity Verification

Cold-path messages (ALF over WS without a lambda) are stamped with meta.identity_verified:

  • true when the connection was mutually authenticated via Ed25519
  • false when the connection is unsigned or the DID was claimed without verification

The stamp is applied by the ingress pipeline, after crypto verification — the WS transport passes the verified identity out-of-band into ingress; it is never read from the message body. Any wire-supplied identity_verified or ws_remote_did on an incoming frame is always discarded before the runtime re-stamps its own value. Because the stamp is transport-derived, HTTP-delivered messages carry no identity_verified at all — the field is WS-only.

When a connection has a verified identity, messages with a from field that doesn’t match the authenticated DID are rejected (close code 4003).

Transport Resolution

When sending messages via msg_send, the runtime automatically selects the best transport:

  1. Local — Recipient is on the same runtime (direct inbox write)
  2. WebSocket — An active, authenticated WS connection exists to the recipient’s DID
  3. HTTP POST — Default fallback

If WebSocket delivery fails (connection died between resolution and send), the runtime falls through to HTTP. Custom outbox middleware can override transport selection.

Reconnection

Outbound connections auto-reconnect on unexpected close (any code other than 1000 or 1001):

  • Increasing delay: reconnect_delay_ms * attempt (1x, 2x, 3x, 4x, 5x)
  • After 5 consecutive failures, reconnection stops. The agent can re-initiate via ws_connect or a timer-triggered lambda.
  • Counter resets after successful authentication (or after entering the no-auth path). A server that accepts TCP but immediately closes or rejects auth will correctly exhaust attempts.
  • Set auto_reconnect: false to disable.

Keepalive

The runtime sends WebSocket pings at keepalive_interval_ms intervals (default 30s). If no pong is received within 10s, the connection is considered dead and closed, triggering reconnection for outbound connections.

Binary frames

ws_send accepts text (string) and binary (Uint8Array) payloads. From sandbox code:

// Text frame (default)
await adf.ws_send({ connection_id, data: 'hello' })

// Binary frame
await adf.ws_send({ connection_id, data: new Uint8Array([0x01, 0x02, 0x03]) })

From direct LLM tool calls (no Uint8Array support in JSON), pass base64 with binary: true:

{
  "connection_id": "abc",
  "data": "AQID",
  "binary": true
}

Inbound frames reach the handler lambda with a binary: boolean flag:

export async function onEvent(event) {
  if (event.binary) {
    const bytes = event.data as Uint8Array  // raw binary
    // ...
  } else {
    const text = event.data as string       // text frame
    // ...
  }
}

Cold-path connections (no lambda configured) drop binary frames with a warn log — text frames continue to validate as ALF messages.

Backpressure

ws_send awaits a drain when the socket’s bufferedAmount exceeds the per-connection high-water mark:

// Awaits if the socket is buffered over the threshold.
await adf.ws_send({ connection_id, data: chunk })

Configurable per connection:

  • Outbound: ws_connections[].high_water_mark_bytes (default 1 MiB)
  • Inbound: on the matching WS route in serving.api[].high_water_mark_bytes

Callers that don’t await retain current fire-and-forget behavior — the drain wait is only observed if you await the returned promise.

Request metadata on open

Inbound connections populate event.url_params (parsed query string) and event.headers (upgrade request headers) on the open event:

// Client: wss://host/agents/:handle/ws?stream=abc123
export async function onEvent(event) {
  if (event.type === 'open') {
    const streamId = event.url_params?.stream
    const userAgent = event.headers?.['user-agent']
    // ...
  }
}

This lets a single WS endpoint disambiguate multiple concurrent sessions without requiring path-based routing.

Tools

Four tools are available for runtime WebSocket management (all disabled by default):

ToolDescription
ws_connectStart a connection (by config ID or ad-hoc URL)
ws_disconnectClose a connection
ws_connectionsList active connections
ws_sendSend data over a connection

Ad-hoc ws_connect URLs pass through the same egress (SSRF) guard as sys_fetch — loopback is allowed by default (except the daemon control API), while link-local (including cloud metadata) and RFC1918/CGNAT targets are blocked on the DNS-resolved address. The escape hatch for private/LAN targets, security.allow_local_fetch, is locked by default in the runtime — a write is denied but surfaces as a protection request your principal can approve as a one-time override.

Enable them in agent config:

{
  "tools": [
    { "name": "ws_connect", "enabled": true },
    { "name": "ws_disconnect", "enabled": true },
    { "name": "ws_connections", "enabled": true },
    { "name": "ws_send", "enabled": true }
  ]
}

Or by name via sys_update_config — e.g. tools.ws_send.enabled — HIL-gated (your principal approves).

UI Configuration

Inbound WS Route (Agent Config > Serving > API Routes)

To add an inbound WebSocket route via the UI:

  1. Go to Agent Config > Serving > API Routes
  2. Click Add Route
  3. Select WS from the method dropdown
  4. Enter the path (e.g., /ws)
  5. Enter the lambda reference (e.g., lib/ws-handler.ts:onEvent) — required for WS routes

When WS is selected as the method, the warm, cache TTL, and middleware options are hidden since they don’t apply to WebSocket routes.

Outbound Connections (Agent Config > WebSocket Connections)

The WebSocket Connections section appears in Agent Config between Serving and Metadata. It manages outbound ws_connections entries.

For each connection, the UI provides:

FieldControlDescription
EnabledCheckboxWhether to auto-connect on agent start
IDText inputUnique identifier for this connection
URLText inputWebSocket URL to connect to (e.g., wss://relay.example.com/agent/ws)
DIDText inputExpected remote DID (optional — verified during auth)
LambdaText inputLambda handler for hot-path events (e.g., lib/ws-handler.ts:onEvent)
AuthSelectAuth mode: auto (default), required, or none
Auto ReconnectCheckboxReconnect on unexpected close (default: on)
Reconnect DelayNumber inputBase delay in ms between reconnection attempts (default: 5000)
Keepalive IntervalNumber inputPing/pong interval in ms (default: 30000)

Use Add Connection to add a new outbound connection entry, and the Remove button to delete one.