Guides
Automations and flows
When something happens, do these steps. Flows run on the box, on events, schedules or GitHub activity.
Automations in Berth are flows: when something happens, do these steps. They run on the box, so they keep going while your laptop sleeps.
{
"id": "check-after-turn",
"name": "Run tests after every agent turn",
"enabled": true,
"trigger": { "event": "agent.finished", "where": { "location": "cal" } },
"steps": [
{ "id": "test", "kind": "run", "command": "pnpm test --changed", "timeout": "15m" },
{ "kind": "prompt", "when": "failure",
"text": "The tests failed (exit {{prev.exit_code}}):\n\n{{steps.test.output}}\n\nFix them." },
{ "kind": "notify", "when": "success", "title": "{{worktree.name}} passes its tests" }
]
}Off until enabled
A flow runs only with "enabled": true. The app's switch sets it.
In the app
Automations in the sidebar has three tabs: Flows, Runs and Advanced: shell hooks. New flow, or one of the templates, opens the editor:
- Runs for: where the flow lives (below).
- Starts: On an event, On a schedule or On GitHub, narrowed by agent and branch.
- Steps: added from Add a step, each running On success, On failure or Always. Text fields offer the variables that fit.
- At most N runs an hour: empty means the box's default, 20.
- Test run: runs the saved flow now, for real, in a worktree you pick, and shows each step's result and output.
The templates are a good start: Test after every agent turn, Notify when an agent needs me, Codex reviews Claude's work, Nightly rebase and tests, PR comments to the agent, and Post to Slack when setup fails.
Where a flow lives
| Runs for | Stored in | Sees |
|---|---|---|
| Any project on a box | ~/.berth/flows.json on the box | Every event on that box. Narrow it with where.location. |
| A project on a box | The box's own config for that location | Only that repository's events. Not committed. |
| A repository, everywhere | flows in its committed .berth/config.json | Every box with the repository. Read-only in the app: change it in the repository. |
| A project's kit | flows in the kit's config | Every box the kit is applied to. Read-only in the app: change it in the kit. |
| Any project on every box | A copy in each box's own config for the project | See below. |
A repository's flows only ever see that repository's events.
A repository's flows layer like the rest of its
config: committed, then the kit's, then
the box's own. A flow with the same id in a later layer replaces the earlier
one, and only the replacement runs. Override on box in the editor of a
committed or kit flow does this: it copies the flow into the box's own config,
where you can change it or switch it off.
Any project on every box
For a project checked out on two or more online boxes, Runs for offers Any project: the same flow on every box that has it. Boxes keep their own flows, so Berth writes one copy into each online box's own config for the project and shows the copies as one flow while they match:
- Its switch turns every copy on or off, and its last run is the latest on any box. A run shows which box it ran on.
- Each copy runs on its own box, with its own runs-per-hour budget.
- Boxes that get the project later, or are offline when you save, don't get a copy. Save again once they are online.
- A copy edited on one box (the editor warns you, and offers Edit all copies) no longer matches; it then shows under that box as a flow of its own.
- Delete removes every copy on the boxes that are online.
- A test run runs on the first box.
Triggers
On an event
event is any event type from the catalog, or a prefix
like worktree.*. before: gates can't start flows, and flow.* events
never do. where narrows it: location, agent (claude, codex), and
branch (a trailing * matches a prefix).
On a schedule
{
"id": "nightly",
"name": "Nightly: rebase every worktree and run tests",
"enabled": true,
"trigger": { "schedule": "0 2 * * *", "each_worktree": true, "where": { "branch": "sean/*" } },
"steps": [
{ "id": "rebase", "kind": "run", "command": "git fetch origin && git rebase origin/main" },
{ "kind": "run", "when": "failure", "command": "git rebase --abort" },
{ "kind": "run", "command": "pnpm test", "timeout": "20m" },
{ "kind": "notify", "when": "failure", "title": "{{worktree.name}}: tests fail after rebasing" }
]
}schedule is a cron expression (minute hour day month weekday, with *,
*/15, 1-5 and lists) or a shortcut (@hourly, @daily, @midnight,
@weekly, @monthly, @yearly), in the box's local time. When both day
fields are restricted, either one matching runs it, as in cron.
- A scheduled flow runs once, in the repository's main checkout.
- With
each_worktree, it runs once for every worktree instead. Withwhere.branchset, only worktrees on a matching branch, and the main checkout too if its branch matches. - A box flow without
where.locationruns once in the box user's home.
Runs carry a schedule.fired event with schedule and time.
On GitHub
{ "trigger": { "github": { "on": "review_comment", "poll": "2m" } } }The box checks each covered worktree's pull request with gh (installed and
signed in on the box) every poll (at least 1m, default 2m), and starts the
flow for what is new:
on | Starts for |
|---|---|
review_comment | A comment on the PR, or on a line |
pr_review | A submitted review |
check_failed | A check that fails, times out or needs action |
pr_merged | The PR being merged |
The first look only records what is already there, so turning a flow on does
not replay a PR's history. Each new item is its own run, one after another,
carrying a github.<on> event (github.review_comment, …) with pr, url,
title, author, body, file, line, check and state as its data. Main checkouts and worktrees on the default branch
have no PR to watch. A box without gh simply never starts these flows. One
poll makes at most 20 gh calls, shared between flows.
Steps
| Kind | Does | Fields |
|---|---|---|
run | Runs a command through a login shell in the event's worktree, with its environment | command, timeout (10m) |
prompt | Types a prompt into the agent that triggered the flow, or session | text, session |
wait | Waits for that agent's turn to end | for (a list: ["finished"], ["waiting"]; default both), session, timeout (30m) |
start_agent | Starts an agent in the worktree, or in a new one | agent, text (its prompt), new_worktree, name |
notify | Shows a notification in the app | title, text |
webhook | POSTs JSON somewhere, e.g. Slack | url, text (the body; default every variable), timeout (15s) |
- Each step runs when the previous one succeeded (
when: "success", the default), failed ("failure"), or"always". - A
runstep fails when its command exits non-zero; awebhookstep when the answer's status is 300 or more (its exit code is the status). - A
waitstep fails if the agent's program exits. - After
start_agent, laterpromptandwaitsteps talk to the new agent. Withnew_worktree, the worktree branches from the current one's branch. - A flow fails when a step fails and no later step handles failure.
- No step runs longer than two hours.
Variables
Text fields take:
| Variable | Meaning |
|---|---|
{{event.FIELD}} | Any field of the event's data, like {{event.agent}}; also {{event.type}}, {{event.box}}, {{event.origin}} |
{{worktree.name}}, {{worktree.path}}, {{worktree.branch}}, {{location}} | Where it runs |
{{prev.output}}, {{prev.exit_code}} | The previous step's |
{{steps.ID.output}}, {{steps.ID.exit_code}} | A named step's (unnamed steps are 1, 2, …) |
{{agent.state}} | After a wait |
{{now}} | The time |
Output is the last 4000 characters. An unknown variable is empty.
Safety
- A flow that is already running for a worktree is not started again for it.
- Each flow runs at most
max_runs_per_hourtimes (default 20); test runs count too. A flow that prompts the agent whose finishing started it would otherwise run forever. - Events a flow causes carry
origin: "flow:<id>", andflow.startedandflow.finishednever start flows. before:gates check requests to the box's API, so they apply to Berth commands arunstep runs (berthd session send,berthd exec, …). A flow's ownpromptandstart_agentsteps don't go through them.
Runs
The box keeps the last 200 runs with each step's status, duration and output. The Runs tab shows them across boxes, with what started each. Over the API:
GET /v1/flows/runs?flow=ID&limit=N newest first (default 50, at most 200)
POST /v1/flows/{id}/test {"scope": "repo:cal", "data": {"path": "<a worktree>"}}A test runs the flow now, as if its trigger had happened there, and returns the finished run. The rest of the flows API is in the app's API.
Shell hooks
For a single command on an event, a hook is still the simplest thing; see hooks and gates. Flows are for anything with more than one step.