FabricFabricHarness
Reference

HTTP Server

REST + SSE conventions for invoking, reading, and streaming agent sessions over HTTP. Identical surface across the Node and Cloudflare server targets.

Both @fabric-harness/node and the Cloudflare build target expose the same HTTP shape. Sessions are namespaced by agent name and session id: /agents/:name/:id.

Routes

MethodPathPurpose
POST/agents/:name/:idInvoke the agent for session :id. Body is the agent's input payload. Returns { result, sessionId, agentPath }.
GET/agents/:name/:idRead session state and history. Returns the full session timeline (entries, events, artifacts, approvals).
GET/agents/:name/:id/streamSubscribe to a server-sent event stream of typed AgentEvent values for this session.
DELETE/agents/:name/:idReserved — currently returns 501 (no store implements deletion yet).
GET/healthLiveness probe
GET/readyReadiness probe with workspace info
GET/buildsList local build manifests
GET/sessionsList sessions (auth-gated)
GET/sessions/:idInspect a session by id (regardless of agent name)
GET/sessions/:id/eventsSSE stream by session id
POST/sessions/:id/approvals/:approvalId/approve (or /reject)Resolve an approval

Streaming events (SSE)

GET /agents/:name/:id/stream opens a long-lived text/event-stream connection. Each event is a JSON-serialized AgentEvent:

curl -N https://my-app.example.com/agents/triage/issue-42/stream
data: {"id":"ev-1","type":"prompt_start","timestamp":"2026-05-05T22:00:00Z","sessionId":"issue-42","data":{"text":"Triage..."}}

data: {"id":"ev-2","type":"tool_start","timestamp":"2026-05-05T22:00:01Z","sessionId":"issue-42","data":{"toolName":"bash","args":{"command":"gh issue view 42"}}}

data: {"id":"ev-3","type":"text_delta","timestamp":"2026-05-05T22:00:02Z","sessionId":"issue-42","data":{"delta":"Severity: medium..."}}

In a browser:

const events = new EventSource(`/agents/triage/${id}/stream`);
events.onmessage = (msg) => {
  const event: AgentEvent = JSON.parse(msg.data);
  // narrow with isEvent(event, 'text_delta') etc.
};

WebSocket protocol

WS /sessions/:id/ws opens a bidirectional WebSocket. Server pushes session events as JSON; the client can send commands back (cancel a task, approve a request, ping). Use this for interactive UIs that need to send messages, not just receive — for read-only streams, SSE is simpler and sufficient.

The server side requires the optional ws peer dep (pnpm add ws); browsers and Node 22+ use the platform WebSocket natively for the client.

Connecting

import { connectFabricWs } from '@fabric-harness/sdk';

const handle = connectFabricWs({
  url: `ws://localhost:4317/sessions/${id}/ws`,
  authToken: process.env.FH_AUTH_TOKEN,
  tenantId: 'tenant-acme',
  replay: 20,                            // last N events replayed on connect
  onEvent: (event) => console.log(event),
  onReplayEnd: () => console.log('live'),
  onAck: (ack) => console.log('ack', ack),
  onError: (err) => console.error(err),
  onClose: () => console.log('closed'),
});

// Send commands back to the server:
handle.send({ type: 'cancel', taskId: 'task-123', reason: 'user cancelled' });
handle.send({ type: 'approve', approvalId: 'appr-1', decision: 'approved' });
handle.send({ type: 'ping' });

Server messages

typePayload
event{ event: FabricEvent }
replay-endsent once after the initial replay buffer is drained
pong{ ts: number } heartbeat
ack{ for: 'cancel' | 'approve', ok: boolean, message?: string }
error{ message: string }

Client messages

typePayload
cancel{ taskId: string, reason?: string }
approve{ approvalId: string, decision: 'approved' | 'denied', reason?: string }
ping{ ts?: number }

Auth + tenancy

Browsers can't set headers on a WebSocket upgrade, so auth flows through query params: ?token=<bearer>&tenant=<id>. The server validates with the same rules as HTTP (authToken / authTokenEnv options + FABRIC_HARNESS_TENANT_REQUIRED=1). Cross-tenant reads return a single error message and close.

Heartbeats

Server sends a pong every 15s. After 3 missed pings from the client (45s), the server closes the socket. Clients should pong on intervals or send periodic ping messages.

Voice WebSocket

WS /sessions/:id/voice opens a bidirectional voice bridge. Server creates an OpenAI Realtime connection on behalf of the client (provider API keys stay server-side). Same auth + tenant pipeline as the chat WS; same ws peer dep requirement.

Query params customize per-connection: ?token=<bearer>&tenant=<id>&voice=alloy&model=gpt-4o-realtime-preview&audioFormat=pcm16&instructions=<persona>.

Server requires OPENAI_API_KEY in env; missing → error message and immediate close.

Use connectFabricVoice (in @fabric-harness/sdk) for browser/Node clients — see Voice.

Webhook trigger gating

In production mode (FABRIC_ENV=production or NODE_ENV=production, and FABRIC_MODE is not local or dev), the server only exposes agents that declare triggers: { webhook: true }. Other agents return 403 Forbidden to the public route.

Lift the gate locally with one of:

FABRIC_MODE=local fh dev --target node
FABRIC_MODE=dev fh dev --target node
# Or simply not running in production mode

Behavior matrix:

FABRIC_MODEFABRIC_ENV / NODE_ENVPublic route exposes
local or devanyevery registered agent
(unset)productiononly agents with triggers.webhook === true
(unset)not productionevery registered agent

Auth

The dev server (fh dev --target node) accepts an optional bearer token in Authorization: Bearer <token>. Configure with:

import { startDevServer } from '@fabric-harness/node';

await startDevServer({
  authToken: process.env.FABRIC_HARNESS_API_TOKEN,
});

The Cloudflare build target reads the same env var: FABRIC_HARNESS_API_TOKEN. If unset, the route is open.

Rate limiting

The Node server supports a simple in-memory rate limiter:

await startDevServer({
  rateLimit: { windowMs: 60_000, maxRequests: 100 },
});

For multi-replica deploys, terminate rate limiting upstream (Cloudflare, nginx, your load balancer) instead.

Error responses

StatusShapeCause
400{ error: '...' }Bad input (e.g. invalid approval decision)
401{ error: { type: 'unauthorized', ... } }Missing or wrong bearer token
403{ error: { type: 'forbidden', ... } }Agent has no webhook trigger in production mode
404{ error: 'Not found' }Unknown session/agent
413{ error: { type: 'body_too_large', ... } }Request body exceeded maxBodyBytes
429{ error: { type: 'rate_limited', ... } }Rate limiter rejected the request
500{ error: { type: 'internal_error', ... } }Uncaught exception
501{ error: '...' }Endpoint exists but the configured store doesn't support it

See also