Writing workflows

A workflow is a typed function plus a small descriptor. You export a run function and the platform calls it; deployment policy (triggers, permissions, budget) lives in workflow.jsonc, a data file the control plane reads without executing your code. No YAML orchestration, no node editor, no framework to subclass.

The shape of a workflow

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

export default async function run(): Promise<string> {
  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");
  return agent(
    `Write a morning digest of these issues:
     ${JSON.stringify(issues)}`,
  );
}

The body of run is ordinary TypeScript: deterministic code (fetching, parsing, holding secrets) and model calls (agent()) interleave freely, with any control flow and any npm dependency. Capabilities are imports, exactly like import boto3 in a Lambda: a helper function deep in your code just imports what it needs.

A workflow is a package directory: the descriptor at the root, the entry at src/index.ts(or wherever the descriptor's entry points), and any helper modules the entry imports. You never list source files; the code that ships is whatever your entry imports, bundled at deploy. A skills/ directory and README.md always ship; other non-code assets (prompt templates, fixtures) ship via the files allowlist in the descriptor.

Put a README.mdat the package root and it becomes the workflow's landing page in the dashboard, rendered beside the config the descriptor declares. The descriptor 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.

The run function

The shape follows AWS Lambda's handler(event, context): params are positional, and both are optional from the right, so run(), run(input), and run(input, context) are all valid.

export default async function run(input: Input, context: Context): Promise<Output>
  • input is the data the run was triggered with: a webhook body, --input, a cron trigger's static input, or a workflows.call argument.
  • context is read-only metadata about this run: runId, workflowVersion, trigger, actor, attempt, workspaceDir, and a cancellation signal. It carries data only, never capabilities or secrets. Declare it only when you need it.
  • The return valueis the run's output: what the dashboard, notifications, and a calling parent receive. A void return means the output is null.

Typed input and output

Your native types are the contract. Annotate input and the return type, and the deploy derives their JSON Schemas from the signature: the dashboard renders an accurate input form, callers and workflows.call parents know the shape, and the runner validates your return against your own declared output schema. No schema literal, no wrapper library, nothing to keep in sync.

interface Payment { id: string; amountUsd: number; reason: string }
interface Triage  { action: "retry" | "refund" | "escalate"; note: string }

export default async function run(input: Payment): Promise<Triage> {
  const analysis = await agent(`Why did payment ${input.id} fail? Reason: ${input.reason}`);
  return { action: "retry", note: analysis };
}

Typing is opt-in. A bare run(input) with no annotation is the zero-ceremony floor: the raw JSON is handed to you, exactly like a Lambda handler reading an untyped event, and nothing is derived. Derivation runs server-side at deploy and reads the resolved types from the compiler, so constructs like Required<T>, Pick/Omit, and intersections come out correct; a field that can't become a typed widget degrades to a raw-JSON input with a warning on the deploy, and never blocks it.

Rich types cross the wire in canonical encodings and arrive revived: a Date travels as an ISO string and lands as a Date, Uint8Array as base64, bigint as a decimal string, Set as a deduped array. The same revival applies to a workflows.callresult, by the callee's schema: a child returning a Date hands its parent a Date.

The descriptor

A few things about a workflow must be known before or around the run by machinery that never executes your code: the schedule, the webhook, the secret allowlist, the budget. Those live in workflow.jsonc (JSON with comments; plain workflow.json also works). The full field-by-field reference is on The descriptor. Note what the descriptor does nothold: your I/O contract (that's your function signature) and any model choice (every agent() call picks its own).

morning-digest/workflow.jsonc
{
  "$schema": "https://boardwalk.sh/schemas/workflow.json",
  "slug": "morning-digest",
  "triggers": [{ "kind": "cron", "expr": "0 9 * * 1-5" }],
  "permissions": { "secrets": [{ "name": "GITHUB_TOKEN" }] },
  "budget": { "max_usd": 1 },
}

Python

The same shape works in Python: a module-level run(async or sync), positional params, fewer params fine. Point the descriptor's entry at the file (the default is main.py). Hosted runners ship Python 3.13; dependencies declared in pyproject.toml resolve at build time with uv and ship inside the artifact.

lead-scorer/main.py
from boardwalk import agent
from pydantic import BaseModel
from typing import Literal

class Lead(BaseModel):
    email: str
    company: str

class Score(BaseModel):
    score: int
    tier: Literal["hot", "warm", "cold"]

async def run(input: Lead) -> Score:
    signals = await agent(f"Find buying signals for {input.company}")
    score = int(await agent(f"Score 0-100, digits only:\n{signals}"))
    return Score(score=score, tier="hot" if score > 70 else "warm" if score > 40 else "cold")

Typed I/O uses pydantic models, dataclasses, or TypedDict annotations. Typed derivation requires pydanticin the package's dependencies; without it the schema honestly degrades to raw JSON with a warning. Scaffold a Python package with boardwalk init --python.

The SDK

Everything a program imports comes from @boardwalk-labs/workflow (MIT, source; Python: boardwalk on PyPI). The same imports work on every engine. The full inventory is the SDK reference; the everyday exports:

ExportWhat it does
agent()Runs one model call to completion; resolves to its final text or, with a schema, a validated object.
phase()Marks a section of the run for the live tail and run log; observability only.
sleep()Really waits and your locals survive; a long sleep suspends the run and releases its machine.
workflows.call()Starts another workflow as a durable child run and resolves to its output; a restarted parent re-attaches instead of double-firing.
parallel()Runs a batch of thunks concurrently and resolves to their results in order.
secrets.get()Resolves a secret to its plaintext value, fail-closed against permissions.secrets.
humanInput()Pauses the run for a person to answer, then resumes with their validated response.
artifacts.write()Stores a file with the run and resolves to a download URL.

To give an agent() call tools, MCP servers, skills, or memory, see Equipping agents. For the multi-agent shapes these primitives compose into, see Patterns & loops.

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.

HookWhat it doesWhere it shows
phase()Names a section of the run on the phase channel, one per logical stage, so the run reads as a sequence of named steps instead of one undifferentiated stream.The live tail, the default run view, and boardwalk runs <id> --logs.
console.logNarrates inside a stage: plain stdout and stderr land on the log channel, where you record the specifics (how many items you fetched, which branch you took and why, the id of something you created).--verbose or --stream log.
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. 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.

A workflow that works can still cost more than it needs to: Efficiency & cost covers matching the model to each call, scoping tools, prompt caching, parallelism, and budget guardrails.

Crashes and restarts

If a run's process dies, Boardwalk restarts the program from the top, like a Lambda or a CI job, and context.attempt increments so you can tell. 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. An answered human-input gate is never re-asked.