Dynamic Agents and Hooks
Build persistent agents whose instructions, tools, model, integrations, and environment evolve from durable state.
Dynamic agents use the existing createAgent() persistent-agent builder. Hooks compose capabilities
inside its synchronous render function; no additional builder or runtime mode is required. The host
renders the function before every delivered interaction using the instance's durable state snapshot.
import {
createAgent,
schema,
useModel,
usePersistentState,
useSandbox,
useSkill,
useTool,
} from '@fabric-harness/sdk';
import { reviewChecklist } from '../skills/review';
import { advancedAnalysis, searchIssues } from '../tools/github';
function TriageAgent() {
const [phase] = usePersistentState('phase', 'triage', {
schema: schema.enum(['triage', 'implementation']),
});
useModel(phase === 'triage' ? 'anthropic/claude-haiku-4-5' : 'anthropic/claude-sonnet-4-6');
useSandbox(phase === 'triage' ? 'virtual' : 'docker', { cwd: '/workspace' });
useSkill(reviewChecklist);
useTool(searchIssues);
if (phase === 'implementation') useTool(advancedAnalysis);
return `Complete the ${phase} phase, use the mounted evidence,
and do not skip verification.`;
}
export default createAgent(TriageAgent, {
durability: { maxAttempts: 5, timeoutMs: 2 * 60 * 60_000 },
});The named function is the agent's capability render and its return value is the current instruction.
State changes become visible on the next render, so the model, sandbox, and conditional tool switch at
an interaction boundary. Resource additions, removals, and definition changes are recorded and narrated
to the model. Native-loop hosts resolve the provider that owns the newly selected model; a custom host
can supply modelProviderResolver when routing cannot be derived from the standard provider registry.
Declare retry and wall-clock durability outside the render so admission and recovery can enforce it even when the agent cannot render:
export default createAgent(renderAssistant, {
durability: { maxAttempts: 5, timeoutMs: 2 * 60 * 60_000 },
});Static definition fields—including policy, durability, triggers, and initial-data validation—remain
in force across every dynamic re-render. Hook output may change per turn, but a refresh cannot silently
remove the agent's static authorization boundary.
The render model
Think of the createAgent() function as a pure capability render:
- Fabric loads the addressed instance and its durable state.
- The function declares the model, instructions, resources, and lifecycle callbacks for this turn.
- Fabric runs the model and tools under the normal policy, identity, budget, and cancellation limits.
- Successful state writes are persisted and affect the next render.
Hooks must be called synchronously and in composition order. Network calls and other asynchronous work belong in tools, lifecycle callbacks, or lazy MCP connectors. State cannot be written during render.
Custom hooks are ordinary functions, so related capabilities can live together:
function useResearchMode(enabled: boolean) {
if (!enabled) return;
useInstruction('Investigate competing explanations before deciding.');
useTool(searchSources);
useSkill(researchSkill);
}
export default createAgent(() => {
const [researchEnabled] = usePersistentState('researchEnabled', false);
useResearchMode(researchEnabled);
return 'Resolve the request and explain the evidence.';
});Complete hook surface
| Hook | Purpose |
|---|---|
useInstruction() | Compose instruction fragments in call order. |
useModel() | Select the model, thinking level, and compaction policy for this render. |
useSandbox() | Select a portable sandbox and working directory. |
useTool() | Mount a typed tool, including conditional and durable tools. |
useSkill() | Mount Markdown-first expertise without granting authority. |
useSubagent() | Add a named specialist for delegated session tasks. |
useMcpConnection() | Lazily connect an MCP tool source for this interaction. |
usePersistentState() | Read a durable snapshot and write from tools or lifecycle callbacks. |
useDelivery() | Read the normalized user or signal delivery that caused this render. |
useInitialData() | Read immutable JSON data captured on first contact. |
useDispatchMessage() | Enqueue another real delivery for the same addressed instance. |
useDataWriter() | Write a named, model-invisible client data part. |
useResponseStart() | Attach synchronous metadata before model work begins. |
useAgentStart() | Run awaited intake work after input is durable. |
useAgentFinish() | Inspect the result and optionally continue the same response. |
useResponseFinish() | Attach final synchronous response metadata. |
defineMcpConnection() and defineSubagent() create reusable declarations.
GeneralSubagent supplies a general-purpose delegate when a custom specialist is unnecessary.
Models, instructions, skills, and sandboxes
Every render can select a different model or environment. A persistent state transition is a useful way to trade up only when a task earns the additional cost or isolation:
export default createAgent(() => {
const [phase] = usePersistentState('phase', 'triage', {
schema: schema.enum(['triage', 'implementation']),
});
useModel(
phase === 'triage' ? 'anthropic/claude-haiku-4-5' : 'anthropic/claude-sonnet-4-6',
{ thinkingLevel: phase === 'triage' ? 'low' : 'high' },
);
useSandbox(phase === 'triage' ? 'virtual' : 'local', { cwd: '/workspace' });
useInstruction(`Current workflow phase: ${phase}.`);
useSkill({ name: 'release', content: releaseInstructions });
return 'Complete the current phase without skipping its verification gate.';
});Capability policy still applies after composition. A skill adds context; it does not widen filesystem, shell, network, credential, connector, or tool permissions.
Durable state and tools
State setters work inside tracked tools and lifecycle callbacks. A successful tool commits its state writes with the tool result. Failed tools do not publish a partial state transition.
Mark a tool durable: true to receive a named step journal:
const [phase, setPhase] = usePersistentState('phase', 'draft', {
schema: schema.enum(['draft', 'published']),
});
useTool({
name: 'publish_report',
input: schema.object({ reportId: schema.string() }),
durable: true,
async run({ data, step, log }) {
const receipt = await step.do(`publish:${data.reportId}`, () =>
publisher.publish(data.reportId, { idempotencyKey: data.reportId }),
);
log.info('Report published', { reportId: data.reportId });
setPhase('published');
return { output: receipt };
},
});If a worker stops after one step, recovery re-enters the logical tool call, replays completed steps, and executes unfinished ones. External operations still need a stable idempotency key because a process can stop after the external effect but before its checkpoint lands.
On restart, resource-change narration may be recorded before recovery finishes. Harness keeps that
narration out of the provider transcript until the recovered tool result is paired with its original
tool call. If reauthorization or execution fails, the correlated failure is projected as the required
tool result, so Anthropic-compatible and other strict providers never receive an orphaned tool use.
The recovered call/result pair belongs to the resumed response and is visible to useAgentFinish(),
preventing required-tool guards from scheduling the same external operation again.
Lifecycle metadata, message data, and lifecycle markers retain their submission and attempt identity
even when the generated host bundles an agent definition separately from the submission runner.
Set harness: true on a hook-authored tool only when it needs the runtime-scoped sandbox:
useTool({
name: 'inspect_workspace',
harness: true,
async run({ harness }) {
return { output: await harness.sandbox.readdir('.') };
},
});Delivery, initial data, and dispatch
useDelivery() exposes the normalized event that triggered the render. useInitialData() exposes the
first JSON seed admitted for the instance and never replaces it with later values.
interface AccountSeed {
accountId: string;
plan: 'standard' | 'enterprise';
}
export default createAgent(() => {
const delivery = useDelivery();
const account = useInitialData<AccountSeed>();
const dispatch = useDispatchMessage();
useAgentStart(async () => {
if (delivery.kind === 'user' && account.plan === 'enterprise') {
await dispatch({
kind: 'signal',
type: 'account.priority',
body: `Prioritize account ${account.accountId}.`,
});
}
});
return `Handle this ${delivery.kind} delivery for ${account.accountId}.`;
}, {
initialData: schema.object({
accountId: schema.string(),
plan: schema.enum(['standard', 'enterprise']),
}),
});Signal deliveries remain typed canonical conversation records with their submission, tenant, and actor correlation intact. They render into model context once using their signal tag, but do not become visible user prompts; the conversation projection classifies them as diagnostic dispatch messages. Durable replay recognizes the correlated signal by submission ID and never applies it a second time. The runtime rejects any contradictory typed delivery and rendered prompt before either is persisted. User deliveries continue to persist as visible user prompts.
Self-dispatch is bound to the host's durable submission queue for each render. This scoped binding survives packaged agents that bundle their own SDK copy and process restarts; Node, Databricks Apps, and Cloudflare Durable Objects inject it automatically, while Temporal activities accept the durable queue explicitly. None depend on process-global queue state inside the agent module.
Seed the first interaction over HTTP:
curl -X POST 'http://localhost:4317/agents/assistant/account-7?wait=true' \
-H 'content-type: application/json' \
-d '{"message":"Begin","initialData":{"accountId":"account-7","plan":"enterprise"}}'Or pass the same option through @fabric-harness/client or @fabric-harness/react:
await client.agent({ agent: 'assistant', id: 'account-7' }).send('Begin', {
initialData: { accountId: 'account-7', plan: 'enterprise' },
});Do not put credentials in initial data, persistent state, tool results, metadata, or data parts. These surfaces are serializable and may be retained, projected to clients, or replayed.
Response metadata, client data, and lifecycle
The response lifecycle runs in this order:
useResponseStart → useAgentStart → model and tools → useAgentFinish → useResponseFinishAll useAgentStart() declarations run concurrently. Each receives the interaction signal, a scoped
harness, a redacted progress log, and append(). Fabric commits their state writes and signal
appends together after every callback settles, flattening appends in declaration order. Put dependent
work in one callback rather than depending on callback scheduling. useAgentFinish() declarations run
sequentially at the would-stop boundary.
useDataWriter() produces typed client-facing data without adding it to model context. Its names are
part of the message shape and must be declared unconditionally on every render.
const writeProgress = useDataWriter('progress', {
schema: schema.object({ completed: schema.number(), total: schema.number() }),
});
useResponseStart(() => ({ startedAt: Date.now() }));
useTool({
name: 'complete_item',
run: () => {
writeProgress({ completed: 3, total: 8 });
return 'recorded';
},
});
useResponseFinish(({ metadata, response }) => ({
startedAt: metadata.startedAt,
toolCalls: response.toolCalls.length,
finishedAt: Date.now(),
}));Use useAgentFinish() for an invariant that must hold before a response settles:
useAgentFinish(({ response, append }) => {
const verified = response.toolCalls.some(
(call) => call.tool === 'verify_release' && !call.error,
);
if (!verified) {
append({
kind: 'signal',
type: 'release.verification-required',
body: 'Run verify_release before finishing.',
});
}
});The signal continues the same response rather than creating a new user delivery. Finish continuations are capped at eight cycles and remain subject to model-turn, tool-call, token, cost, cancellation, and wall-clock limits.
MCP connections and Databricks
MCP declarations are lazy. Fabric connects only when the current render mounts the declaration, applies the optional remote-tool allowlist, and closes the connection after the interaction.
const documentation = defineMcpConnection({
name: 'documentation',
url: 'https://docs.example.com/mcp',
auth: () => process.env.DOCS_MCP_TOKEN,
tools: ['search', 'read'],
});
export default createAgent(() => {
const [researchEnabled] = usePersistentState('researchEnabled', false);
if (researchEnabled) useMcpConnection(documentation);
return 'Use approved documentation sources when research mode is active.';
});Databricks managed MCP uses the same hook through the existing governed connector:
import { connectDatabricksManagedMcpServer } from '@fabric-harness/databricks';
import { createAgent, defineMcpConnection, useMcpConnection } from '@fabric-harness/sdk';
const catalog = defineMcpConnection({
name: 'catalog',
connect: () => connectDatabricksManagedMcpServer({
host: process.env.DATABRICKS_HOST!,
tokenProvider: async () => process.env.DATABRICKS_TOKEN,
server: {
name: 'catalog',
endpoint: { kind: 'functions', catalog: 'main', schema: 'agent_tools' },
defaultEffect: 'execute',
},
}),
});
export default createAgent(() => {
useMcpConnection(catalog);
return 'Use governed Unity Catalog functions when required.';
});For Databricks Apps, prefer an OBO or least-privilege M2M token provider resolved at request time. Never persist the workspace credential. See Databricks integrations for effect classification, grants, and connection cleanup.
Subagents
Declare specialists once and mount them conditionally:
const reviewer = defineSubagent({
name: 'reviewer',
description: 'Reviews release evidence before publication.',
model: 'anthropic/claude-sonnet-4-6',
thinkingLevel: 'high',
agent: () => {
useInstruction('Reject unsupported claims and list missing evidence.');
useSkill(reviewChecklist);
return 'Review the proposed release independently.';
},
});
export default createAgent(() => {
const [readyForReview] = usePersistentState('readyForReview', false);
if (readyForReview) useSubagent(reviewer);
return 'Prepare a release and delegate review when the evidence is ready.';
});Subagents share the parent environment and cannot own persistent state, sandbox selection, public response metadata, or root lifecycle hooks. Use session tasks to invoke a named specialist under the normal task-depth, cancellation, checkpoint, and policy limits.
Addressable Node and Databricks App runtimes expose the same path without inventing a model-callable delegation tool:
curl -X POST 'http://localhost:4317/agents/assistant/demo/tasks' \
-H 'content-type: application/json' \
-d '{"task":"Review the release evidence","agent":"reviewer","session":"release","timeoutMs":120000,"maxIterations":4}'The endpoint invokes session.task() against the freshly rendered hook configuration. It is
tenant-checked, checkpoints before and after work, caps timeouts at five minutes and iterations at
16, propagates disconnect cancellation, and rejects unknown subagent names.
Integrations
Dynamic composition works with the existing Fabric ecosystem rather than creating hook-specific adapters:
| Integration | Dynamic-agent pattern | Guide |
|---|---|---|
| Slack, Teams, GitHub, Discord, and 13 more channels | Read normalized channel input with useDelivery(); mount governed outbound tools with useTool(). | Channels |
| Postgres, MySQL, MongoDB, Redis, SQLite, libSQL, Turso, Supabase, Valkey, and Lakebase | Construct the adapter outside render, then mount its tools conditionally. | Databases |
| E2B, Daytona, Modal, Vercel, Cloudflare, Databricks SQL, and other sandboxes | Select a backend with useSandbox() while retaining capability discovery and policy. | Sandboxes |
| OpenTelemetry, Braintrust, Sentry, Jetty, and evaluations | Keep telemetry configured at the runtime boundary; lifecycle metadata and data parts enrich the trace. | Tooling |
| Remote MCP servers | Mount lazy, authenticated, allowlisted connections with useMcpConnection(). | MCP |
| Databricks data, AI, Jobs, Genie, AI Search, Unity Catalog, Lakebase, and MLflow | Compose existing governed tools and managed MCP while preserving OBO or M2M identity. | Databricks |
Use fh add <recipe> to install managed integration wiring. Recipes remain ordinary public Fabric
tools, channels, stores, and sandboxes, so the same integration can be used by finite defineAgent()
jobs and dynamic createAgent() instances.
Run locally
Create the agent file under .fabricharness/agents/, then start the development server:
pnpm add @fabric-harness/sdk @fabric-harness/node @fabric-harness/cli
pnpm exec fh devSend an interaction to the file name and a stable instance ID:
curl -X POST 'http://localhost:4317/agents/assistant/demo?wait=true' \
-H 'content-type: application/json' \
-d '{"message":"Start the work"}'The complete source example is in examples/dynamic-agent in the source distribution.
Build and publish an agent
Put public trigger metadata in the optional static createAgent() argument. Hosts inspect this
metadata without rendering interaction-dependent hooks:
export default createAgent(Assistant, {
description: 'Handles durable support work.',
triggers: { webhook: true },
});Choose a deployment target without changing the agent definition.
Node or Docker
pnpm exec fh build --target node
NODE_ENV=production \
FABRIC_HARNESS_API_TOKEN="$FABRIC_HARNESS_API_TOKEN" \
node .fabricharness/build/node/dist/server.mjsShip the complete .fabricharness/build/node directory. See Node deployment
or build an OCI image with the Docker target.
Cloudflare
pnpm exec fh build --target cloudflare
cd .fabricharness/build/cloudflare
pnpm install
pnpm exec wrangler deployPersistent instances are serialized and stored in Durable Objects. Configure production bindings and run the credentialed smoke described in Cloudflare deployment.
Databricks Apps
pnpm exec fh build --target databricks-app
cd .fabricharness/build/databricks-app
databricks bundle validate
databricks bundle deploy
databricks bundle run <app-resource-key>The generated App uses the shared Node server, Databricks App identity, and the same dynamic render
contract. Bind model endpoints, warehouses, Lakebase, MCP Services, and Unity Catalog resources through
App resources or bundle variables—not committed identifiers or tokens. Follow the complete
Databricks App tutorial, including fh doctor, OBO/M2M selection,
restart testing, and cleanup.
Temporal
pnpm exec fh build --target temporal-worker
node .fabricharness/build/temporal-worker/dist/worker.mjsThe generated worker bundles the workspace's persistent-agent definitions. A dynamic prompt carries only a JSON-safe descriptor (agent name, instance, delivery, actor/tenant correlation, and resource fingerprint) through workflow history. The activity resolves that bundled definition, verifies it matches the addressed persistent session, and re-renders hooks at the trusted worker boundary. It also recomputes the model/tool/MCP resource fingerprint and rejects any mismatch before initializing the agent, so a different worker deployment cannot silently execute a changed resource set. Completed activity results are durably memoized so an activity retry does not repeat a completed model response.
For a hand-composed worker, provide resolveDynamicAgent to createLocalTemporalActivities() and set
dynamicAgents: true on temporalSessionRuntime() only after the matching task queue is deployed.
Without both sides, Harness fails closed. Hook functions, resolved MCP credentials, tool
implementations, and secret values must never enter workflow input.
For a production target, use a durable store, enable authentication and tenant isolation, verify the target's capability matrix, exercise cancellation and restart recovery, and retain the generated manifest as release evidence.
Limits and compatibility
- Dynamic hooks are supported by persistent
createAgent()definitions. FinitedefineAgent()jobs keep their explicit bounded run lifecycle. - Named state is durable and may be declared conditionally. Named
useDataWriter()identities must be declared on every render. - Root lifecycle, dispatch, persistent state, sandbox, MCP, and response-output hooks are unavailable inside a subagent render.
- An async legacy initializer is accepted, but hooks after its first
awaitfail because render has ended. Keep hooks synchronous. - Dynamic agents inherit the same policy, secret handling, identity isolation, retry, timeout, cancellation, store, and deployment contracts as other persistent agents.
- Dynamic Temporal execution uses the coarse prompt activity so re-rendering and all nondeterministic I/O stay outside workflow code. A worker that does not advertise and implement the dynamic-agent resolver is rejected before delegation.
See the runnable dynamic-agent example, persistent-agent lifecycle, and generated API reference for the complete types.