Headless agents with the default import
The 10-line path to a working headless agent — agent({...}) from @fabric-harness/sdk with headless defaults injected.
Fabric Harness separates three choices that are easy to mix up:
- Entrypoint —
@fabric-harness/sdkfor headless defaults, or@fabric-harness/sdk/strictfor no implicit behaviour. - Runtime —
stateless,inline, ortemporal. - Target — where the agent runs, such as
node,temporal-worker,docker, orcloudflare.
This page shows the smallest headless setup: the bare @fabric-harness/sdk import + headless defaults + @fabric-harness/sdk. The same init(), session.prompt(), session.shell(), session.skill(), and session.task() APIs work from @fabric-harness/sdk/strict too.
One SDK, two import entrypoints. The minimal entrypoint is not a separate framework and not a less capable runtime. It keeps imports small and applies headless defaults. Durability still comes from
runtime, not the import path.
The 7-line agent
import { agent } from '@fabric-harness/sdk';
export default agent<{ message: string }>({
name: 'echo', triggers: { webhook: true },
run: async ({ init, input }) => {
const session = await (await init({ model: 'openai/gpt-5.5' })).session();
return { reply: await session.prompt<string>(input.message) };
},
});Run it:
export FABRIC_MODEL=openai/gpt-5.5
export OPENAI_API_KEY=...
fabric-harness run echo --message "hello"Or expose it as a webhook:
fabric-harness dev
curl -X POST http://localhost:8787/agents/echo \
-H "Content-Type: application/json" \
-d '{"message":"hello"}'That's it. No schemas to define, no policy to author, no session store to configure.
Defaults applied automatically
the bare @fabric-harness/sdk import injects three headless defaults so the snippet stays minimal. Each is overridable per call:
| Default | Why | Override with |
|---|---|---|
runtime: 'stateless' | Headless / edge / one-shot agents often do not need persistence | init({ runtime: 'inline' }) to keep an in-memory session, or 'temporal' for durable workflows |
sandbox: 'virtual' | In-memory bash + filesystem (grep, glob, read, cat, mkdir, rm, echo) via just-bash. No host shell access. | init({ sandbox: 'local' }) for host shell, 'docker' for isolation, or a SandboxFactory |
loopRuntime: pi-agent-core | Multi-provider model coverage from @earendil-works/pi-ai — Anthropic, OpenAI, Google, OpenAI-compatible proxies, OpenRouter, etc. | init({ loopRuntime: defaultLoopRuntime }) to use Fabric's NativeLoopRuntime (with capability-policy + result-retry hooks) |
For agents with typed I/O, capability policy, approvals, artifacts, or explicit metadata, use agent({...}) directly from @fabric-harness/sdk/strict. You can also keep agent({ run: , }) and change imports first; the session primitives do not change.
What the pieces do
the bare @fabric-harness/sdk import
agent({ options?, run: handler, }) is a thin shorthand for agent({ run: handler, ...options }) with no input/output schema. The payload is unknown, the return value passes through unchanged. Use it when typed validation is more ceremony than value.
runtime: 'stateless'
Disables session persistence. No store writes, no artifact disk persistence, no approval waiting. Each invocation is independent. This is the right choice for:
- High-volume webhook handlers where state would just be discarded.
- Edge runtimes (Cloudflare Workers, Vercel Edge) with no durable storage attached.
- Quick prototypes where conversation continuity isn't yet a concern.
In production (FABRIC_ENV=production or NODE_ENV=production), choosing inline without an explicit SessionStore will emit a warning unless you set FABRIC_ALLOW_EPHEMERAL_STATE=1 or pick runtime: 'stateless' explicitly. This prevents surprise data loss.
@fabric-harness/sdk
A minimal entrypoint that re-exports the headless surface — init, the bare @fabric-harness/sdk import, agent, schema, defineCommand, sandbox helpers, MCP — without pulling Temporal, durable stores, or telemetry exporters into the import graph. Smaller bundles for edge deployments.
For the complete export surface, import from @fabric-harness/sdk directly. Both entrypoints share the same runtime and session primitives.
Minimal does not mean feature-light
The minimal entrypoint skips import weight and ceremony, not the shared agent primitives. Skills, roles, scoped commands, MCP tools, and tasks all work — they just do not require typed input/output metadata or a policy authoring step.
Skills (Markdown-defined procedures)
Drop a Markdown file under .fabricharness/skills/ and call it from your agent. Skills are discovered automatically by the workspace loader.
---
name: triager
description: Search the knowledge base for the best matching article and write a concise, friendly reply.
---
You are a customer support triager.
Steps:
1. Use grep / glob / read over `/workspace/kb` to find articles relevant to the customer's question.
2. Quote the most directly relevant lines.
3. Write a 2–4 sentence reply in the brand's voice (friendly, concise, no jargon).
4. If nothing matches, say so honestly and suggest the customer contact support@example.com.import { agent } from '@fabric-harness/sdk';
export default agent({
name: 'support', triggers: { webhook: true },
run: async ({ init, input }) => {
const session = await (await init({ runtime: 'stateless' })).session();
const message = String((payload as any)?.message ?? '');
const reply = await session.skill<string>('triager', { args: { message } });
return { reply };
},
});session.skill('triager', { args }) injects the skill body as the prompt, templates the args in, and returns the model's response. The same primitive exists from both entrypoints. Complete metadata agents can also declare default inline skills with agent({ skills: [...] }) and can add result: schema for typed validation.
Roles (system-prompt overlays)
Roles let you change the agent's voice or expertise without rewriting the prompt every time. Drop a Markdown file under .fabricharness/roles/:
---
name: concise
description: Reply in 1-3 sentences. No bullet points unless explicitly asked. Plain prose only.
---
You write extremely concise replies. Aim for 1–3 sentences. Use plain prose,
not bullet points or headings, unless the user asked for structured output.
Never repeat the question back. Never apologize for the length of the answer.Use it on a session, prompt, or skill call:
const session = await agent.session('thread-1', { role: 'concise' });
await session.prompt('What does this code do?'); // uses 'concise'
await session.skill('triager', { args, role: 'concise' }); // override on the callPrecedence: per-call role > session role > agent role.
Scoped commands (privileged CLIs)
defineCommand binds a CLI like gh or npm with its env (and therefore its secrets) at the command level — secrets never enter model context. Grant the command per-call:
import { agent, defineCommand } from '@fabric-harness/sdk';
const gh = defineCommand('gh', { env: { GH_TOKEN: process.env.GH_TOKEN } });
const npm = defineCommand('npm');
export default agent({
name: 'triage',
run: async ({ init, input }) => {
const session = await (await init({ sandbox: 'local', runtime: 'stateless' })).session();
const result = await session.skill<string>('triage-issue', {
args: { issueNumber: (payload as any)?.issueNumber },
commands: [gh, npm], // available only inside this skill call
});
return { result };
},
});The agent can run gh issue view 42 ... because gh is in commands. It cannot read GH_TOKEN directly — the token is bound to the gh invocation by the runtime, not exposed to the model.
Tasks (delegated subagents)
session.task(prompt, options?) runs a child session with its own message history but the same sandbox/filesystem — perfect for parallel research or focused subgoals.
const research = await session.task('Read /workspace/kb/billing/* and summarize the refund policy in 5 bullets.');
const reply = await session.prompt(`Use this research to answer the customer:\n\n${research}`);Tasks support a role and cwd override, so you can run them as a "researcher" without changing the parent role.
Mounting a knowledge base
Combine withFilesystemSources with the bare @fabric-harness/sdk import for a compact support agent in ~25 lines:
import { agent, localDirectorySource, withFilesystemSources } from '@fabric-harness/sdk';
const sandbox = withFilesystemSources('empty', [{
mountAt: '/workspace/kb',
source: localDirectorySource('./knowledge-base', { include: (p) => p.endsWith('.md') }),
}]);
export default agent({
name: 'support', triggers: { webhook: true },
run: async ({ init, input }) => {
const session = await (await init({ sandbox, runtime: 'stateless' })).session();
const reply = await session.skill<string>('triager', {
args: { message: String((payload as any)?.message ?? '') },
});
return { reply };
},
});See Filesystem sources for more.
MCP tools
connectMcpServer works the same from the bare @fabric-harness/sdk import — just don't forget to close() the connection in a finally:
import { connectMcpServer, agent } from '@fabric-harness/sdk';
export default agent({
name: 'gh-assist',
run: async ({ init, input, env }) => {
const github = await connectMcpServer('github', {
url: 'https://mcp.github.com/mcp',
headers: { Authorization: `Bearer ${env?.GITHUB_TOKEN}` },
});
try {
const session = await (await init({ tools: github.tools, runtime: 'stateless' })).session();
return await session.prompt(String((payload as any)?.prompt ?? ''));
} finally {
await github.close();
}
},
});Progressive disclosure: when to switch to /strict
The minimal entrypoint can be a starting point. Add production features one by one as the work demands them:
| You want to... | Switch to... | What changes in the agent |
|---|---|---|
| Validate inputs / outputs | agent({ input, output, run }) | Add Fabric schemas; the bare @fabric-harness/sdk import doesn't validate. |
| Restrict shell / tools | policy: CapabilityPolicy on init or session.prompt | Allow/deny/requireApproval lists. |
| Bind a privileged CLI | defineCommand('gh', { env: { GH_TOKEN } }) + commands: [gh] | Secrets bound at the command, never exposed to the model. |
| Run a Markdown skill | session.skill('triage', { args, result }) | Drop the skill into .fabricharness/skills/triage.md. |
| Persist conversations | runtime: 'inline' + a SessionStore (SQLite/Postgres/file from @fabric-harness/node) | Same init(), just pass store. |
| Write durable artifacts | session.artifact(name, content) | Requires a store that implements putArtifact. |
| Survive crashes / restarts | runtime: 'temporal' + @fabric-harness/temporal | The agent code is unchanged; the runtime swaps. |
Minimal vs complete entrypoint at a glance
import { agent } from '@fabric-harness/sdk';
export default agent({
name: 'echo',
run: async ({ init, input }) => {
const session = await (await init({ runtime: 'stateless' })).session();
return { reply: await session.prompt<string>(String((payload as any)?.message)) };
},
});import { agent, schema } from '@fabric-harness/sdk';
import type { CapabilityPolicy } from '@fabric-harness/sdk';
const policy: CapabilityPolicy = {
commandPolicy: {
allow: ['git status*', 'git diff*'],
deny: ['git push*'],
requireApproval: ['gh issue comment**'],
},
maxCommandTimeoutMs: 30_000,
};
const inputSchema = schema.object({ issueNumber: schema.number(), title: schema.string() });
const outputSchema = schema.object({ severity: schema.enum(['low','medium','high']), summary: schema.string() });
export default agent({
name: 'triage',
input: inputSchema,
output: outputSchema,
model: process.env.FABRIC_MODEL,
skills: [{ name: 'triage', content: 'Triage the issue and return a concise structured result.' }],
run: async ({ init, input }) => {
const session = await (await init({ policy })).session();
const result = await session.skill('triage', { args: input, policy, result: outputSchema });
await session.artifact('triage.md', `# ${input.title}`, { contentType: 'text/markdown' });
return result;
},
});The CLI, dev server, and deploy targets are shared. The SDK entrypoint and runtime choice are independent; see SDK entrypoints, runtimes, and targets.
Anti-patterns
- Don't fork your code into minimal and complete copies. Graduate features in place when you need typed I/O, policy, durable stores, telemetry, or provider classes.
- Don't use
runtime: 'stateless'when you need approvals. The approval flow needs a store to wait on; in stateless mode it falls through immediately. - Don't import from both
@fabric-harness/sdkand@fabric-harness/sdkin the same agent. Pick one entrypoint. The complete entrypoint is the broader surface; the bare@fabric-harness/sdkimport is the smaller import graph. - Don't assume durability from the import path. Use
runtime: 'temporal'or a durable store explicitly.
See also
- examples/minimal — the 10-line example end-to-end.
- examples/support-agent — minimal entrypoint + filesystem-mounted knowledge base.
- SDK entrypoints, runtimes, and targets — how the choices fit together.
- Runtime modes —
inlinevsstatelessvstemporal. - Filesystem sources — mount read-only content into the sandbox.