FabricFabricHarness
Reference

HTTP Server

REST, SSE, and WebSocket conventions for finite jobs and persistent agents on Node-derived targets.

The Node server and Node-derived build targets share this v2 HTTP surface. Finite jobs support synchronous results or asynchronous run admission at /jobs/:name. Persistent agents are addressable instances at /agents/:name/:id and use durable, asynchronous admission by default.

The Cloudflare target exposes finite jobs and core persistent prompt/read/delete routes with a smaller operational route set. See Cloudflare Workers + Sandbox.

Node applications can mount authenticated Fetch-standard routes and middleware without replacing this server. See HTTP applications.

Routes

MethodPathPurpose
POST/jobs/:nameInvoke a finite job with its input payload. Returns { result, runId }; add ?wait=false for a 202 run receipt.
GET/runsList tenant-visible finite runs with ?status=&job=&limit=&cursor=.
GET/runs/:runIdRead tenant-bound run status, output, error, timestamps, actor, and parent linkage.
GET/runs/:runId/eventsRead run events with ?offset=&limit= pagination.
POST/runs/:runId/abortPropagate cancellation to an active run.
POST/agents/:name/:idDurably admit { message, session? } to a persistent instance. Returns 202 { submissionId, streamUrl, conversationUrl, updatesUrl, offset }. Add ?wait=true for a synchronous bridge.
POST/agents/:name/:id/dispatchEnqueue { input, session? } for asynchronous delivery. Returns a 202 dispatch receipt.
GET/agents/:name/:idRead the persistent instance's default session state and history.
GET/agents/:name/:id/conversationRead incarnation-fenced conversation records with ?session=&offset=&limit= pagination.
GET/agents/:name/:id/streamTail conversation records and stream checkpoints with SSE when ?offset= is present; without it, stream legacy session events.
GET/agents/:name/:id/submissions/:submissionIdRead queued, running, or settled submission status.
POST/agents/:name/:id/abortDurably request abort for unsettled work in { session? }.
GET/agents/:name/:id/attachments/:digestDownload a stored attachment when an attachment store is configured.
DELETE/agents/:name/:idCascade-delete the tenant-bound instance sessions, submissions, streams, and attachments supported by the configured stores.
GET/healthLiveness probe
GET/readyReadiness probe with workspace info
GET/buildsList local build manifests
GET/builds/:target/manifestRead a target's normalized build manifest.
GET/sessionsList sessions (auth-gated)
GET/sessions/:idInspect a session by id (regardless of agent name)
GET/sessions/:id/eventsSSE stream by session id
GET/approvalsList approvals across sessions visible to the authenticated tenant. Requires approval:read.
POST/sessions/:id/approvals/:approvalId/approve (or /reject)Resolve an approval.

Invoke a finite job

curl -sS http://localhost:4317/jobs/summarize \
  -H 'content-type: application/json' \
  -d '{"text":"Durable agents need durable state."}'

Each call receives a new runId. Finite jobs exist only under /jobs; /agents/:name/:id addresses persistent agents.

For long-running jobs, admit the run and reconnect through the typed client:

const receipt = await client.jobs.invoke(
  'summarize',
  { text: 'Durable agents need durable state.' },
  { wait: false, idempotencyKey: 'summary:document-42' },
);

const run = await client.runs.get(receipt.runId);
const recent = await client.runs.list({ job: 'summarize', status: 'running', limit: 20 });
const events = await client.runs.events(receipt.runId, { offset: '0' });
await client.runs.abort(receipt.runId);

runs.list() is reverse chronological and cursor-based. Node reads the configured shared session store and hydrates each projected run from the durable submission lifecycle before applying status filters. A run visible as completed through runs.get() is therefore immediately visible to runs.list({ status: 'completed' }), even while the terminal projection write is still finishing. Cloudflare reads its tenant-filtered pointer registry while direct run reads continue to use the authoritative per-run Durable Object.

Idempotency-Key scopes duplicate admission by tenant, authenticated principal, and job. Replaying the same key and payload returns the original run; reusing the key with a different payload returns 409 Conflict. This prevents one principal from observing or taking over another principal's run.

X-Fabric-Parent-Run links nested work initiated by an application or worker. The referenced run must exist and belong to the selected tenant; otherwise admission returns 400 Bad Request. Run reads return 404 across tenant boundaries.

Durable finite-run behavior

Asynchronous finite jobs use the same durable submission store and lease coordinator as persistent agent work. This gives /runs consistent behavior across process restarts and multiple replicas:

  • an idempotent run interrupted by a process crash can be claimed and executed again;
  • a non-idempotent run interrupted after execution began settles as failed instead of silently repeating side effects;
  • an abort request is persisted, reaches an active session.prompt() through its abort signal, and reports the terminal outcome after settlement;
  • scheduled occurrences use deterministic idempotency keys and appear through the same run status and event routes;
  • client.runs.observe() drains the terminal event tail before returning.

Use a shared submission store in multi-replica production deployments. In-memory storage provides the same API contract but cannot preserve work across a full process restart.

Discover tenant approvals

GET /approvals?status=pending&offset=0&limit=50 is the non-admin discovery surface for deployed applications. It returns only approval states from sessions visible to the authenticated tenant and requires approval:read; it does not grant access to /admin or other operator APIs. Use the session and approval ids in each item with the existing approve, reject, or vote route.

Work with a persistent agent

Prefer the typed client for admission, settlement polling, history, live observation, and aborts:

import { createFabricClient } from '@fabric-harness/client';

const client = createFabricClient({
  baseUrl: 'http://localhost:4317',
  headers: { authorization: `Bearer ${process.env.FABRIC_HARNESS_API_TOKEN}` },
});

const support = client.agent({
  agent: 'support',
  id: 'account-42',
  session: 'billing',
});
const admitted = await support.send('Where is invoice 1007?');

// This can run in a later process with only the durable submission id.
const reply = await support.read(admitted.submissionId, { timeoutMs: 30_000 });
console.log(reply.text);

client.agents.prompt(...) adds ?wait=true and waits on the HTTP request. Use it for short compatibility flows, not as the default for durable or long-running work.

Streaming events (SSE)

For persistent agents, client.agents.updates() is the preferred stream. It catches up durable records from the requested offset, interleaves transient token and reasoning deltas, reconnects with the latest durable offset, and deduplicates replayed delta event ids. In live: 'auto' mode it falls back to offset polling when a deployment does not expose SSE.

Start at the offset returned by admission to catch up and then tail without a replay gap:

curl -N 'https://my-app.example.com/agents/support/account-42/stream?session=billing&offset=0'
data: {"type":"conversation","offset":"1","records":[{"kind":"entry","entry":{"type":"user_prompt","data":{"text":"Where is invoice 1007?"}}}]}

data: {"type":"delta","eventId":"event-17","submissionId":"submission-1","kind":"text","delta":"I found "}

data: {"type":"conversation","offset":"4","records":[{"kind":"entry","entry":{"type":"assistant_message","data":{"text":"I found invoice 1007."}}}]}

With the typed client:

for await (const update of client.agents.updates(
  { agent: 'support', id, session: 'billing' },
  { offset, live: 'auto' },
)) {
  if (update.type === 'delta') process.stdout.write(update.delta);
  else console.log(update.records);
}

streamUrl remains the legacy JSON conversation URL. New clients use conversationUrl for durable pages and updatesUrl for SSE when the receipt supplies them.

For SDK session events rather than conversation records and deltas, omit offset or use GET /sessions/:id/events. Those events use the AgentEvent taxonomy.

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 each connection: ?token=<bearer>&tenant=<id>&voice=alloy&model=gpt-realtime&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 jobs and persistent agents that declare triggers: { webhook: true }. Other definitions return 403 Forbidden to public invocation routes.

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 definitions with triggers.webhook === true
(unset)not productionevery registered definition

Auth

The dev server (fh dev --target node) accepts a bearer token in Authorization: Bearer <token>. It is optional in local/dev mode. In production, requests other than /health and /ready fail closed with 401 unless a bearer token or custom extractAuthToken authorizer accepts them:

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

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

Enterprise hosts can instead return a tenant-bound principal and scoped permissions. The same principal becomes the session actor, and the same authorization path protects HTTP and WebSocket requests:

await startDevServer({
  authenticate: async (req) => ({
    id: await subjectFromVerifiedRequest(req),
    kind: 'user',
    tenantId: 'acme',
    permissions: ['agent:invoke', 'session:read', 'approval:read'],
  }),
  authorize: ({ principal, permission }) =>
    permission !== 'admin:read' || principal.roles?.includes('operator') === true,
});

See Authentication and RBAC for the permission table, multi-tenant selection, and persistent-instance deletion behavior.

Node-derived build targets read the same env var: FABRIC_HARNESS_API_TOKEN. In production, leaving it unset does not open the server; protected routes return 401.

Rate limiting

Production mode enables fixed-window limits automatically. The defaults are 120 requests per minute, with separate budgets for prompts (30), streams and WebSockets (20), channel delivery (120), MCP (60), admin operations (60), and reads (300). Local development remains unlimited unless configured.

Override the base or individual route classes with rateLimit:

await startDevServer({
  rateLimit: {
    windowMs: 60_000,
    maxRequests: 100,
    routes: {
      prompt: { maxRequests: 20 },
      admin: { maxRequests: 10 },
    },
  },
});

The built-in store is process-local. Multi-replica deployments can use the Redis adapter without adding a Redis SDK dependency to Fabric Harness:

import { redisHttpRateLimiter, startDevServer } from '@fabric-harness/node';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

await startDevServer({
  httpRateLimiter: redisHttpRateLimiter({
    client: {
      eval: (script, keys, args) =>
        redis.eval(script, keys.length, ...keys, ...args) as Promise<unknown>,
    },
  }),
});

Forwarded IP headers are ignored by default. List the direct ingress addresses when a trusted proxy terminates client connections:

await startDevServer({
  trustedProxies: ['10.0.4.12', '10.0.4.13'],
});

trustedProxies: true trusts any direct peer and is only appropriate when network policy prevents clients from reaching the server without passing through your ingress.

Error responses

StatusShapeCause
400{ error: '...' }Bad input (e.g. invalid approval decision)
202{ submissionId, streamUrl, offset }Persistent input was durably admitted and continues asynchronously.
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