Writing workflows

A workflow is one TypeScript file with two parts: a meta export that declares the contract, and a script body that does the work. No YAML, no node editor, no framework to subclass.

The shape of a workflow

morning-digest/index.ts
import { phase, agent, output, secrets } from "@boardwalk-labs/workflow";

export const meta = {
  slug: "morning-digest",
  title: "Morning Digest",
  triggers: [{ kind: "cron", expr: "0 9 * * 1-5" }],
  permissions: { secrets: [{ name: "GITHUB_TOKEN" }] },
};

phase("Fetch issues");
const token = await secrets.get("GITHUB_TOKEN");
const res = await fetch("https://api.github.com/issues", {
  headers: { Authorization: `Bearer ${token}` },
});
const issues = await res.json();

phase("Summarize");
const digest = await agent(
  `Write a morning digest of these issues:
   ${JSON.stringify(issues)}`,
);
output(digest);

The file executes top to bottom each time the workflow runs. Top-level await is normal; deterministic code (fetching, parsing, holding secrets) and model calls (agent()) interleave freely.

One file is the starting point, not the ceiling. When a workflow grows, make it a package: an entry file that imports your own helper modules and ships bundled assets next to it (a skills/ directory, prompt templates, a rubric). The entry file still exports meta; the CLI bundles the whole package to one artifact at deploy. boardwalk deploy . on a directory does exactly this.

Put a README.mdat the package root and it becomes the workflow's landing page in the dashboard, rendered beside the config your meta derives. Your meta can only say what the workflow is configured to do; the README is where you say what it is for, what it costs, and what to do when it pages you. It always ships, whether you deploy the directory or the file.

The meta contract

metamust be a pure literal: Boardwalk derives the workflow's manifest from it at deploy time without executing your code. The fields:

FieldRequiredWhat it declares
slugyesThe workflow's identity; lowercase, stable across versions.
titlenoA human-readable display label; defaults to a title-cased slug.
triggersyesAt least one of cron, webhook, manual. See Triggers.
permissionsnoWhat a run may do, including permissions.secrets, the allowlist of names the program may secrets.get(). See Secrets & environments.
budgetnoCaps: max_usd, max_tokens, max_duration_seconds (active compute only: a run parked in sleep, a human-input gate, or a child-wait doesn't burn it), and deadline_seconds (wall-clock from start, including idle suspension). Breaching a budget fails the run; it never silently truncates.
workspacenoDirectories under the workspace to persist between runs.
runs_onnoMachine label; defaults to boardwalk/linux.
descriptionnoOne line for the dashboard and your teammates.

The SDK

Everything a program imports comes from @boardwalk-labs/workflow (MIT, source). The same imports work on every engine.

agent()

const text = await agent("Summarize this changelog: ...");

// Name a model explicitly, or pass a schema for structured output:
const groups = await agent<Groups>(prompt, {
  model: "anthropic/claude-sonnet-4.5",
  schema: GROUPS_SCHEMA,
});

Runs one model call to completion and resolves to its final text (or, with schema, the validated object). model is optional: on Boardwalk, omitting it lets the platform route the call (no API key needed); under boardwalk dev and self-hosting it uses your configured default model and your key. provider selects a named inference provider when you have several.

phase() and output()

phase("name") marks a section of the run for the live tail and the run log; it is observability only. output(value)records the run's result, which is what the dashboard, notifications, and workflows.call() see.

sleep()

await sleep(5 * 60 * 1000); // ms
await sleep({ until: "2026-07-01T09:00:00Z" });

Really sleeps: the process waits and your locals survive. A short sleep holds in place; a long one suspends the run and releases its machine, so you do not pay while it waits, then it resumes where it left off.

workflows.call() and parallel()

// Durable child run: the parent waits and gets the child's output.
const result = await workflows.call("file-issue", { title });

// Fan out in-process work:
await parallel(items.map((item) => () => handle(item)));

workflows.call() starts another workflow as a durable child run and is idempotent: if the parent restarts, it re-attaches to the same child instead of spawning a duplicate. workflows.run() is the fire-and-forget variant.

Make the run legible

Every run is a permanent, replayable record (see Runs & observability), and your program decides how much of its story that record tells. Two hooks do the work, and a good workflow uses both from the start: when you come back to a failed run, the log is all you have to go on.

Mark each stage with phase(). A phase names a section of the run on the phase channel, so the live tail and boardwalk runs <id> --logsread as a sequence of named steps ("Fetch issues", "Triage", "File tickets") instead of one undifferentiated stream. Set one per logical stage.

Narrate inside a stage with console.log. Plain stdout and stderr land on the logchannel, so this is where you record the specifics: how many items you fetched, which branch you took and why, the id of something you created, the decision a step reached. A run whose log answers "what did this actually do?" is one you can debug without re-running it.

phase("Fetch issues");
const issues = await fetchOpenIssues();
console.log(`Fetched ${issues.length} open issues`);

phase("Triage");
const urgent = issues.filter(isUrgent);
console.log(`${urgent.length} of ${issues.length} need attention`);

phase("File tickets");
for (const issue of urgent) {
  const ticket = await workflows.call("file-ticket", { issue });
  console.log(`Filed ${ticket.id} for issue #${issue.number}`);
}

The default run view is the quiet trio (lifecycle, phase, output), so well-named phases alone make a run readable at a glance; --verbose or --stream log adds the detail when you need it. One caution: the log channel is persisted with the run, and secretsare redacted from the model's context, not from your own console.log. Never log a secret value.

Writing efficient workflows

A workflow that works can still cost more than it needs to. The model is almost always the biggest line on the bill, so most of the savings come from spending tokens deliberately: the right model for each call, a tight prompt, and a shape that lets the platform reuse work instead of redoing it. This is not premature optimization. It is the difference between a workflow you run once and one you run on a schedule for months.

Match the model to the job

The model is chosen per agent() call (see Inference), so match each call to its work: a small, fast model for routine steps (classify, route, short summaries), and a stronger model for the genuinely hard ones. When you're not sure which to pick, reach for Auto: omit model (or pass model: "auto") and the managed lane routes each call to a fitting model, with no routing fee, and it keeps improving as better models ship. reasoning is the other dial: a high effort spends a lot of extra thinking tokens and latency, so raise it only on the steps that need careful multi-step reasoning.

// Routine triage: a small, fast model, no deep reasoning.
const { category } = await agent<Label>(`Classify: ${msg}`, {
  model: "google/gemini-3.5-flash",
  schema: LABEL,
});

// The hard step: a stronger model and more reasoning, only here.
const plan = await agent(`Work out the migration plan: ...`, {
  model: "openai/gpt-5.5",
  reasoning: "high",
});

// Not sure which fits? Let Auto route it.
const summary = await agent(`Summarize this thread: ...`); // model defaults to Auto

Scope tools and output

By default an agent carries the full tool belt. A step that only reads, or only needs to return an answer, does not need all of it. Narrow it with builtins: "read-only" for analysis, "none" for a pure classifier or judge. Every tool definition is tokens the model re-reads on each turn, so a smaller tool set means a smaller prompt and fewer chances for the agent to wander into extra turns. Ask for structured output with a schema instead of prose you then re-prompt to reformat: one call, a typed object, no cleanup pass. And do the deterministic work in code. Parsing, filtering, deduping, and routing are cheaper and more reliable as plain TypeScript than as another model call. Save the model for the judgement only a model can make.

// A judge needs no tools and one structured answer.
const { verdict } = await agent<Verdict>(`Is this correct? ...`, {
  builtins: "none",
  schema: VERDICT,
});

Prompt caching

Managed inference caches the stable front of your prompt automatically, with nothing to configure. The win shows up inside a single agent() call that takes more than one turn (a tool-use loop): the instructions, tool definitions, and early context stay the same across turns, so after the first turn the model reads that prefix from cache at a small fraction of the normal input cost, and only the new tokens are charged in full. A long agentic step can be a great deal cheaper than its raw token count suggests.

To earn that discount, keep the front of the prompt stable and put the part that varies last. Lead with fixed instructions and shared context; append the specific item, the latest message, or the file under review at the end. Anything baked into the instructions that changes every run (a timestamp, a random id, "today is ...") makes every turn look new, so nothing is reused.

// Good: the instructions are a stable prefix the loop reuses every turn.
const review = await agent(`${RUBRIC}\n\nReview this PR:\n${diff}`);

// Avoid: a timestamp in the instructions changes the prefix on every turn,
// so the cache never hits.
const bad = await agent(`Run at ${new Date().toISOString()}\n${RUBRIC}\n${diff}`);

A standalone call with no follow-up turn (a one-shot classifier or judge, especially with builtins: "none") is a single model turn, so there is no later turn to read a cache. That is expected. Do not engineer caching for those; make them cheap with a small model and a tight prompt instead. And when many items share the same large context, prefer one agent loop that works through them, so the shared context is a cached prefix across its turns, over many separate one-shot calls that each resend the whole thing fresh. Very short prompts are not cached at all, which is fine.

Parallelism and patterns

Independent work should run concurrently. parallel() runs a batch of tasks at once on the same held machine, so the run finishes in the time of the slowest task rather than the sum. It is concurrency on one machine, so the cost is the tokens and compute you would have spent anyway, just sooner.

// Concurrent, not sequential: finishes in the time of the slowest item.
const results = await parallel(items.map((item) => () => handle(item)));

The multi-agent patterns (a panel of verifiers, a fan-out of researchers, a tournament) buy quality and coverage, and they spend real tokens to do it: five verifiers is five times the tokens of one answer. Reach for them when a task is genuinely large, parallel, or adversarial, and use a single focused agent() call for everything an everyday task handles well.

Don't pay to wait

Use sleep() instead of polling. A long sleep suspends the run and releases its machine, so you are not billed while it waits, then it resumes where it left off. A loop that wakes every minute to check on something keeps a machine (and often a model) busy the whole time; a single sleep until the next check, or until a known time, costs nothing in between.

// Releases the machine while it waits, then resumes in place.
await sleep({ until: "2026-07-01T09:00:00Z" });

Guardrails and reuse

Set a budget: max_usd fails a run fast if its cost runs away, instead of letting a stuck loop burn money (it is a guardrail, not the bill). Right-size the machine: the default is small and fits most workflows, so ask for a larger runs_on only when a step is actually CPU or memory bound, since a bigger machine costs more per second. Persist expensive setup: if a run rebuilds the same thing every time (a cloned repo, a downloaded model, a built index), name it in workspace: { persist: [...] } so the next run reuses it. And put work you must not repeat behind workflows.call(), which re-attaches to a finished child on restart rather than running it again.

export const meta = {
  slug: "nightly-audit",
  triggers: [{ kind: "cron", expr: "0 2 * * *" }],
  budget: { max_usd: 5 },            // fail fast if cost runs away
  workspace: { persist: ["repo"] },  // reuse the clone next run
};

Crashes and restarts

If a run's process dies, Boardwalk restarts the program from the top, like a Lambda or a CI job. Write accordingly: make side effects idempotent where it matters, and put work you must not repeat behind workflows.call() (which re-attaches) rather than inline.