The descriptor (workflow.jsonc)
Every workflow package ships a workflow.jsonc at its root: the deployment policy the control plane must know without running your code (schedules to register, webhooks to verify, the secret allowlist and budget to enforce around the run). It is JSON with comments and trailing commas; plain workflow.json is also accepted, and comments are stripped on parse, never stored. The schema is strict: an unknown field is a validation error (boardwalk check catches it before deploy), so it can never silently drift.
Your behavior and your I/O contract stay in code: the descriptor does not describe your input or output (that's your runfunction's signature) and it names no model (every agent() call picks its own).
{
"$schema": "https://boardwalk.sh/schemas/workflow.json",
"slug": "morning-digest",
"title": "Morning Digest",
"description": "Summarize open issues every weekday at 9am.",
"triggers": [{ "kind": "cron", "expr": "0 9 * * 1-5", "timezone": "America/New_York" }],
"permissions": { "secrets": [{ "name": "GITHUB_TOKEN" }] },
"budget": { "max_usd": 1 },
}Fields
| Field | Required | What it declares |
|---|---|---|
slug | yes | The workflow's identity: lowercase letters, digits, and hyphens, stable across versions. |
title | no | A human-readable display label for the dashboard. |
description | no | One line for the dashboard and your teammates. |
entry | no | The package-relative file exporting run. Defaults to src/index.ts for TypeScript and main.py for Python; the language is inferred from the entry's extension. |
triggers | yes | At least one trigger: manual, cron, webhook, workflow_run, or a provider event (github, linear, jira, notion). See Triggers. |
env | no | Environment variables for the run. Values are plaintext or a whole-value secret reference, ${{ secrets.NAME }}. |
workspace | no | { persist: ["cache"] }keeps those directories of the run's working directory between runs (per environment); { persist: true }keeps all of it. Omitted, it's scratch. key splits that state into separate workspaces. See Workspace & state. |
recording | no | Session recording is on for every hosted run; set false to turn it off for the whole run. |
budget | no | Cost caps. A breach pauses the run for approval, never a hard kill. See below. |
concurrency | no | How many runs of this workflow may run at once. Default: unlimited. See below. |
runs_on | no | The machine. Default boardwalk/linux. See Runners. |
notifications | no | Email or webhook on completion, failure, cancelled, or budget_exceeded. |
permissions | no | What a run may do: its API token scope, OIDC, artifacts, and the secret allowlist. See below. |
callable_by | no | Who may invoke this workflow (e.g. other workflows in the org). Default: anyone in the org. |
egress | no | Outbound network policy: { level: "none" }, { level: "full" }, or { level: "custom", allow: [...] } with a host allowlist. Default: full (reach any public host; requests still pass through a controlled egress proxy). Enforced on hosted runs, where platform endpoints stay reachable regardless; not enforced on self-hosted runners. |
container | no | A custom OCI image to run the workflow's tools inside (hosted). |
files | no | The allowlist of non-code assets that ship in the package, as glob patterns. See below. |
Derived I/O schemas
The descriptor holds no input_schema or output_schema fields. The deploy derives both from your runfunction's type annotations and stores them on the manifest, so the dashboard's input form, webhook senders, and workflows.call callers all know the shape without you writing a schema by hand. An unannotated run(input) derives nothing and receives the raw JSON. See Typed input and output.
runs_on
Almost every workflow uses the default, boardwalk/linux: a Linux machine with Node, Python, git, and common CLIs preinstalled. For more CPU and memory, ask for a larger machine:
"runs_on": { "label": "boardwalk/linux", "size": "large" }, // 4 vCPU / 8 GiBbudget
Cost caps, all optional and all metered. A breached cap pausesthe run: it parks with its state intact and waits for approval, and approving resumes it from exactly where it stopped. Nothing is hard-killed and nothing is silently truncated, so a guardrail set too tight costs a click, not the run's progress.
"budget": {
"max_usd": 5, // pause if the run's inference cost passes $5
"max_tokens": 2000000, // ...or this many tokens
"max_compute_seconds": 3600, // ...or this much active compute
},max_compute_seconds caps active compute: time the run is actually executing. A long sleep, a human-input gate, or a wait on a child run does not count against it, so a workflow that waits hours for an approval is never paused for being slow. There is no wall-clock deadline: idle waiting is free, so there is nothing to cap. A program can read its own live budget state with usage.get() and wind down gracefully before the pause.
concurrency
By default runs overlap freely. Serialize them when a workflow mutates shared state, either globally or per key, so one customer, repo, or tenant runs one at a time:
"concurrency": { "mode": "serial" },
// or keyed, one run per resolved key:
"concurrency": { "mode": "serial", "key": "refund-${input.customerId}" },The keyis a template over the run's input (${input.<path>}), resolved at run creation as pure data access; no tenant code runs. Two runs whose keys resolve to the same string are serialized; different keys proceed in parallel.
Give the key a fallback for the deliveries that legitimately lack the field, or each one fails the run instead of picking a lane. A GitHub App's ping and installation events carry no repository at all:
"key": "${input.repository.full_name ?? 'none'}",The literal is used when the path is missing or null. Single quotes are the ergonomic choice inside JSON; double quotes work but need escaping.
latest_wins is the same one-at-a-time lane, with a different queue discipline: a new run replaces the runs still waiting in its lane rather than queueing behind them.
"concurrency": { "mode": "latest_wins", "key": "${input.repo}" },Reach for it when the work is level-triggered and idempotent (rebuild this repo, re-sync this customer): a burst of twenty events otherwise runs twenty times, nineteen of them against state the next event already invalidated. A run that is already executing is never touched, so nothing in flight is thrown away; the waiting runs end as cancelled with a reason on the run.
permissions
A run executes with a least-privilege token. permissions raises or lowers what it can do, modeled on GitHub Actions:
"permissions": {
"contents": "read", // the run's API token: "read" (default), "write", or "none"
"artifacts": "write", // read/write/none for run artifacts
"id_token": "write", // mint an OIDC token (for cloud federation)
"secrets": [{ "name": "GITHUB_TOKEN" }], // allowlist of names the program may secrets.get()
},permissions.secrets is the allowlist of secret names the program may secrets.get(): see Secrets & environments. The injected token is always ceilinged below admin, so a workflow can never escalate past member-level access to your org. id_token: "write" lets the program mint a per-run OIDC id-token with auth.idToken() and federate into your own cloud without stored keys: see Cloud access (OIDC).
files
You never list source files: the code that ships is whatever your entry imports (the bundler computes it for TypeScript; the source tree ships for Python). filesdeclares the non-code assets the runtime needs but can't reach via import:
"files": ["prompts/**", "data/seed.json"],It is an allowlist, not an ignore-list, because packaged files are stored on the control plane and shown in the web Code tab to your whole org; a denylist leaks credentials the day someone drops a .env next to the entry. skills/** and README.md are always included, so most workflows set no files at all. Regardless of any glob, node_modules, .git, .env*, and dotfiles are never packaged.