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
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.
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:
| Field | Required | What it declares |
|---|---|---|
name | yes | The workflow's identity; lowercase, stable across versions. |
triggers | yes | At least one of cron, webhook, manual. See Triggers. |
permissions | no | What a run may do, including permissions.secrets, the allowlist of names the program may secrets.get(). See Secrets. |
budget | no | Caps: max_usd, max_duration_seconds. Breaching a budget fails the run; it never silently truncates. |
workspace | no | Directories under the workspace to persist between runs. |
runs_on | no | Machine label; defaults to boardwalk/linux. |
description | no | One 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. There is no checkpointing or replay to reason about; a sleeping run simply holds its machine until it wakes.
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.
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.