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
| Method | Path | Purpose |
|---|---|---|
POST | /agents/:name/:id | Invoke the agent for session :id. Body is the agent's input payload. Returns { result, sessionId, agentPath }. |
GET | /agents/:name/:id | Read session state and history. Returns the full session timeline (entries, events, artifacts, approvals). |
GET | /agents/:name/:id/stream | Subscribe to a server-sent event stream of typed AgentEvent values for this session. |
DELETE | /agents/:name/:id | Reserved — currently returns 501 (no store implements deletion yet). |
GET | /health | Liveness probe |
GET | /ready | Readiness probe with workspace info |
GET | /builds | List local build manifests |
GET | /sessions | List sessions (auth-gated) |
GET | /sessions/:id | Inspect a session by id (regardless of agent name) |
GET | /sessions/:id/events | SSE 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/streamdata: {"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
type | Payload |
|---|---|
event | { event: FabricEvent } |
replay-end | sent 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
type | Payload |
|---|---|
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 modeBehavior matrix:
FABRIC_MODE | FABRIC_ENV / NODE_ENV | Public route exposes |
|---|---|---|
local or dev | any | every registered agent |
| (unset) | production | only agents with triggers.webhook === true |
| (unset) | not production | every 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
| Status | Shape | Cause |
|---|---|---|
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
- Events —
AgentEventtaxonomy used by the SSE stream - Triggers — webhook / schedule / cli trigger declarations
- Deployment → Cloudflare — same routes on the Worker target