WebSocket Connections
Persistent bidirectional agent connections and NAT traversal: hot/cold paths, auth, reconnection, binary frames, backpressure
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
}
]
}
| Field | Required | Default | Description |
|---|---|---|---|
id | Yes | — | Unique identifier for this connection |
url | Yes | — | WebSocket URL to connect to |
did | No | — | Expected remote DID (verified during auth) |
enabled | Yes | — | Whether to auto-connect on agent start |
lambda | No | — | Lambda handler for hot-path events |
auth | No | auto | Auth mode: auto (auth if key available), required (always auth), none (skip auth) |
auto_reconnect | No | true | Reconnect on unexpected close |
reconnect_delay_ms | No | 5000 | Base delay between reconnection attempts |
keepalive_interval_ms | No | 30000 | Interval 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 hasallow_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:
truewhen the connection was mutually authenticated via Ed25519falsewhen 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:
- Local — Recipient is on the same runtime (direct inbox write)
- WebSocket — An active, authenticated WS connection exists to the recipient’s DID
- 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_connector 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: falseto 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):
| Tool | Description |
|---|---|
ws_connect | Start a connection (by config ID or ad-hoc URL) |
ws_disconnect | Close a connection |
ws_connections | List active connections |
ws_send | Send 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:
- Go to Agent Config > Serving > API Routes
- Click Add Route
- Select WS from the method dropdown
- Enter the path (e.g.,
/ws) - 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:
| Field | Control | Description |
|---|---|---|
| Enabled | Checkbox | Whether to auto-connect on agent start |
| ID | Text input | Unique identifier for this connection |
| URL | Text input | WebSocket URL to connect to (e.g., wss://relay.example.com/agent/ws) |
| DID | Text input | Expected remote DID (optional — verified during auth) |
| Lambda | Text input | Lambda handler for hot-path events (e.g., lib/ws-handler.ts:onEvent) |
| Auth | Select | Auth mode: auto (default), required, or none |
| Auto Reconnect | Checkbox | Reconnect on unexpected close (default: on) |
| Reconnect Delay | Number input | Base delay in ms between reconnection attempts (default: 5000) |
| Keepalive Interval | Number input | Ping/pong interval in ms (default: 30000) |
Use Add Connection to add a new outbound connection entry, and the Remove button to delete one.