berthdocs

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 forStored inSees
Any project on a box~/.berth/flows.json on the boxEvery event on that box. Narrow it with where.location.
A project on a boxThe box's own config for that locationOnly that repository's events. Not committed.
A repository, everywhereflows in its committed .berth/config.jsonEvery box with the repository. Read-only in the app: change it in the repository.
A project's kitflows in the kit's configEvery box the kit is applied to. Read-only in the app: change it in the kit.
Any project on every boxA copy in each box's own config for the projectSee 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. With where.branch set, only worktrees on a matching branch, and the main checkout too if its branch matches.
  • A box flow without where.location runs 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:

onStarts for
review_commentA comment on the PR, or on a line
pr_reviewA submitted review
check_failedA check that fails, times out or needs action
pr_mergedThe 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

KindDoesFields
runRuns a command through a login shell in the event's worktree, with its environmentcommand, timeout (10m)
promptTypes a prompt into the agent that triggered the flow, or sessiontext, session
waitWaits for that agent's turn to endfor (a list: ["finished"], ["waiting"]; default both), session, timeout (30m)
start_agentStarts an agent in the worktree, or in a new oneagent, text (its prompt), new_worktree, name
notifyShows a notification in the apptitle, text
webhookPOSTs JSON somewhere, e.g. Slackurl, 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 run step fails when its command exits non-zero; a webhook step when the answer's status is 300 or more (its exit code is the status).
  • A wait step fails if the agent's program exits.
  • After start_agent, later prompt and wait steps talk to the new agent. With new_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:

VariableMeaning
{{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_hour times (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>", and flow.started and flow.finished never start flows.
  • before: gates check requests to the box's API, so they apply to Berth commands a run step runs (berthd session send, berthd exec, …). A flow's own prompt and start_agent steps 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.

On this page