berthdocs

Guides

Hooks and gates

Run your commands when something happens, or refuse an action before it does.

Hooks run your commands when something happens in Berth. They run on the machine whose ~/.berth/hooks.json names them: hooks on a box run there, next to its repositories; hooks on the laptop run on the laptop, where they can notify you, open things, or call other tools.

~/.berth/hooks.json
{
  "hooks": [
    { "on": "agent.waiting", "run": "osascript -e 'display notification \"$BERTH_PATH\" with title \"An agent needs you\"'" },
    { "on": "worktree.created", "run": "cd \"$BERTH_PATH\" && pnpm install", "timeout": "10m" },
    { "on": "before:worktree.create", "run": "case \"$BERTH_NAME\" in wip-*) echo 'no wip- worktrees'; exit 1;; esac" }
  ]
}

The file is read again for every event, so edits apply immediately. The app edits it too: Automations → Advanced: shell hooks has a table per machine, templates to start from, and a live feed of events with New hook for this event.

Two kinds

  • After the fact: on is an event type (agent.finished), a prefix (worktree.*), or *. These run one at a time, off the critical path. They cannot change what happened, and their failures are only logged.
  • Gates: on is before: and an action (before:worktree.create, before:*). They run first, in order, with a 30 second default timeout. A gate that exits non-zero stops the action, and whatever it printed is the error the app, the CLI or the agent sees. Gates run on the box; a before: hook in the laptop's file never runs.

Where hooks live

WhereRunsFor
~/.berth/hooks.json on the laptopOn the laptopLaptop events and every online box's events, relayed
~/.berth/hooks.json on a boxOn the boxThat box's events and gates
hooks in a repository's .berth/config.jsonOn the box, in the worktreeOnly that repository's events and gates, with the worktree's environment
A plugin's berth-plugin.jsonWhere the plugin is installed, in its folderAs above, while the plugin is on

What a hook gets

Each hook runs through /bin/sh -c and gets:

  • The event as JSON on stdin: {"type":"worktree.created","time":"…","box":"devl","origin":"app","data":{…}}
  • The same as environment variables: BERTH_EVENT, BERTH_EVENT_BOX, BERTH_EVENT_ORIGIN, and every data field upper-cased, as BERTH_PATH, BERTH_NAME, BERTH_LOCATION, BERTH_BRANCH, …
  • BERTH_ORIGIN, which Berth commands the hook runs pass on, so their own events are attributed to the hook rather than to you.
  • Hooks from a repository's config also get the worktree's environment (its ports and BERTH_* variables, and the project's env); plugin hooks get BERTH_PLUGIN_DIR.

Fields

FieldMeaning
onAn event type, a prefix like worktree.*, *, or before: and an action
runThe command
timeoutA Go duration: default 1m, or 30s for gates
toolAn integration's name. The hook never runs for events whose origin is that tool, which stops two integrations from bouncing events back and forth.

Events

The full list, with each event's data, is in events and gates. The ones you'll want first:

EventWhen
agent.waitingAn agent needs you: a permission, a question
agent.finishedAn agent is done with its turn
worktree.created, worktree.removedA worktree appeared or went
worktree.setup.failedIts setup script failed
task.createdA worktree with an agent in it
box.connected, box.disconnectedLaptop only

Box events reach the laptop too: the laptop agent relays every online box's events, with box set, so a laptop hook on agent.waiting hears about every agent on every box.

Agent events come from the agents' own hooks. berthd integrations install claude (or codex, cursor, all) on a box sets them up; the same command with berth does it on a laptop.

Anything can announce its own events:

berth emit deploy.finished url=https://…        # on the laptop
berthd emit deploy.finished url=https://…       # on a box

Gates

GateData
before:location.addlocation, path, url
before:worktree.createlocation, name, branch, base
before:worktree.removelocation, name, path
before:task.createlocation, name, branch, base, agent, command
before:session.startname, location, path, command
before:session.sendname, and from: "phone" from the phone
before:execlocation, path, command
before:kit.installlocation, kit, source
before:kit.removelocation
before:config.changewhat changes: a location's config, flows, env or the guard

There are a few more; see events and gates. A task runs before:task.create, then the worktree's and the session's own gates.

Testing a hook

berth events                                   # watch what arrives, live
berthd emit worktree.created path=/tmp/x name=x  # fire one on a box

A gate's output is what people see when it refuses, so make it a sentence.

For anything with more than one step (run the tests, send failures back to the agent, notify you), use a flow instead.

On this page