FabricFabricHarness
Building Agents

Agent Anatomy

The metadata-first shape of a Fabric Harness agent — default and strict variants of the same call.

Fabric Harness finite agents are metadata-first. The canonical default export is defineAgent({...}), which provides lazy prompt, skill, task, shell, and session helpers. The runtime admits these finite definitions as jobs/runs, so they live under .fabricharness/jobs/ and use /jobs routes. Persistent, addressable agents use createAgent() under .fabricharness/agents/.

  • Defaultimport { defineAgent } from '@fabric-harness/sdk'. Injects headless defaults (runtime: 'stateless', sandbox: 'virtual', loopRuntime: pi-agent-core, compaction: { enabled: true }) on every init() call. The fast path for prototypes, webhooks, edge agents.
  • Strictimport { defineAgent } from '@fabric-harness/sdk/strict'. Same call shape, no defaults injected. Required for Temporal-backed durability (replay determinism) and recommended for compliance/audit workloads.

Both produce the same AgentDefinition and run identically through fh run, fh build, fh describe, and any deploy target.

import { defineAgent, schema } from '@fabric-harness/sdk';

export default defineAgent({
  name: 'ask',
  input: schema.object({ question: schema.string() }),
  output: schema.string(),
  async run({ input, prompt }) {
    return prompt(input.question);
  },
});

Invoke another finite job

Use the job context's invoke() for child work. The configured runtime admits a new run without an HTTP round trip and propagates the parent run, tenant, and actor. Cycles and nesting beyond 16 jobs are rejected before admission.

.fabricharness/jobs/account-review.ts
import { defineAgent, schema } from '@fabric-harness/sdk';

export default defineAgent({
  input: schema.object({ accountId: schema.string() }),
  run: async ({ input, invoke }) => {
    const receipt = await invoke({
      job: 'collect-account-evidence',
      input: { accountId: input.accountId },
      idempotencyKey: `evidence:${input.accountId}`,
    });
    return { evidenceRunId: receipt.runId };
  },
});

When a definition has a declared or loader-registered name, invoke(definition, { input }) is also available. The top-level invoke() export remains available in application routes, channels, and schedules. Use the named form across module boundaries to avoid importing executable job modules.

Middleware and run identity

Job middleware wraps run in declaration order. It receives the same typed context and is suitable for tracing, shared authorization, metrics, and transaction boundaries. context.run contains the stable run ID, job name, parent chain, tenant, and actor when the job was admitted through a server; it is absent for a direct handler call in a unit test.

import { defineAgent, schema } from '@fabric-harness/sdk';

export default defineAgent({
  name: 'account-review',
  input: schema.object({ accountId: schema.string() }),
  middleware: [
    async ({ run }, next) => {
      console.log({ event: 'started', runId: run?.runId });
      const output = await next();
      console.log({ event: 'completed', runId: run?.runId });
      return output;
    },
  ],
  async run({ input, prompt }) {
    return prompt(`Review account ${input.accountId}`);
  },
});

Each middleware may call next() once. It may also short-circuit by returning a typed output.

The runtime and default session are initialized on the first helper call. Add init: { sandbox: 'local' } to set initialization options, or call session('review') when you need a named session.

Lower-level job definition

import { defineAgent, schema } from '@fabric-harness/sdk';

export default defineAgent({
  name: 'ask',
  input: schema.object({ question: schema.string() }),
  output: schema.string(),
  triggers: { webhook: true },
  run: async ({ init, input }) => {
    const session = await (await init()).session();
    return session.prompt(input.question);
  },
});

init() defaults are injected automatically. Override any of them by passing a value: init({ runtime: 'inline', sandbox: 'docker' }).

import { defineAgent, schema } from '@fabric-harness/sdk/strict';

export default defineAgent({
  name: 'ask',
  input: schema.object({ question: schema.string() }),
  output: schema.string(),
  triggers: { webhook: true },
  run: async ({ init, input }) => {
    const fabric = await init({
      runtime: 'temporal',
      sandbox: 'local',
      compaction: { enabled: false },
    });
    const session = await fabric.session();
    return session.prompt(input.question);
  },
});

Every option is declared in source. Nothing implicit. Required for Temporal — auto-compaction would break replay determinism.

The defineAgent call shape is identical across both imports. Plain default-exported functions are intentionally rejected because a definition builder keeps jobs discoverable and gives the CLI typed metadata.

Top-level finite-definition instructions, tools, policy, costBudget, and approval timeout are applied to init(). A call-provided role overrides definition instructions; definition policies and budgets remain security floors, while call policy can add denials and approval requirements.

The FabricContext

When the CLI invokes an agent it provides:

interface AgentRunContext<TInput = JsonObject> {
  payload: TInput;
  input: TInput;
  init(options?: AgentInit): Promise<FabricAgent>;
  run?: JobInvocationContext;
  session(id?: string): Promise<FabricSession>;
  prompt(text: string): Promise<string>;
  skill(name: string): Promise<string>;
  task(text: string): Promise<string>;
  shell(command: string): Promise<ShellResult>;
  invoke(request: NamedJobInvocation): Promise<JobInvocationReceipt>;
}

Use input for typed, schema-validated values inside run:

run: async ({ init, input }) => {
  const fabric = await init();
  const session = await fabric.session();
  return session.prompt(input.question);
}

init() options

Agents from either entrypoint call the same init() to construct the runtime:

const fabricAgent = await init({
  id: 'agent-1',
  model: 'openai/gpt-5.5',
  role: 'engineer',                 // Markdown role file under .fabricharness/roles/
  sandbox: 'local',                 // 'virtual' | 'empty' | 'local' | 'docker' | 'cloudflare' | factory
  autonomy: {
    mode: 'background',
    onMissingInput: 'assume',
    onApprovalUnavailable: 'fail',
    onCredentialMissing: 'fail',
  },
});

When using the bare @fabric-harness/sdk import you typically omit runtime, sandbox, and loopRuntime — those defaults are injected. Override anything you like; defaults fill the gaps you don't set.

⚠️ Temporal users: if you set runtime: 'temporal' from the bare import, the SDK emits a one-time console.warn. Auto-compaction is non-deterministic across Temporal replay. Either switch to @fabric-harness/sdk/strict or pass compaction: { enabled: false } explicitly.

Triggers

Declare triggers inside defineAgent({...}). The Node and Cloudflare server targets respect:

export default defineAgent({
  name: 'triage',
  triggers: {
    webhook: true,    // POST /jobs/triage
    schedule: '*/15 * * * *',    // Node scheduler or Cloudflare Cron Trigger
    // cli: true,                  // CLI-only, default true
  },
  async run(ctx) {
    return ctx.input;
  },
});

Where to go next