The agent that nobody can run
Most agent code starts life in a terminal. You type a prompt, the model calls a few tools, files change, and it works. Then somebody asks the obvious question: can it run on a schedule, without you?
That is usually where it falls apart. The loop was written around a human — a person to approve the next step, read the output, retry when a tool fails, and remember what happened last time. Take the human out and you are left rebuilding the boring parts: session state, a filesystem the model can safely touch, retries that survive a crash, and somewhere to actually deploy the thing.
Flue is a TypeScript framework built around exactly that missing layer. Its pitch from Astro co-founder Fred K. Schott is blunt: it feels like Claude Code, but it is 100% headless and programmable. No TUI, no GUI, no assumption that an operator is watching. The framework's own equation is the clearest summary of its design:
Agent = Model + Harness
The model is a commodity you pick with a string. The harness — sessions, tools, skills, a sandbox, a durable filesystem, a deployment target — is the part Flue actually gives you.
This tutorial builds a real one: an autonomous content review agent that reads a Markdown article, checks it against an editorial checklist, calls a typed tool for data it cannot guess, and writes its findings to a file. You will run it locally, trigger it over HTTP, then deploy it to Cloudflare Workers where each agent instance becomes its own Durable Object.
Prerequisites
Before starting, make sure you have:
- Node.js 22 or newer and a package manager (npm, pnpm, or bun)
- An Anthropic API key (or another supported provider key) exported as an environment variable
- Working TypeScript knowledge — ES modules, async/await, and generics
- A Cloudflare account plus
wrangler, only for the deployment section - Familiarity with schema validation helps; Flue uses Valibot rather than Zod for tool schemas
A note on versions: Flue went public on 1 May 2026 and shipped its 1.0 Beta on 16 June 2026 after a 335-commit rewrite. It is moving fast, and the beta already broke its own tool API once (more on that in the troubleshooting section). Pin your versions.
What you'll build
A single project with four moving parts:
| Piece | File | Job |
|---|---|---|
| Agent | agents/reviewer.ts | Execution policy: model, instructions, tools, skills, sandbox |
| Skill | skills/review-checklist/SKILL.md | The editorial standard, in Markdown |
| Tool | shared/tools.ts | A typed lookup the model cannot hallucinate |
| Workflow | workflows/review-article.ts | Finite orchestration, triggered by HTTP |
The finished agent accepts a Markdown document, reviews it, and returns structured findings. The same source deploys to a long-running Node server or to Cloudflare's edge without a rewrite.
Step 1: Scaffold the project
Install the runtime as a dependency and the CLI as a dev dependency:
mkdir article-reviewer && cd article-reviewer
npm init -y
npm install @flue/runtime
npm install --save-dev @flue/cliGenerate the config. The --target flag decides which runtime you build for:
npx flue init --target nodeThat writes a flue.config.ts:
// flue.config.ts
import { defineConfig } from '@flue/cli/config';
export default defineConfig({
target: 'node',
});The full signature is worth knowing, because you will use --root in a monorepo:
flue init --target <node|cloudflare> [--root <path>] [--force]Now create the directory layout Flue discovers by convention:
mkdir -p agents workflows skills/review-checklist sharedFlue treats these directories as meaningful:
agents/— one agent per file. The filename becomes the agent ID, soagents/reviewer.tsis therevieweragent. Discovery here is required for persistent agent routes and fordispatch().workflows/— one workflow per file, default-exported.skills/— Markdown instruction bundles.shared/— ordinary modules; no magic.
Step 2: Define your first agent
An agent in Flue is not a long-lived object. defineAgent takes an initializer — a function that runs every time a runner builds a root harness from the definition. That distinction matters: do not treat it as a constructor for one persistent instance.
// agents/reviewer.ts
import { defineAgent } from '@flue/runtime';
export default defineAgent(() => ({
model: 'anthropic/claude-sonnet-4-6',
instructions: [
'You are an editorial reviewer for a technical publication.',
'Review the requested document and report only findings supported by evidence from the text.',
'Never invent a quotation, a statistic, or a source.',
].join('\n'),
}));Two fields and you have a working agent. The initializer receives a context object, which is where per-run identity and platform bindings arrive:
export default defineAgent(({ id, env }) => ({
model: 'anthropic/claude-sonnet-4-6',
instructions: `Reviewing under run ${id}.`,
}));id is the agent instance ID or workflow run ID. env holds platform environment bindings supplied by the runtime — on Cloudflare this is how you reach your Worker bindings.
The full AgentRuntimeConfig surface is small enough to memorise:
| Field | Purpose |
|---|---|
model | Default model specifier, in provider/model form |
instructions | Prepended to discovered workspace context |
tools | Custom model-callable tools |
skills | Registered Markdown skills |
actions | Reusable Actions exposed as framework-managed tools |
subagents | Named profiles available for delegated session.task() calls |
sandbox | Where code and file operations execute |
cwd | Working directory for the agent |
thinkingLevel | Default reasoning effort; individual operations can override |
profile | A reusable baseline; agent fields replace or extend it |
description | Organizational metadata for this initialization |
Notice what is absent: no graph, no node registry, no state machine. Flue's bet is that a capable model plus a well-built harness beats an explicit orchestration DAG. If you want the opposite philosophy, compare it with LangGraph's stateful graphs.
Step 3: Teach it standards with a skill
Instructions live in the agent definition and apply to everything. Standards that only matter for one kind of task belong in a skill — a plain Markdown file the runtime registers and the model can pull in.
<!-- skills/review-checklist/SKILL.md -->
# Editorial review checklist
Apply every rule below. For each finding, quote the offending text.
## Accuracy
- Every version number, benchmark figure, and price must be attributable to the document itself.
- Flag any claim phrased as fact without a source.
## Structure
- The opening must state the problem before naming any product.
- Headings must be scannable and describe outcomes, not features.
- Code blocks must declare a language for syntax highlighting.
## Language
- Prefer the active voice.
- Cut hedging: "arguably", "it could be said", "quite possibly".
- Expand every acronym on first use.
## Output contract
Write findings to `review.md` as a Markdown list, most severe first.
Each entry: severity, the quoted text, and the concrete fix.
If the document passes a section cleanly, say so in one line.Import it with an import attribute — this is how Flue distinguishes a skill from an ordinary Markdown asset:
// agents/reviewer.ts
import { defineAgent } from '@flue/runtime';
import reviewChecklist from '../skills/review-checklist/SKILL.md' with { type: 'skill' };
export default defineAgent(() => ({
model: 'anthropic/claude-sonnet-4-6',
instructions: 'Review the requested document and report only evidence-backed findings.',
skills: [reviewChecklist],
}));Keeping the checklist in Markdown is a genuine operational win: your editor can revise the standard in a pull request without touching TypeScript, and the diff is readable by someone who does not write code.
Step 4: Add a typed tool with Valibot
Skills shape judgement. Tools are for facts the model has no business guessing — a word-count budget from your CMS, an approved terminology list, a publication date.
defineTool validates and returns a frozen tool definition. Schemas are Valibot, and the input schema must be a top-level object schema:
// shared/tools.ts
import { defineTool } from '@flue/runtime';
import * as v from 'valibot';
import { cms } from './cms.ts';
export const lookupStyleBudget = defineTool({
name: 'lookup_style_budget',
description:
'Read the publication style budget for a content type. Use before commenting on article length or heading depth.',
input: v.object({
contentType: v.picklist(['tutorial', 'news', 'blog']),
}),
output: v.object({
minWords: v.number(),
maxWords: v.number(),
maxHeadingDepth: v.number(),
}),
async run({ input, signal }) {
return cms.getStyleBudget(input.contentType, { signal });
},
});The validation contract is precise, and it is what makes tools safe to expose:
- Model-supplied input is validated and parsed before
runreceives it. - A validation failure becomes a tool error, so the model can read it and retry — it does not crash the run.
- When
outputis present, the return value is validated and parsed too, then snapshotted as JSON-compatible data and JSON-stringified for the model. - Without an
outputschema, returningundefinedsendsnullto the model. - Tool names must be unique across active built-in and custom tools; collisions are checked when a session assembles its tool list.
Bound tools beat clever prompts
The most valuable pattern here has nothing to do with types. Your application decides the boundary; the model only picks values inside it. Close over the tenant identity instead of accepting it as a parameter:
// agents/support.ts
import { defineAgent, defineTool } from '@flue/runtime';
import * as v from 'valibot';
import { orders } from '../shared/orders.ts';
export default defineAgent(({ id: customerId }) => ({
model: 'anthropic/claude-haiku-4-5',
tools: [
defineTool({
name: 'lookup_customer_order',
description: 'Look up one order belonging to this customer.',
input: v.object({
orderId: v.string(),
}),
async run({ input }) {
const status = await orders.getStatus(customerId, input.orderId);
return status ?? 'No accessible order was found.';
},
}),
],
}));customerId comes from the initializer context, not from the model. There is no argument the model can set to read another customer's orders, so no amount of prompt injection reaches that data. Compare this with the prompt-level defences in our AI agent guardrails guide — structural boundaries are strictly stronger, because a jailbreak cannot argue its way past a closure.
Wire the tool into the reviewer:
// agents/reviewer.ts
import { defineAgent } from '@flue/runtime';
import { local } from '@flue/runtime/node';
import reviewChecklist from '../skills/review-checklist/SKILL.md' with { type: 'skill' };
import { lookupStyleBudget } from '../shared/tools.ts';
export default defineAgent(() => ({
model: 'anthropic/claude-sonnet-4-6',
instructions: 'Review the requested document and report only evidence-backed findings.',
skills: [reviewChecklist],
tools: [lookupStyleBudget],
sandbox: local(),
}));Step 5: Orchestrate with a workflow
An agent is a policy. A workflow is a finite job that borrows that policy and terminates. Default-export it from workflows/<name>.ts:
// workflows/review-article.ts
import { defineWorkflow } from '@flue/runtime';
import * as v from 'valibot';
import reviewer from '../agents/reviewer.ts';
export default defineWorkflow({
agent: reviewer,
input: v.object({
document: v.string(),
contentType: v.picklist(['tutorial', 'news', 'blog']),
}),
output: v.object({
review: v.string(),
}),
async run({ harness, input }) {
await harness.fs.writeFile('document.md', input.document);
const session = await harness.session();
await session.prompt(
`Review document.md as a ${input.contentType}. Apply the editorial checklist and write your findings to review.md.`,
);
return { review: await harness.fs.readFile('review.md') };
},
});This short function is where Flue's design pays off. Three things deserve attention:
harness.fs is the handoff. You write the input as a file, the agent reads and edits files, you read the result back. No brittle parsing of a prose reply, no asking the model to emit clean JSON and hoping. The filesystem is the contract, which is exactly how a human engineer would work.
harness.session() is stateful. The session accumulates context across turns. Call prompt several times and the agent remembers the earlier ones:
async run({ harness, input }) {
await harness.fs.writeFile('document.md', input.document);
const session = await harness.session();
await session.prompt('Read document.md and list every factual claim to review.md.');
await session.prompt('Now verify each listed claim against the document. Delete any you cannot support.');
await session.prompt('Rewrite review.md sorted by severity, most severe first.');
return { review: await harness.fs.readFile('review.md') };
}Each pass is cheap to reason about and the intermediate state is inspectable on disk — a far better debugging story than one giant prompt.
agent may be private. The workflow's agent does not need to live under agents/. Inline it when nothing else uses it:
const workflow = defineWorkflow({
agent,
async run({ harness }) {
return await (await harness.session()).prompt('Triage this incoming issue.');
},
});Discovery under agents/ is only required for persistent agent routes and dispatch().
defineWorkflow has a second form that takes an action instead of run, when you want the input and output contract to live in a reusable Action:
function defineWorkflow<TAction extends ActionDefinition>(options: {
agent: AgentDefinition;
action: TAction;
}): WorkflowDefinition<TAction>;The extracted form does not accept input or output — those contracts belong to the Action itself.
Step 6: Run it locally and trigger it over HTTP
Iterate with the dev server:
npx flue devTo attach an interactive session to an agent while developing:
npx flue connect reviewer local-sessionFor a deployable Node artifact, build and start it:
npx flue build --target node
PORT=8080 node dist/server.mjsThe Node target exposes an asynchronous job API — the right shape for agent work, which takes seconds to minutes rather than milliseconds:
POST /workflows/review-articlestarts a run and returns a run IDGET /runs/[runId]polls for the result
curl -X POST http://localhost:8080/workflows/review-article \
-H 'Content-Type: application/json' \
-d '{"document":"# Draft\n\nOur framework is arguably the fastest.","contentType":"tutorial"}'
# => {"runId":"run_01J..."}
curl http://localhost:8080/runs/run_01J...To add middleware — authentication, rate limiting, request logging — export a route handler from the workflow:
import type { WorkflowRouteHandler } from '@flue/runtime/node';
export const route: WorkflowRouteHandler = async (c, next) => {
if (c.req.header('authorization') !== `Bearer ${process.env.TRIGGER_TOKEN}`) {
return new Response('Unauthorized', { status: 401 });
}
return next();
};Do not skip that check. A workflow endpoint spends money on every call, which makes an unauthenticated one a billing vulnerability as much as a security one. Pair it with Upstash rate limiting before exposing it publicly.
Step 7: Choose a sandbox
The sandbox decides what "write a file" and "run a command" actually mean. Flue ships two options with very different trade-offs.
just-bash is the default. It is in-memory and carries no infrastructure cost. The agent gets a coherent filesystem and shell semantics with nothing to provision. For document work like ours it is genuinely enough.
local() gives real filesystem access. Import it from the Node entrypoint:
import { local } from '@flue/runtime/node';
export default defineAgent(() => ({
model: 'anthropic/claude-sonnet-4-6',
cwd: '/srv/repositories/catalog-service',
sandbox: local(),
}));Use local() when the agent must operate on a real checkout — reviewing a repository, running a test suite, building a project. Scope it with cwd and treat that directory as fully writable by the model. The trade-off is real: you have handed a language model your disk, so run it as an unprivileged user, in a container, on a machine you would be comfortable rebuilding.
Here is the fuller agent that pattern produces:
import { defineAgent } from '@flue/runtime';
import { local } from '@flue/runtime/node';
import reviewChecklist from '../skills/review-checklist/SKILL.md' with { type: 'skill' };
import { reviewChange } from '../actions/review-change.ts';
import { repositoryTools } from '../shared/repository-tools.ts';
export default defineAgent(() => ({
model: 'anthropic/claude-sonnet-4-6',
instructions: 'Review the requested change and report only findings supported by evidence.',
cwd: '/srv/repositories/catalog-service',
actions: [reviewChange],
tools: repositoryTools,
skills: [reviewChecklist],
sandbox: local(),
}));Step 8: Deploy to Cloudflare Workers
Flue is the first framework built on the Cloudflare Agents SDK, which is less of a coincidence than it looks: Cloudflare acquired the Astro team in January 2026.
Switch the target and the deployment model changes underneath you, with the agent code untouched:
// flue.config.ts
import { defineConfig } from '@flue/cli/config';
export default defineConfig({
target: 'cloudflare',
});Each agent becomes a Durable Object. That single sentence resolves most of the hard parts of hosting agents. A Durable Object is a single-threaded, addressable, stateful compute instance with its own storage, so session state has a natural home. There are no sticky sessions to configure and no servers to provision, and scaling is a consequence of the architecture rather than a project.
Durability comes from Agents SDK primitives — runFiber(), stash(), and onFiberRecovered() — so a run that dies mid-flight can resume rather than restart. If you have built compensating logic for half-finished agent runs on a conventional queue, this is the part worth paying attention to.
For sandboxed code execution the Cloudflare target uses @cloudflare/codemode, which wraps Dynamic Workers: Code Mode creates a fresh Dynamic Worker for each snippet, runs it, and discards it. Isolates start in under 10 ms at roughly $0.002 per load. Every snippet gets a clean, disposable environment, which is a stronger isolation story than reusing one long-lived container — and cheap enough to use per tool call.
Develop against the Cloudflare runtime locally, reading variables from .dev.vars or .env:
npx flue dev --target cloudflareThen build and ship:
# One-off build for Cloudflare
npx flue build --target cloudflare
# Configure a deployed secret interactively, then deploy
npx wrangler secret put ANTHROPIC_API_KEY
npx wrangler deploy --config dist/my-agent/wrangler.jsonNote that the build emits its own wrangler.json under dist/; point wrangler deploy at that file rather than writing one by hand.
Because you are inside the Cloudflare platform, the surrounding bindings are available through env in your initializer — AI Gateway, Browser Run, Email Service, Agent Memory, and AI Search. Routing model calls through AI Gateway is the easy win: you get caching, retries, and per-model spend visibility without touching agent code. Our AI Gateway tutorial covers the same idea from the provider-routing side.
Step 9: Delegate to subagents
One session holding every concern produces a bloated context window and mediocre judgement. subagents declares named profiles, and session.task() delegates to them — each with its own context:
export default defineAgent(() => ({
model: 'anthropic/claude-sonnet-4-6',
instructions: 'Coordinate a multi-pass editorial review.',
subagents: [
{
description: 'Verifies factual claims against the document only.',
model: 'anthropic/claude-sonnet-4-6',
instructions: 'Check each claim. Report unsupported ones. Never speculate.',
},
{
description: 'Checks structure, headings, and code-block languages.',
model: 'anthropic/claude-haiku-4-5',
instructions: 'Report structural problems only. Ignore factual content.',
},
],
}));Two things this buys you. Each subagent reads only what its job requires, which keeps accuracy up and token spend down — a cheap model handles the structural pass while the expensive one does fact-checking. And thinkingLevel can be tuned per profile, so reasoning effort goes where it earns its cost.
Testing your implementation
Agent tests that call a live model are slow, expensive, and non-deterministic. Flue's test utilities let you script model responses instead, so you assert on your own logic:
const provider = createProvider();
provider.setResponses([
fauxAssistantMessage(fauxToolCall('lookup', { limit: '2' }), { stopReason: 'toolUse' }),
fauxAssistantMessage('Done.'),
]);
const harness = await context.initializeRootHarness(
defineAgent(() => ({
model: `${provider.getModel().provider}/${provider.getModel().id}`,
tools: [
defineTool({
name: 'lookup',
description: 'Look up values.',
input: v.object({ limit: v.pipe(v.string(), v.transform(Number)) }),
run: async ({ input }) => input.limit,
}),
],
})),
);
await (await harness.session()).prompt('Look up values.');Note v.pipe(v.string(), v.transform(Number)) — models emit strings for numeric arguments far more often than they should, and a transform absorbs that at the boundary instead of leaving it in your run body.
Test in three layers:
- Tools in isolation. They are ordinary async functions. Call
rundirectly with valid and invalid input and assert that bad input produces a tool error rather than a throw. - Workflow logic with scripted responses. Verify the filesystem handoff, the branching, and the output schema, with no network calls.
- A small live end-to-end suite. A handful of real runs against known documents, checking that findings are non-empty and reference real quotations. Keep it out of the pre-commit path.
Verify the whole path works before moving on:
npx flue build --target node
PORT=8080 node dist/server.mjs &
curl -X POST http://localhost:8080/workflows/review-article \
-H 'Content-Type: application/json' \
-d '{"document":"# Draft\n\nOur framework is arguably the fastest available.","contentType":"tutorial"}'Poll the returned run ID. A correct implementation flags "arguably the fastest" as an unsupported claim, because the checklist demands attribution and the document provides none. If findings come back empty, your skill is not reaching the model — check that the import attribute is present.
Troubleshooting
parameters or execute throws when defining a tool. This is the 1.0 Beta breaking change. The old markers now throw by design. Rename parameters to input, rename execute(args, signal) to run({ input, signal }), and return structured JSON-compatible data directly instead of calling JSON.stringify(...). Add an output schema where the shape should be validated.
Tool name collision errors. Names must be unique across built-in and custom tools, and the check happens when a session assembles its tool list — so a collision can surface at runtime rather than at build time. Prefix your custom tools, for example cms_lookup_budget.
The skill is ignored. A Markdown import without with { type: 'skill' } is just a string. The import attribute is what registers it.
Import attributes fail to parse. They need Node 22 or newer and a bundler that understands them. If your toolchain rejects the syntax, upgrade before working around it.
The agent cannot see your files. Check sandbox and cwd. The default just-bash sandbox is in-memory and cannot read your disk at all — that is the point. Switch to local() when real files are required.
Environment variables are missing on Cloudflare. flue dev --target cloudflare reads .dev.vars or .env, but deployed Workers do not. Every secret needs wrangler secret put, and a key that works locally proves nothing about production.
A run stops partway with no error. Check whether you hit a model context limit inside a single long session. Split the work into several prompt calls, or delegate to subagents so each context stays small.
Production notes
Three things matter more than they look:
Cost is a design decision, not a line item. A single autonomous run can make dozens of model calls. Set thinkingLevel deliberately, route cheap passes to a cheap model via subagents, and put prompt caching in front of stable instruction prefixes — the checklist skill is identical on every run, which makes it an ideal cache prefix.
You cannot debug what you cannot see. A headless agent that fails silently at 3 a.m. is worse than no agent. Emit traces from the start; our Langfuse observability guide covers the tooling.
Bound every tool. Re-read Step 4. Close over tenant identity and pass only the narrow selector as tool input. This is the single highest-leverage security decision in an agent codebase, and it costs nothing.
Next steps
flue addblueprints. Commands likeflue add channel slackgenerate a Markdown blueprint that agents can then modify and integrate — the framework treats its own extension surface as agent-editable.- Actions. Extract reusable, schema-validated operations with
defineAction()and share them across workflows. - Scheduling. Point a cron trigger at the workflow endpoint for genuinely unattended runs, or compare with durable background jobs in Trigger.dev.
- Other targets. Flue also deploys to GitHub Actions, which makes a pull-request review agent a natural second project.
- Compare harnesses. The Vercel AI SDK 7 harness and sandbox model solves overlapping problems with different primitives; reading both clarifies what a harness is actually for.
- Explore the CLI.
flue add,flue build,flue dev,flue docs,flue init,flue run, andflue updateare the full surface.flue docsis genuinely useful mid-task.
Conclusion
Flue's contribution is not another way to call a model. It is the claim that the interesting engineering in agents lives in the harness — and that if you build that layer properly, the agent code shrinks to something you can read in one sitting.
The reviewer you just built is about forty lines of TypeScript plus a Markdown checklist. It has stateful sessions, validated tools, a sandbox, a durable filesystem, an HTTP trigger, and a path to the edge where each instance becomes its own Durable Object. None of that came from framework ceremony; it came from choosing a target and letting the harness handle the rest.
The pattern worth taking away, whatever framework you end up using: let the application define the boundary and the model choose within it. Bound tools, a filesystem handoff instead of parsed prose, and small delegated contexts are not Flue-specific ideas. They are what separates an agent you can run unattended from a demo that needs somebody watching.
Start with just-bash and a single workflow. Add a sandbox when the agent genuinely needs real files, and subagents when one context stops being enough. Ship the boring version first — an agent that runs every night without you is worth more than a clever one that cannot leave your terminal.