Persistent Agents & Dispatch
Long-lived, addressable agent instances with cross-call sessions, async dispatch, and a streaming WebSocket conversation.
Fabric Harness has two authoring surfaces. A job is a finite, run-once execution; a persistent agent is a long-lived, URL-addressable instance whose sessions continue across calls. (Fabric names the finite surface job because workflow is reserved for Temporal durable execution.)
| Finite job | Persistent agent | |
|---|---|---|
| Author with | defineAgent({ run }) | createAgent(AgentFunction, staticConfig?) |
| Directory | .fabricharness/jobs/ | .fabricharness/agents/ |
| Invoke | POST /jobs/:name → { result, runId } | POST /agents/:name/:id with { message, session? } |
| Lifetime | one run, returns a result | long-lived instance; sessions persist across calls |
| Async / streaming | tracked under /runs | dispatch() + GET /agents/:name/:id WebSocket |
The directory and route split is enforced. Finite definitions live in .fabricharness/jobs/ and
use POST /jobs/:name; persistent definitions live in .fabricharness/agents/ and use
POST /agents/:name/:id.
Defining a persistent agent
A persistent agent module default-exports createAgent(...). Prefer a named synchronous agent
function: hooks compose its current capabilities and its return value becomes the instruction.
import { createAgent, useModel, useSandbox } from '@fabric-harness/sdk';
function SupportAgent({ id }: { id: string }) {
useModel('anthropic/claude-sonnet-4-6');
useSandbox('virtual');
return `Resolve the support request for conversation ${id}.
Use the conversation history and state what the user should do next.`;
}
export default createAgent(SupportAgent, {
durability: { maxAttempts: 5, timeoutMs: 2 * 60 * 60_000 },
triggers: { webhook: true },
});The function receives the addressed instance id and platform env, then renders again for each
interaction. useModel(), useSandbox(), useTool(), useSkill(), useSubagent(), MCP, lifecycle,
and persistent-state hooks compose the live harness.
Declare policy, durability, triggers, and initial-data validation in the second, host-readable
argument. Those fields are validated at registration and remain enforceable even when rendering
crashes. The lower-level initializer form that returns a PersistentAgentConfig remains supported
for programmatic adapters, but hook-composed functions are the recommended authoring surface.
See Dynamic Agents and Hooks for state-driven capability changes.
Persistent agents may declare triggers.webhook or triggers.manual. They do not accept
triggers.schedule, because a cron expression does not specify which instance, session, and
message should be invoked. Use a scheduled finite job that calls dispatch() with those values;
see Triggers and Public Route Gating.
Direct prompts
POST /agents/:name/:id durably admits a message for the instance's named session and returns 202 { submissionId, streamUrl, offset }. Processing is FIFO per session and continues off the request. Add ?wait=true only when a synchronous compatibility response is required:
# Same instance "u1" → one continuing conversation
curl -XPOST localhost:4317/agents/assistant/u1 -d '{"message":"remember my name is Ada"}'
curl -XPOST localhost:4317/agents/assistant/u1 -d '{"message":"what is my name?"}'Read settlement at /agents/:name/:id/submissions/:submissionId, catch up through
/conversation?offset=, or tail /stream?offset=. Stream checkpoints carry an incarnation; a
changed incarnation resets stale offsets after deletion/recreation. Concurrent messages to one
instance session queue FIFO instead of returning 409.
What operators see
The session address, tenant, entry count, queue state, and permitted recovery actions remain visible together. The screenshot uses deterministic, sanitized fixture data; a real console derives every row from the authenticated principal's scope.

For conditional admission, uid: null means create only, a string means continue exactly that
incarnation, and omission is unconditional. A string uid cannot carry initialData because an
existing-incarnation condition forbids creation.
Async dispatch
dispatch() hands an input to an instance for asynchronous processing, returning a receipt immediately:
import { dispatch } from '@fabric-harness/sdk';
const receipt = await dispatch({ agent: 'assistant', id: 'u1', input: 'summarize today' });
// { dispatchId, acceptedAt }Over HTTP, POST /agents/:name/:id/dispatch returns 202 + the receipt:
curl -XPOST localhost:4317/agents/assistant/u1/dispatch -d '{"input":"summarize today"}'Dispatch processing is idempotent by dispatchId (a re-delivered dispatch is applied at most once). The fh dev server uses an in-process queue; with runtime: 'temporal', durable delivery uses temporalDispatchQueue — dispatches survive worker/process restarts.
Streaming conversation (WebSocket)
GET /agents/:name/:id upgrades to a conversational WebSocket:
const ws = new WebSocket('ws://localhost:4317/agents/assistant/u1');
// server → { type: 'ready', target: 'agent', name, instanceId }
ws.send(JSON.stringify({ type: 'prompt', requestId: 'r1', message: 'hello' }));
// server → { type: 'started', requestId }
// → { type: 'event', requestId, event } (streamed, repeated)
// → { type: 'result', requestId, result, session }Send { type: 'ping' } for a pong heartbeat. Prompts on one connection are serialized.
Jobs and persistent agents
Use a finite job for one typed invocation and a persistent agent for an addressable conversation. Both share the same session, tool, policy, sandbox, and deployment surfaces.