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
| Method | Path | Purpose |
|---|---|---|
POST | /jobs/:name | Invoke a finite job with its input payload. Returns { result, runId }; add ?wait=false for a 202 run receipt. |
GET | /runs | List tenant-visible finite runs with ?status=&job=&limit=&cursor=. |
GET | /runs/:runId | Read tenant-bound run status, output, error, timestamps, actor, and parent linkage. |
GET | /runs/:runId/events | Read run events with ?offset=&limit= pagination. |
POST | /runs/:runId/abort | Propagate cancellation to an active run. |
POST | /agents/:name/:id | Durably 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/dispatch | Enqueue { input, session? } for asynchronous delivery. Returns a 202 dispatch receipt. |
GET | /agents/:name/:id | Read the persistent instance's default session state and history. |
GET | /agents/:name/:id/conversation | Read incarnation-fenced conversation records with ?session=&offset=&limit= pagination. |
GET | /agents/:name/:id/stream | Tail conversation records and stream checkpoints with SSE when ?offset= is present; without it, stream legacy session events. |
GET | /agents/:name/:id/submissions/:submissionId | Read queued, running, or settled submission status. |
POST | /agents/:name/:id/abort | Durably request abort for unsettled work in { session? }. |
GET | /agents/:name/:id/attachments/:digest | Download a stored attachment when an attachment store is configured. |
DELETE | /agents/:name/:id | Cascade-delete the tenant-bound instance sessions, submissions, streams, and attachments supported by the configured stores. |
GET | /health | Liveness probe |
GET | /ready | Readiness probe with workspace info |
GET | /builds | List local build manifests |
GET | /builds/:target/manifest | Read a target's normalized build manifest. |
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 |
GET | /approvals | List 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
failedinstead 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
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 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 modeBehavior matrix:
FABRIC_MODE | FABRIC_ENV / NODE_ENV | Public route exposes |
|---|---|---|
local or dev | any | every registered agent |
| (unset) | production | only definitions with triggers.webhook === true |
| (unset) | not production | every 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
| Status | Shape | Cause |
|---|---|---|
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
- Events —
AgentEventtaxonomy used by the SSE stream - Triggers — webhook / schedule / cli trigger declarations
- Persistent agents — authoring and delivery lifecycle
- Build and run artifacts — package this server for deployment
- Deployment → Cloudflare — finite-job Worker surface and current limitations