bees is a single binary. Most commands need a bees.toml; it is found with
--config, then $BEES_CONFIG, then by searching upwards from the current
directory.
Four commands — bees mail send, bees issue create, bees issue link and
bees done — exist for the sessions inside the factory, though people can use
them too. A session normally reaches the same operations as MCP tools, along
with the GitHub operations every role performs:
bees mcp serve serves them, and every session
gets it automatically. Everything else is for people.
Global flags
| Flag | Description |
|---|---|
-c, --config <path> |
Path to bees.toml. Default: $BEES_CONFIG, else search upwards from cwd. |
-v, --verbose |
Debug logging (same as --log-level debug). With run/tick/exec, also streams every session event to stderr — except under the live view, which owns the terminal; bees run --no-tui streams as before. |
-q, --quiet |
Console shows only session summaries, warnings and errors. Cannot be combined with -v or --log-level debug. |
--log-format <text\|json> |
Console log format. Default text; $BEES_LOG_FORMAT, then logging.format. |
--log-level <debug\|info\|warn\|error> |
Console log level. Default info; $BEES_LOG_LEVEL, then logging.level. |
-h, --help |
Help for any command. |
A flag beats its environment variable, which beats the
[logging] table in bees.toml, which beats the
default. An unknown value is an error naming the valid ones.
With the live view up — bees run in a terminal — the
console shows nothing at all: these flags describe the console, and
<state_dir>/bees.log gets every record whatever they say. --no-tui turns
the view off.
Setting up
bees init
Creates bees.toml in the current directory (which must be a git clone of the
project), creates the state directory, adds it to the repository’s .gitignore
(unless git check-ignore says it is already ignored, or it lives outside the
clone) and prints a reminder to commit that, and creates the workflow labels in
the GitHub repository. Refuses to overwrite an existing file. bees.toml is
meant to be committed.
init validates before it writes: the current directory must be a git clone; the
configuration it is about to write must parse and resolve to a repository and a
default branch; and, when --github-login/--github-token gave the factory an
account of its own, GitHub must accept that token, the token must belong to
that login, and the account must be able to read the repository. init prints
the login it will act as (acting on GitHub as busybees-bot); creating the
labels is that token’s first real job. A value you stated, or init really
detected, is written as an active setting; one it could only guess stays a
commented placeholder, so init fails rather than write a default branch nobody
confirmed. A failed init leaves no bees.toml behind and the directory exactly
as it was: fix what the error reports and run init again. The one step that can
fail after the local files exist is creating the labels; the error then says to
run bees labels sync, not init again.
Last, init runs the full bees doctor, the expensive per-role
checks included — this is where a wrong skill URL or an unreachable MCP server
is worth waiting for — prints the table and points at bees doctor. A check
that fails does not make init exit non-zero: bees.toml and the labels are
written by then, and the table is the list of what is left to set up.
| Flag | Description |
|---|---|
--remote name |
Git remote the factory pushes to (default origin). |
--repo owner/name |
Write project.repo as an active setting, and project.default_branch too when it could be detected from the remote. By default both are derived from the remote at run time and only appear as commented placeholders showing the detected values. |
--default-branch <name> |
Write project.default_branch as an active setting, as given: no detection, no check against the remote. Use it when the branch cannot be detected (a remote that cannot be reached), which is otherwise what makes init fail. |
--label <name> |
Visibility label (default bees). |
--assignee <login> |
Only see items assigned to this login; @me for yourself. |
--github-login <login> |
Write github.login: the GitHub account the factory acts as. Needs --github-token. |
--github-token <token> |
Write github.token. Pass '$VAR' (quoted, so the shell leaves it alone) to keep the secret out of bees.toml and read it from the environment instead. Needs --github-login. |
--template <name> |
Write the bees.toml a named config template describes instead of the default file: the eight settings that template decides are written as active lines, under a comment naming it. An unknown name is an error and nothing is written. |
--print |
Print the template to stdout instead of writing it. Writes nothing, so it works outside a git clone. |
--no-labels |
Skip creating GitHub labels. |
The default file lists every option; optional ones are commented out with
their default values (#max_developers = 1), so configuring is a matter of
uncommenting and editing lines. See configuration.md.
cd ~/src/my-project
bees init
bees init --assignee @me --label kyle-bees
bees doctor
Runs the preflight checks the factory otherwise only discovers mid-run, and prints what it found grouped by area:
| Group | Checks |
|---|---|
toolchain |
git on PATH; gh on PATH, authenticated and holding the repo token scope; claude (or $BEES_CLAUDE_BIN) runnable and new enough; codex (or $BEES_CODEX_BIN) runnable, when a role is configured with agent = "codex"; opencode (or $BEES_OPENCODE_BIN) runnable, when a role is configured with agent = "opencode". |
config |
bees.toml loads and validates; project.repo and project.default_branch are set or derivable; the remote answers; the state directory is ignored by git; the notes directory is writable; the sessions directory is writable, when a role is configured with agent = "opencode"; every configured prompt_file exists; the repository’s bees/prompts/ files are all readable and named after a role; a running scheduler is serving a build of the commit that is checked out. |
github |
The repository is readable and writable (viewerPermission); with [github] set, that github.token belongs to github.login; every workflow label exists; with [github] set, that the account can actually write issues, issue comments and labels; with [github] set, that the account can actually push branches; the visibility filter matches at least one open issue; with auto_merge on, what a merge is actually gated on. |
workspace |
A worktree can be created under workspace_root and removed again. |
roles |
Per role: every configured skill URL clones and produces a plugin directory; every configured MCP server starts and answers an initialize request within 15s; a configured shell can be executed. |
A failure (✗) means the factory cannot run: a missing tool, a repository it
cannot push to, missing workflow labels. A warning (!) means something that
will probably bite you but does not stop a session: a state directory that is
not git-ignored (notes and transcripts would be committed), a filter that
matches no open issue (usually a misconfigured label or assignee), a Claude
Code older than bees expects, a running scheduler older than the repository it
is building.
The filter check tells the two empty cases apart: when nothing matches the
filter but open issues or pull requests carry the base label, it reports both
counts and spells the filter out (0 match your filter (label=bees AND
assignee=kyle)). That is a filter criterion hiding work the factory already
owns, not an empty repository, and the fix is bees doctor --fix (below) or
unsetting the criterion in bees.toml.
The auto-merge check is a warning of the same kind: with
roles.reviewer.auto_merge on and no check required on the default branch,
bees gates a merge on whatever checks the pull request happens to report.
Requiring your CI checks in the branch protection rules is the fix, and
leaving it alone is a legitimate choice. bees never enables or edits branch
protection itself, and the check is silent when auto_merge is off.
The scheduler build check is the one to read after merging a change to a role
prompt. The prompts are compiled into the binary, so a running bees run
keeps serving the ones it was started from: the check reads the revision the
scheduler recorded in status.json and asks git where it sits relative to
HEAD, and warns when it is behind it, is not an ancestor of it, or is a
commit this repository has never seen. The repair is to rebuild and restart
the factory, which doctor will not do to a running one — hence a warning,
never a failure, and never --fix. It compares against what is checked out
and never asks the remote, so doctor works offline. It passes and says so
whenever the question does not arise: no scheduler has run, none is running
now (a status.json outlives the run that wrote it), or the binary carries no
revision to compare — a release build, or one built from a tree with no VCS
stamps.
The three [github] checks answer what
viewerPermission cannot, and all three are silent — a pass saying so — when
the table is unset, because there is then no configured account to check.
The first compares the login github.token actually authenticates as with
github.login, and reports a mismatch by name. github.login is what tells
the factory’s own comments from a person’s, so a login naming an account other
than the one posting means a person’s comments are read as the factory’s own
and answered by nobody. A [bot] suffix is never stripped or added — it
belongs in bees.toml exactly when GitHub uses it — so a user token whose
login was written with the suffix is an ordinary mismatch, named in the
detail, and it fails.
The second establishes that the account can write what bees writes. Repository
permission does not imply it: a fine-grained token carries per-resource
permissions on top of the repository role, so ADMIN and “cannot create an
issue” are an ordinary pair — and then every issue_create, comment and label
edit in every session fails, one session at a time. The probe is a no-op
update of the base label, renaming it to the name it already has: a real
write, so GitHub’s permission gate is what answers it, and it changes nothing
and leaves nothing behind. A fine-grained token’s Issues grant governs
issues, issue comments and labels alike, so the cheapest of them answers for
the others, and the remediation names what to grant.
The third establishes the sibling permission: that the account can write the
repository’s git refs, which is what every developer session’s git push
needs. The probe is a no-op ref update — the default branch is read and then
set to the commit it already points at. It changes nothing: no commit, no push
event, nothing in the timeline and nothing to clean up. A protected branch
refuses the update for a reason that is not permission, so that answer is a
warning rather than a failure. Pull requests is a separate fine-grained
permission and is deliberately not probed — every side-effect-free candidate
perturbs something, and the Contents refusal is the earlier failure anyway,
since a session that cannot push never reaches gh pr create — so the
remediation names both grants together.
Every warning and failure prints the command that fixes it on the next line;
doctor changes nothing unless --fix is given.
doctor exits 1 when a check failed and 0 when only warnings are present, so it
can gate a deploy. Checks that need something that is missing are left out
rather than reported twice: without a bees.toml only the toolchain checks
run, and the GitHub and workspace checks need a resolved repository.
The roles group is the expensive half: it clones skill repositories and
starts MCP servers, which takes seconds to minutes on a cold cache.
bees doctor and bees init run it; the bees run preflight does not (see
bees run). Each role reports one line per thing it configures,
named after the role (developer skills, qa mcp), so the table says which
role is broken rather than that something is. A role that configures none of
the three still gets a line, and a role with enabled = false is reported as
disabled rather than dropped silently. The skills are cloned into the cache a
session uses ($BEES_CACHE_DIR, else the user cache directory: ~/.cache/bees
on Linux, ~/Library/Caches/bees on macOS), so doctor warms it instead of
duplicating the work.
$ bees doctor
toolchain
✓ git /usr/bin/git (git version 2.50.1)
✓ gh authenticated logged in as kyle, token scopes: gist, read:org, repo
✓ claude runnable claude 2.1.251 at /usr/local/bin/claude
config
✓ bees.toml valid /home/kyle/src/proj/bees.toml (version 1)
✓ project repo kyle/proj, default branch main (remote "origin")
✓ remote reachable origin answers
! state dir ignored .bees is not ignored by git
→ add "/.bees/" to .gitignore: notes, mail and session transcripts would be committed otherwise
✓ notes dir writable /home/kyle/src/proj/.bees/notes
✓ prompt files exist no prompt_file configured
✓ project prompt files no bees/prompts/ directory
! scheduler build is current running 4773767e3b1a, which is 6 commits behind HEAD (9f56e8a2c104)
→ rebuild and restart `bees run` to pick up prompt and code changes: the role prompts are compiled into the binary
github
✓ repo readable and writable kyle/proj (ADMIN)
✓ github.login matches token github.token belongs to proj-bot
✗ workflow labels 2 of 17 missing: bees:size/l, bees:size/xl
→ run `bees labels sync`
✓ can write issues proj-bot can write issues, issue comments and labels in kyle/proj
✓ can push branches proj-bot can update main in kyle/proj
✓ filter matches issues 12 open issues matching label bees
✓ auto_merge check gate auto_merge is off: people merge pull requests themselves
workspace
✓ worktree created and removed one under /tmp/bees
roles
✓ product_manager enabled, no skills, MCP servers or shell configured
✓ project_manager enabled, no skills, MCP servers or shell configured
✓ developer skills 1 skill ready: https://github.com/acme/skills#skills/tdd
✗ developer mcp sentry: fork/exec /opt/sentry-mcp: no such file or directory
→ start the server by hand or fix [roles.developer.mcp] in /home/kyle/src/proj/bees.toml: a session that cannot reach it loses those tools
✓ reviewer enabled, no skills, MCP servers or shell configured
✓ qa disabled (roles.qa.enabled = false)
25 checks: 21 passed, 2 warnings, 2 failed
| Flag | Description |
|---|---|
--json |
Print the results as JSON (name, group, status, detail, remediation) instead of the table. |
--fix |
Apply the repairs doctor knows how to make, then re-run the checks. |
bees doctor --fix
--fix runs the checks, applies the repairs doctor knows how to make for the
ones that did not pass, prints one line per action and then re-runs every
check, so the table is what the repository looks like afterwards and the exit
code follows the repair: --fix exits non-zero only if a check still fails.
Checks doctor cannot repair are untouched, and their remediation line still
says what to do by hand.
Exactly one repair exists today: the filter check. It lists the open issues
and pull requests carrying the base label (filter.label) that do not match
the rest of the filter, and adds filter.assignee and, when one is
configured, filter.milestone to each. That is the repair for the failure
this exists to catch: adding assignee = "@me" to a factory that has been
running for weeks takes every issue nobody ever assigned out of the factory’s
view in one commit.
$ bees doctor --fix
fixing filter matches issues
assigned issue #92 to kyle
assigned issue #119 to kyle
assigned pull request #148 to kyle
! issue #131: assign to kyle: gh: HTTP 403 (forbidden)
...
✓ filter matches issues 12 open issues matching label bees + assignee kyle
What it will not do:
- It never touches an item that does not carry the base label. That is the
safety rule that makes bees usable in a repository shared with people, and
it is enforced on selection and again per item before any write. Selection
is on the label alone, never on who wrote the issue: a feature issue a
person filed with the
beeslabel and no assignee is adopted exactly like one the factory created. - It never adds or removes a label, and it never edits
bees.toml.--fixmoves items into the filter; deciding what the filter should be is yours. - With
filter.require_label = falseit does nothing at all and says so in one line. Without a base label there is no way to tell the factory’s work from everyone else’s, and “assign every issue in the repository” is not a repair. If you run with an assignee-only filter, bring items into it by hand. - One item it cannot repair is reported on its own line and does not stop the others.
bees labels sync
Creates or updates every workflow label in the repository (idempotent), forcing
the factory’s colour and description on the labels that already exist. Run it
after changing filter.label. Labels that are merely missing need no sync:
bees run creates them at start.
bees labels list
Prints the label names and what each one means.
bees skills list
Prints the skill repositories configured for the enabled roles: the cache
directory and the refresh policy on the first line, then one line per reference
with its commit (or not cached), how long ago it was fetched, the roles that
use it and the reference itself. Reads the cache only — no session, no GitHub.
$ bees skills list
/home/kyle/.cache/bees (refresh: 24h)
9f1c0aa 3h ago developer,reviewer https://github.com/acme/skills#skills/tdd
not cached - qa https://github.com/acme/qa-skills
bees skills update
Clones what is missing and pulls everything else right now, whatever
skills_refresh says. With no argument (or --all) it updates every
configured reference; arguments must match a configured reference verbatim.
$ bees skills update
updated https://github.com/acme/skills#skills/tdd 9f1c0aa → 2b7d431
unchanged https://github.com/acme/qa-skills 4c19e02
A reference that fails prints failed <ref>: <error> and the command exits
non-zero after trying the rest. Pinned references (@v1.2.0) are detached
checkouts and cannot be pulled; that failure is expected.
bees config validate
Loads bees.toml and reports errors (missing or unsupported version, unknown
keys, bad repo, invalid MCP server, …).
bees config migrate
Rewrites bees.toml to the current format version (see
version), keeping the original as
bees.toml.v<old>.bak. Prints “already version N” when nothing needs doing.
bees run, tick, exec, status, issue create and issue link run the
same migration automatically on startup.
bees config show [role]
Prints the resolved configuration as JSON: project, filter, github, scheduler
and — for every role, or the one given — the effective prompt, skills, MCP
servers, model, fallback model, limits, sandbox and enabled after merging
[global] with [roles.<name>]. The global-only skills_refresh is printed
under every role, since it governs how each role’s skills are refreshed. github.token is
never printed resolved: a "$VAR" value is shown as written and anything else
as "(set)".
The JSON keys are the bees.toml key names, so you can match what is printed
against what you wrote, and durations print as duration strings ("45m0s").
The role-specific keys appear on the role that owns them: the reviewer carries
its merge policy (auto_merge, merge_method, checks_wait,
checks_poll_interval, checks_timeout, max_check_fix_rounds) and its
resolved angles, brief_model, judge_model and angle_models, and the developer its commit_flags, max_size,
model_by_size and the best-of-N and mixture-of-experts keys.
bees config show
bees config show developer
{
"path": "/src/widgets/bees.toml",
"version": 2,
"filter": { "label": "bees", "require_label": true, "assignee": "@me", "milestone": "", "creator": "" },
"github": { "login": "busybees-bot", "token": "$BEES_GITHUB_TOKEN", "git_name": "", "git_email": "" },
"scheduler": { "poll_interval": "5m0s", "max_developers": 1, "max_review_rounds": 3, "...": "" },
"roles": {
"reviewer": {
"name": "reviewer",
"model": "opus",
"fallback_model": "sonnet",
"max_turns": 200,
"timeout": "45m0s",
"enabled": true,
"sandbox": "none",
"angles": {
"xs": ["quick_general", "docs"],
"s": ["quick_general", "docs"],
"m": ["general", "docs", "test_coverage", "acceptance_criteria"],
"l": ["general", "docs", "test_coverage", "acceptance_criteria"],
"xl": ["general", "docs", "test_coverage", "acceptance_criteria", "side_effects"]
},
"brief_model": "",
"judge_model": "",
"angle_models": {},
"auto_merge": false,
"merge_method": "squash",
"checks_wait": "1m0s",
"checks_poll_interval": "2m0s",
"checks_timeout": "30m0s",
"max_check_fix_rounds": 2
}
}
}
bees templates list
Prints every config template: the name, then a one-line summary
of how it runs the factory. Reads no bees.toml and no git remote.
$ bees templates list
contributor one contributor among many: builds the issues in its filter, a person merges
issue-driven a person writes feature issues, the full staff builds them, a person merges
slop-factory the full staff with auto-merge on and no approval gate on proposed features
reviewer reviews pull requests other people open and builds nothing
planner the two managers scope and break down issues, nobody builds
bees templates show <name>
Prints the bees.toml the template writes, which is the file
bees init --template <name> would create, headed by a comment block naming
the template and saying who it is for. Reads nothing either, so it works
outside a git clone and before there is anything to configure. An unknown name
is an error listing the names that exist.
bees templates diff [name]
Reports the settings this project’s config and a template disagree on: the key, what the config resolves it to, and what the template sets. With no name, the template the config is closest to is reported, which places a project set up without one. Differences are reported, not gated, so the exit status is 0 either way.
$ bees templates diff
/src/widgets/bees.toml vs issue-driven (closest of 5 templates)
roles.reviewer.auto_merge true (issue-driven: false)
The comparison is on the resolved values rather than the file text, so a key left commented out compares equal to a template that sets it to the same default. Only the eight settings a template decides are compared; repo, filter, models, budgets and intervals are not.
$ bees templates diff contributor
/src/widgets/bees.toml matches contributor.
bees prompts show <role> [--rendered]
Without --rendered, prints the role’s built-in base prompt (the part busybees
ships). With --rendered, prints the full system prompt the role would receive
for this project: common preamble, base prompt, your bees.toml additions and
the repository’s own
project prompt files, with placeholder
values for the worktree and issue.
The project prompt files are read from the checkout bees.toml sits in, which
is the only one this command has. A session reads them from its own worktree,
so a branch that changes bees/prompts/ renders a different prompt; when the
command finds any, it says so on stderr.
bees prompts show reviewer
bees prompts show pm --rendered | less
Running the factory
bees run
Runs the scheduler until interrupted. Every poll_interval (default 5m; two
API calls per poll) it lists visible issues and PRs, delivers new human
reviews and comments on factory PRs to the developer as mail (sending an
approved issue back to bees:ready), reconciles labels (unlabelled issues go
to the product manager when it is enabled, answered questions unblock), hands
ready issues to free developer workers and starts the product manager, project
manager and QA when they have work. It does not wait out the interval for what
happens locally: a finished session wakes it, so a freed developer slot, mail
one role wrote to another and the labels a session moved on GitHub are picked
up at once, without polling GitHub again. Ctrl-C — or q in
the live view — stops polling, starts nothing new and waits
for the work already in flight to finish. An issue a developer worker holds
goes on through the stages it has left — a developer session is followed by
the review that belongs with it — until it is approved, escalated, out of
max_review_rounds or over max_cost_per_issue; each session is still
bounded by its role’s timeout. A second Ctrl-C stops them now.
Before the first poll it runs the cheap half of bees doctor —
every check except the roles group, which clones skills and starts MCP
servers — and refuses to start when one of them fails: it prints the doctor
table and exits non-zero, having started no session. Warnings do not stop it
and are not printed, so a start that is going to work stays quiet.
--skip-doctor bypasses the preflight. bees tick and bees exec never run
it: they are debugging commands and must stay usable on a half-configured
machine.
Ahead of the doctor, and not bypassed by --skip-doctor, it checks that every
role in the rotation asks for a sandbox bees can
build here, and refuses to start naming the role when one does not: falling
back to running that role unboxed would give it what it was configured to be
kept away from.
$ bees run
github
✗ workflow labels 2 of 19 missing: bees:size/l, bees:size/xl
→ run `bees labels sync`
...
Error: preflight: 1 of 19 checks failed — fix them, run `bees doctor --fix`, or start anyway with `bees run --skip-doctor`
At start it lists the repository’s labels once and creates any workflow label
that is missing, so a repository whose labels have fallen behind needs no
bees labels sync first. Labels that already exist are left untouched, colour
and description included. Failing to read or create them only logs a warning;
the run continues.
| Flag | Description |
|---|---|
--once |
Do one pass and exit when the sessions it started finish. Same scheduling as bees tick; in a terminal it draws the live view rather than logging the pass, so bees tick or --no-tui is what prints a report. |
--roles a,b |
Only run these roles (aliases accepted: pm, pjm, dev, reviewer, qa). |
--skip-doctor |
Start without running the doctor preflight. |
--no-tui |
Log to the console instead of drawing the terminal UI. A stdout that is not a terminal turns the UI off on its own. |
bees run
bees run --roles dev,reviewer
bees -v run --once
bees --log-format json --quiet run
The live view
In a terminal, bees run draws the factory instead of logging to it: a
full-screen view, redrawn as sessions start and finish and as the queues
change, with a session view behind it. It subscribes to the scheduler’s
event stream in the same process and re-reads status.json; it never polls
GitHub itself and the scheduler never waits for it.
busybees acme/widgets 10:03:08
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Now │
│ role issue pr stage elapsed turns cost model │
│ ▸ developer #12 #31 developer r2 3m20s 61 $1.79 opus │
│ reviewer #14 #33 pre-review checks r1 42s 9 - sonnet (fallback) │
│ product manager - - - 9s 4 - sonnet │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Recent │
│ role issue pr outcome took cost note │
│ reviewer #12 #31 changes-requested 6m14s $1.18 tests missing for the erro… │
│ project manager #12 - done 3m2s $0.61 refined and moved to ready │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Needs human │
│ issue waiting title why │
│ #44 2d Parser drops a token Checks on #52 still fail after 2 fix rounds: go /… │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Approved PRs │
│ pr issue open title │
│ #60 #20 1d Retry a session that hit the account limit │
│ #62 #22 3h Docs: the release workflow │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Queues │
│ triage 2 ready 4 in-progress 2 review 1 approved 2 │
│ blocked 0 needs-human 1 features 5 feedback 1 open PRs 3 │
│ unread mail product manager 1, developer 2 │
│ next poll in 2m30s │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
↑↓ select · enter watch · o open on GitHub · k stop session · q or ctrl-c stops (sessions finish)
Now is every session running right now: the role, the issue and pull
request it is about, the stage its developer worker is in with the round it
is on, how long it has been going, and the model it runs on — (fallback)
when a retry is running on the role’s fallback_model. ↑/↓ move the
cursor down the list and Enter opens
the session view on the session it is on.
Turns is what the work item has taken: the sessions of it that have
already finished, plus the assistant messages the running session’s own
transcript.jsonl holds right now, recounted every few seconds. Cost is
the finished sessions only, and it is - until one of them has ended and
reported a cost — an agent prices a session in the event that ends its stream
and says nothing before it, so a session that ended without reaching that
event (killed by a signal, most often) leaves the cost unknown rather than a
cost of zero. A codex session’s cost is never known: codex reports tokens
rather than a price. An opencode session’s cost is the sum of its steps’
costs, known whenever at least one step finished, zero and known for a local
model that really is free. A session that really did cost nothing still
prints $0.00.
Recent is what just happened: the sessions that have finished, newest
first, with how each ended, what it said about it, how long it took and what
it cost. It is a view, not a log — bees cost reads every session out of
ledger.jsonl, and <state_dir>/bees.log has every record.
Needs human is every issue carrying bees:needs-human: how long it has
been waiting and why the factory gave it up, in the words the escalation
comment used. An issue you labelled by hand has no recorded reason and says
so.
Approved PRs is what the reviewer approved and left for a person to merge, oldest first — the queue that grows when nobody is merging.
Queues is what bees status prints, read from the same status.json
rather than counted a second way: every queue, whether or not anything is in
it, the unread mail per role and the countdown to the next GitHub poll.
Colour is a second way to read the same rows, never the only way: everything it tells apart is already spelled out in the row’s own words. A running session is coloured by its role, a finished one by how it ended — green for the ones that worked, yellow for the ones that want a person, red for a failure and for a session that reported no outcome at all. The two panels that hold what is waiting for you say so with their title and border while they hold anything, and look like every other panel when they are empty. The colours are the terminal’s own, so they follow whatever palette you have set and are readable on a light background and a dark one alike.
The keys:
| Key | What it does |
|---|---|
↑ ↓ |
Move the selection through every panel’s rows in turn. |
enter |
Watch the selected session’s transcript (below). |
o |
Open the selected issue or pull request on GitHub. |
k |
Stop the selected session and hand its issue to a person. It asks first, naming the session: press k again to stop the one it named. |
q, ctrl-c |
Stop the factory: nothing new starts and the work in flight finishes. Press again to stop the running sessions now, and a third time to leave the terminal early. |
q and Ctrl-C stop the factory exactly as an interrupt does without the
view: polling stops, nothing new starts, and the view stays up — its footer
saying what it is still waiting for — until the work in flight is done. An
issue a developer worker already holds is not left mid-loop: it runs on
through its remaining stages, so the review that belongs with a developer
session that was running still starts, and the Now panel picks it up like
any other session; the work item ends where the loop ends it — approved,
escalated, out of review rounds or over its cost budget. Pressing either
again stops the running sessions now, killed mid-work the way a
crashed scheduler would leave them, so
the next bees run resumes each issue and tells its next session what was
interrupted. A third press leaves the terminal and waits out whatever is
still coming down with the console back.
k is the key that throws work away, and asks first: the first press names
the session it would stop and the second stops that one, whatever the cursor
has moved on to in between. It stops it the way
bees kill stops a leftover
one — the process and its group, and the container of a session in the
container sandbox, with an interrupted marker left in the session
directory — and then labels its issue bees:needs-human with a
comment saying a person stopped it, exactly as the factory giving up would.
The session’s own worker ends without retrying it. A singleton session
(product manager, project manager, QA) owns no issue, so stopping one stops a
session and nothing more. Nothing is recorded as run either, so a singleton
the factory still has work for starts again on the next pass.
The view wants about 30 rows to show every panel at once. In a shorter terminal the lists shrink first, each keeping one row and saying how many entries did not fit; when even that will not fit, whole panels go, from the bottom up — Approved PRs first, then Needs human, then Recent. The header, Now, Queues and the footer are the last things to go, and Queues goes on counting whatever the panels below it stopped listing.
Whenever dispatch is paused, the header says so and why, next to the clock —
so a factory sitting on a full queue with an empty Now panel does not read as
idle. See bees status for what the numbers mean and
when each pause lifts:
busybees acme/widgets ⏸ daily budget ($101.20 / $100.00) 10:03:08
While the view is up, console logging is silenced — it would scribble over
the panels — and <state_dir>/bees.log gets every record, so nothing is
lost. --no-tui, a redirected or piped stdout, and bees tick log as
before.
Watching one session
Enter on a session in the Now panel opens its transcript, and the view keeps
reading it as the session writes: the session’s own words, the tools it
called and how each one answered, the way the agent’s own output reads. It
is the session’s transcript.jsonl under <state_dir>/sessions/, so
nothing extra is asked of the scheduler and nothing is lost when the view is
closed.
busybees acme/widgets 10:03:08
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ developer · developer-issue-12-r2 · issue #12 · PR #31 — following │
│ ● I'll start by reading the issue and the tests around it. │
│ ● Bash(go test ./internal/scheduler/ -run TestResumeStage) │
│ ⎿ ok github.com/kpenfound/busybees/internal/scheduler 0.412s (+2 lines) │
│ ✻ thinking │
│ ● Edit(internal/scheduler/developer.go) │
│ ⎿ The file has been updated. │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
esc back · ↑/↓ scroll · end follow · m message · q or ctrl-c stops (sessions finish)
The view follows the tail as the session writes to it. ↑/↓, PgUp/PgDn
and Home scroll; scrolling away stops it following so a line arriving does
not take what you are reading away, and scrolling back to the end — or End
— resumes it. A session that finishes while you are reading it stays on
screen, marked ended; esc goes back to the panels.
A message goes to the next session, not to the one on screen. m opens
a line to type one, and Enter queues it:
message for the next developer session on issue #12 (enter queues it, esc cancels)
…
queued for the next developer session on issue #12
It is an ordinary mailbox message from human — the same channel
bees mail send --from human writes and every role’s prompt calls
authoritative — addressed to the role that session is running as and
carrying its issue and pull request, so it reaches whichever session picks
that work item up next. Nothing reaches the session on screen: a headless
session reads its prompt and works to the end of it, and a follow-up turn
written to its stdin is read and then ignored. The view says “queued for the
next session” because that is what happened.
While a message is being typed, q is a letter rather than a stop key —
Ctrl-C still stops the factory, so nothing is lost by the exception.
With the view off, every finished session prints one summary line. In text
format they are the message alone, so a run reads as a report:
✓ project manager issue #12 done: "refined and moved to ready" (34 turns, $0.61, 3m02s)
✓ developer issue #12 → PR #31 opened (87 turns, $2.41, 11m37s)
✗ reviewer PR #31 changes requested: "tests missing for the error path" (52 turns, $1.18, 6m14s)
✓ developer issue #12 → PR #31 updated (41 turns, $0.98, 5m03s)
✓ reviewer PR #31 approved: "lgtm" (23 turns, $0.47, 2m41s)
⚠ issue #14 escalated to a human: Checks on #33 still fail after 2 fix rounds: go / test
With --log-format json the same line is an ordinary record carrying its
numbers as fields:
{"time":"2026-08-29T10:14:02Z","level":"INFO","msg":"✓ developer issue #12 → PR #31 opened","summary":true,"role":"developer","issue":12,"pr":31,"outcome":"pr-opened","turns":87,"cost_usd":2.41,"duration":697000000000,"note":""}
--quiet keeps the summary lines, warnings and errors and drops the rest, so a
service can run the factory and still see what it did.
run, tick and exec also write every record — at debug level, whatever the
console flags say — as JSON to <state_dir>/bees.log. It rotates in place at
10 MiB into bees.log.1 and bees.log.2; older generations are dropped. The
log file is a diagnostic, never a reason not to start: if it cannot be opened
(a read-only or full state directory) the run continues with console logging
only, after one warning naming the path and the reason.
bees tick [--roles a,b]
One scheduler pass, then wait for everything it started. Useful for cron-style operation or for watching a single cycle while tuning prompts.
bees exec <role> [--issue N] [--pr N]
Runs one session for a role right now, outside the polling loop, with the same prompts and label transitions the scheduler would apply.
| Role | Arguments |
|---|---|
pm, pjm, qa |
None. Polls GitHub, reconciles, then runs the role once. |
developer |
--issue N. Runs the full developer ↔ reviewer loop for that issue. |
reviewer |
--issue N or --pr N (the PR’s closing issue is used). Moves the issue into review and runs the loop from the review stage, the pre-review checks read included. It is an instruction, not a resumption: any stage the worker that last had the issue recorded is forgotten. |
bees exec pjm
bees exec developer --issue 12
bees exec reviewer --pr 34
bees status [--json]
Shows the scheduler’s last poll time, PID and build; queue sizes per workflow
state, plus feedback and features (the open bees:feedback and
bees:feature issues owned by the product manager), proposals (the subset of
features still waiting for a person to approve them) and open_prs; running
developer workers (issue, size, stage, round, the attempt
number while a session is being retried, and whether the worker resumed); a row
per role; and unread mail per role. Reads status.json from the state
directory, so it works while bees run is active in another terminal.
When [github] gives the factory an account of its
own, the first line names it, so it is visible whose comments and labels the
repository is about to see (acting_as in --json, empty when the factory
uses your own gh login):
repo: acme/widgets state: /home/kyle/src/acme/.bees acting as: busybees-bot
The scheduler line ends with the build bees run was started from — what
bees version prints for it (version in --json, with the
untruncated commit as revision):
scheduler: pid 4711, last poll 12s ago build dev (b24a0605c2a1 modified)
Until the scheduler’s first poll succeeds, the line says so instead of a poll age:
scheduler: pid 4711, no successful poll yet build dev (b24a0605c2a1 modified)
The role prompts are compiled into the binary,
so a running factory serves the prompts of that build: a prompt change merged
to the default branch reaches no session until bees is rebuilt and
bees run restarted. bees doctor compares that build against
the repository and warns when the running scheduler is behind it. The segment
is absent when status.json records no build.
A worker’s stage is develop, fan-out, assembler, pre-review checks,
review, stack-wait or checks. Once the checks stage knows what it is
waiting for, the stage names the gate — checks (required), checks
(reported) or checks (none) — so a worker sitting in a 30-minute wait says
whether it is waiting on the branch’s required checks, on the checks the
pull request happens to report, or on nothing at all. See
auto-merge.
A worker line ends with resumed when the worker took over from a session
that never finished — a killed scheduler, or a hard stop — rather than
starting fresh:
developer workers:
dev-1 issue #12 m develop round 1 sandbox none since 8:22AM
dev-2 issue #14 s review round 2 sandbox none since 8:31AM resumed
The branch of a resumed worker may already carry work nobody reported, and the session that took over is told so in its prompt — see crash recovery.
sandbox is the mode the session running right now is boxed in, not the
worker’s: the stages of one worker run different roles, and a role’s
sandbox is its own. A worker whose
first session has not started reads -.
The roles: table covers all five roles with what each is doing (running or
idle; - for the developer and reviewer, whose work is in the workers table
above), when it last ran, and how big its notes have
grown — a role whose notes are getting long is a candidate for
bees notes reset:
roles:
product_manager idle last run 12m0s ago notes 4.2 KB
project_manager running last run 1m0s ago notes 2.1 KB
developer - last run never notes 31.4 KB
reviewer - last run never notes 6.0 KB
qa idle last run 2h0m0s ago notes -
Notes sizes are measured when the command runs, not read from status.json,
so they are right even when the scheduler has never run. They come from the
backend notes.backend names, so with
backend = "neo4j" they are the sizes the service holds. --json carries
them as notes_bytes (role → bytes).
A no_state queue counts issues that are visible to the factory but carry no
workflow state label yet — usually ones a person just filed from the GitHub
UI. The scheduler reads them as feedback and gives them bees:feedback on its
next reconcile, so the row normally disappears again within the same pass and
the feedback row grows by one. A workflow-state queue is omitted while it is
empty (feedback, features, proposals and open_prs are always shown).
Under the scheduler line it also reports what the factory has spent since
midnight, summed from the
session ledger (today in
--json):
today: 23 sessions, 412 turns, $8.12
With scheduler.max_cost_per_day configured,
the scheduler line itself carries the rolling 24-hour spend against that
budget — a different window from today: above — and says plainly when the
budget has paused dispatch:
scheduler: pid 4711, last poll 12s ago daily budget: $42.10 / $100.00 build v0.2.0
scheduler: pid 4711, last poll 12s ago paused: daily budget ($101.20 / $100.00) build v0.2.0
While it is paused the scheduler keeps polling and reconciling labels but
starts no new session; the workers already running finish their loop. Both
numbers come from status.json (budget_paused, day_spend_usd and
day_budget_usd in --json), so they are what the scheduler last computed
rather than a fresh sum.
The claude session limit pauses
the factory the same way, and is reported before the budget because it is the
harder stop — it names the time it lifts (limit_paused_until in --json):
scheduler: pid 4711, last poll 12s ago paused: claude session limit until 23:50 (in 37m) build v0.2.0
The ready queue also carries a breakdown by size
(ready_sizes in --json); issues the scheduler has not sized yet are
counted as unsized:
ready 4 (xs 1, s 2, m 1)
A work hours: line always follows it. With
scheduler.work_hours configured it reports
whether the factory is inside the window, the window itself, and when the next
GitHub poll is due:
work hours: yes (09:00-18:00 mon-fri, America/New_York) next GitHub poll in 2m55s
Without it, the line says so and names the cadence in force instead, because a missing line would be indistinguishable from a factory that polls around the clock:
work hours: not configured — GitHub polled every 5m0s next GitHub poll in 2m55s
When scheduler.timezone is unset the window is read in the machine’s local
time, which is printed as the abbreviation and offset in force right now
rather than the uninformative Local:
work hours: no (09:00-18:00 sat,sun, local time (PDT -07:00))
--json reports the same answer as a work_hours object, computed when the
command runs:
"work_hours": {
"configured": true,
"in_work_hours": false,
"window": "09:00-18:00 mon-fri, America/New_York",
"poll_interval": "1h0m0s",
"checked_at": "2026-08-29T20:48:00Z"
}
in_work_hours is omitted when configured is false, and poll_interval
is the cadence in force at checked_at (so off_hours_poll_interval outside
the window). This is the live answer. status.in_work_hours and
status.next_poll next to it are the scheduler’s own record from its last
pass, and go stale as soon as the scheduler stops; both are reported so the
stale one is never the only one available.
Ready issues held back by an open dependency are
counted on the ready row and listed below the queues:
queues:
ready 4 (xs 1, s 3, 2 waiting on deps)
waiting on dependencies:
#40 blocked by #37
#46 blocked by #44
--json carries the same information as waiting_on_deps (issue number →
open blockers).
Two queue counts also have their detail in --json, though the text output
prints the counts alone: needs_human names each escalated issue with the
reason the factory recorded when it gave up, and approved names each pull
request waiting for a person to merge, oldest first.
The live view is what draws them as panels.
Degraded operations
A factory operation that keeps failing — assigning what a session created,
editing a label, the poll itself — is reported under a short, stable name, so
a half-broken run does not look like a healthy one. The operations failing
right now are listed after the last error line:
degraded:
assign 12 consecutive failures over 3h10m last: GraphQL: Projects (classic) is being deprecated
label 1 failure last: gh: HTTP 403 (Resource not accessible by integration)
The section is absent entirely when nothing is failing. A single success
clears the operation’s streak and removes its line. --json carries the same
entries as status.degraded (op, count, first, last, last_error,
escalated).
Three consecutive failures of one operation also print one line into the run’s output — once per streak, not once per pass:
⚠ assign has failed 3 times in a row: GraphQL: Projects (classic) is being deprecated
Nothing else happens: the scheduler does not retry, back off or stop, and no issue is commented on — there is no issue to comment on for a factory-wide operation, and no role can fix a broken credential or a missing label. This is visibility only.
The mailbox
Roles talk to each other only through the local mailbox in <state_dir>/mail.
The scheduler delivers messages by including them in the prompt of the session
working on the referenced issue or PR and marks them read afterwards.
bees kill [--dry-run] [--scheduler] [--grace 5s]
Cleans up after a crash: finds the agent sessions started by bees, terminates
them together with their process groups (MCP servers, shells), removes stale
pid files, removes the temporary worktrees bees created under the workspace
root, and resets the worker list in status.json.
Sessions are found two ways: from the pid file each running session keeps in
its <state_dir>/sessions/<id>/ directory, and from the process table, limited
to sessions of this state directory — a claude or codex process counts
only when it carries a session marker (the --name bees-… argument every
claude session is started with, or the mcp_servers.bees.env.BEES_SESSION_DIR=
override every codex session gets) and its command line references
<state_dir>/sessions/. Another project’s factory
running on the same machine is never touched, whichever config you point
bees kill at. Pid files are cross-checked against that scan, so a pid reused
by an unrelated process after a reboot is discarded, never killed.
A session in the container sandbox is found a
third way, because its agent runs in the container rather than on the host:
the engine is asked which of its running containers carry the bees.session
label, whose value is the session directory, and stopping such a session
removes its container. Such a session also leaves the pid of the MCP server
bees runs on the host for it in mcp-server-pid, and that server is stopped
with it. A container, an engine client or a server whose session is
otherwise gone is stopped on its own.
Each session it stops through a pid file or through its container is also
marked as stopped, by an interrupted file in the session’s directory. The
next session for that issue is then told the session before it was stopped on
purpose rather than lost with the machine, and — when it was a developer
session — that the branch may carry its unreported work.
It refuses to run while a bees run scheduler is alive (killing sessions
under a running scheduler would corrupt its state); pass --scheduler to stop
the scheduler too. To stop one session of a factory that is still running, use
k in the live view: it stops the session the same way and
hands that session’s issue to a person, which is what a running scheduler
needs and this command does not do.
| Flag | Meaning |
|---|---|
--dry-run |
Show what would be killed and removed. |
--scheduler |
Also stop a running scheduler (found via the pid in status.json). |
--grace 5s |
Time to wait after SIGTERM before SIGKILL. |
bees kill --dry-run
bees kill
bees kill --scheduler # the scheduler itself is hung
bees mail send (sessions)
| Flag | Description |
|---|---|
--to <role> |
Recipient role (required). |
--from <name> |
Sender. Default $BEES_ROLE; humans must pass one, e.g. --from human. |
--subject <text> |
Subject line. |
--body <text> |
Body. |
--body-file <path> |
Read the body from a file; - for stdin. |
--issue N |
Issue the message is about. Default $BEES_ISSUE. |
--pr N |
Pull request the message is about. Default $BEES_PR. |
--in-reply-to <id> |
Id of the message being answered. |
Attach --issue/--pr whenever possible: that is how the scheduler routes the
message to the right developer session, and how an answer unblocks a
bees:blocked issue.
bees mail talks to the state directory of $BEES_STATE_DIR when it is set
(that is how a session reaches its own mailbox), but an explicit --config
wins over it, so bees -c other/bees.toml mail send ... inside a session
reaches the other project. The confirmation line names the state directory the
message landed in: sent <id> to <role> (<state dir>).
bees mail send --to project_manager --issue 12 --subject "Which auth scheme?" --body "JWT or sessions?"
bees mail send --to developer --pr 34 --issue 12 --subject "Review round 1" --body-file review.md
bees mail send --from human --to product_manager --subject "Priority" --body "Ship billing before reports."
bees mail send --from human --to developer --issue 12 --body "Keep the CLI flag names as they are."
bees mail list
| Flag | Description |
|---|---|
--to <role> / --from <name> |
Filter by recipient or sender. |
--issue N / --pr N |
Filter by referenced item. |
--unread |
Only undelivered messages. |
--full |
Print whole messages instead of one line each. |
Unread messages are marked with *.
bees mail read <id>
Prints one message.
Notes
<state_dir>/notes/<role>.md is a role’s memory between sessions with the
default notes.backend = "file": a session reads it with the notes_read
tool and replaces it with notes_write before it finishes, and these
commands are how a person reads and steers it. With notes.backend =
"neo4j", notes_read and notes_write act on Neo4j Agent Memory instead;
these commands still act on the file, which no session then reads. See
Notes files. Roles accept the usual aliases (pm,
pjm, dev, review, qa).
bees notes show <role>
Prints the notes file (nothing when the role has never run).
bees notes edit <role>
Opens the notes file in $VISUAL, else $EDITOR, else vi, creating it first
if needed, and exits with the editor’s status. It needs a terminal, so it
refuses to run inside a session ($BEES_SESSION_DIR set) — sessions edit their
file directly.
bees notes reset <role>
Moves the notes file to <state_dir>/notes/archive/<role>-<timestamp>.md,
prints that path and leaves a fresh file behind. Use it when a role has
accumulated advice that no longer applies; nothing is lost, the archive stays.
bees notes add <role> [text]
Appends one bullet to the notes file, creating it when needed:
bees notes add developer "Always run dagger check before committing"
bees notes add pm --body-file vision.md
Pass --body <text> or --body-file <path> (- reads stdin) instead of the
argument for longer text. A note spanning several lines keeps its line breaks;
every line after the first is indented by two spaces so the whole note stays
inside one bullet. Like bees mail, show, reset and add find the
state directory from $BEES_STATE_DIR before falling back to bees.toml, so a
session can append to its own notes without a config file.
Creating issues
bees issue create (sessions, and humans)
Creates an issue the way the factory wants it. Roles are told to use this
instead of gh issue create; it is equally handy for people.
| Flag | Meaning |
|---|---|
--title |
Required. |
--body / --body-file |
Body text, or a file (- for stdin). |
--parent N |
Make the new issue a native GitHub sub-issue of feature N and inherit its milestone. |
--related N |
Inherit the milestone of issue N without attaching (a bug found while working on N, a feature distilled from feedback N). Exclusive with --parent. |
--milestone T |
Set the milestone explicitly (overrides inheritance). |
--bug |
Bug work item (bees:bug). |
--feature |
Feature issue for the product manager (bees:feature, plus bees:proposal unless scheduler.feature_proposals = false; no state label). |
--ready |
Work item is already detailed: bees:ready instead of bees:triage. |
--blocked-by N |
Repeatable. Prefixes the body with a Blocked by #N line, so the scheduler does not build the issue ahead of N (see Dependencies). No GitHub dependency relationship is created. |
--label L |
Extra label (repeatable). |
What it always does: adds the visibility label and, when filter.assignee is
set, the assignee; adds the kind label; adds bees:triage (or bees:ready) to
work items — feature issues get no state label; resolves the milestone as
explicit → parent/related issue’s milestone → filter.milestone; and, with
--parent, attaches the issue as a sub-issue (three API calls: parent details,
create, attach). Bees never create, edit or close milestones themselves.
--milestone only picks among the ones people made, and the one role that
passes it is the product manager, for a feature spawned from an agreed design
(From an agreed design to several features).
bees issue create --parent 12 --title "Export as CSV" --body-file body.md # work item under feature #12
bees issue create --bug --related 34 --title "Crash on empty input" --body "…" # bug in #34's milestone
bees issue create --feature --related 40 --title "Search" --body-file body.md # feature from feedback #40
bees issue create --title "Fix typo in README" --ready # fast-tracked work item
bees issue create --parent 12 --blocked-by 37 --title "Order the queue" --body-file body.md # waits for #37
bees issue link --parent N --child M
Attaches existing issue M as a sub-issue of feature N (for example a bug
filed by QA that turns out to belong to a feature in progress).
Becoming a sub-issue carries the feature’s milestone across, exactly as
--parent does on bees issue create, so an issue attached after the fact
lands in the same release as one created under the feature. It only ever
fills in a milestone: an issue that already has one keeps it, and the
command says which milestone it set, if any. Refuses a parent that is still a
proposal (unless scheduler.feature_proposals = false), or that a person has
put in planning (bees:planning).
Reporting outcomes
bees done <status> [-m note] [--pr N] [--issue N] (sessions)
The last command every session runs. Writes outcome.json into
$BEES_SESSION_DIR; the scheduler reads it to decide what happens next. A
session that ends without an outcome is treated as failed. Statuses are
validated against $BEES_ROLE:
| Role | Valid statuses |
|---|---|
product_manager |
done, idle, failed |
project_manager |
done, idle, failed |
developer |
pr-opened --pr N, pr-updated --pr N, question, failed |
reviewer |
approved, changes-requested, failed |
qa |
done, failed |
pr-opened and pr-updated require a PR number (--pr or $BEES_PR).
question and changes-requested are only honoured if the session actually
sent the corresponding mail; otherwise the issue is escalated to a human.
bees done pr-opened --pr 34
bees done changes-requested
bees done approved -m "Clean implementation, tests cover the edge cases"
bees done failed -m "Could not get the test-suite to run: missing DATABASE_URL"
bees mcp serve (sessions)
Runs the built-in MCP server on stdio (or over HTTP, below). You never start
it yourself: bees
hands it to every session as the server named bees (in mcp.json for
claude, as mcp_servers.bees overrides for codex, in the mcp table of the
configuration file OPENCODE_CONFIG names for opencode), and the agent
starts it as <bees binary> mcp serve with the session’s BEES_*
variables. The name bees is reserved — a [global.mcp.bees] or
[roles.<role>.mcp.bees] entry in bees.toml fails validation.
For a container session the runner
starts it on the host itself, as bees mcp serve --listen <address> with
$BEES_MCP_TOKEN set: it prints listening on <address> and serves the same
tools over HTTP to a client presenting that token, which is how a session
without the bees binary reaches them.
The server is backed by the same code as the commands above, so a tool and its
command do exactly the same thing. Claude Code exposes the tools as
mcp__bees__<name>:
| Tool | Arguments | Same as |
|---|---|---|
mail_send |
to, subject, body, optional issue, pr, in_reply_to |
bees mail send |
mail_list |
optional unread, issue, pr |
bees mail list --full |
issue_create |
title, body, optional parent, related, milestone, bug, feature, ready, labels, blocked_by |
bees issue create |
issue_link |
parent, child |
bees issue link |
done |
status, optional note, pr, issue |
bees done |
notes_read |
none | bees notes show <role> for the session’s own role; the notes are not in the prompt, so a session reads them first |
notes_write |
text |
no command: replaces the role’s notes with the whole text (bees notes add appends one bullet instead); an empty text is refused |
report_factory_error |
title, detail |
no command: writes a draft to <state_dir>/feedback/<id>.json when scheduler.report_factory_errors is on, and answers that nothing was recorded when it is off |
The rest are GitHub operations: the same gh calls a role would build by
hand, with the factory’s rules applied.
| Tool | Arguments | Offered to | Does |
|---|---|---|---|
issue_view |
optional number |
every role | Prints an issue: state/kind/size labels, milestone, parent feature, body, then every comment oldest first, marked as a bee’s or a person’s. |
pr_view |
optional number |
every role | Prints a pull request: title, head → base, draft flag, body, required-check summary with the failed check names, then every review and comment a person left. |
comment |
number, body |
every role | Comments on an issue or pull request, appending the role’s <!-- bees:<role> --> marker unless the body’s last line already is it. A marker quoted anywhere else — including as the last line, > <!-- bees:<role> --> — does not suppress it, so the marker line is always the poster’s. |
issue_edit_body |
number, body |
product_manager, project_manager | Replaces an issue body. Refuses a bees:feature or bees:feedback issue for anyone but the product manager. |
issue_set_state |
number, state (ready|blocked), size (xs…xl, required for ready) |
project_manager | Moves a work item out of bees:triage in one label edit, replacing any existing size. Refuses an issue that is in any other state, naming it. |
issue_question |
number, waiting |
product_manager | Adds or removes bees:question. Refuses anything that is not a feature or feedback issue. |
submit_review |
optional number, event (approve|request-changes|comment), body |
reviewer | Submits one GitHub review on a pull request, appending the reviewer’s marker as comment does: the findings of the review, as a comment review on a developer’s pull request (the verdict goes to the developer by mail) and with the verdict as the event on a requested review. Refuses an issue. |
file_bug |
title, body, optional related, override |
qa | Files a bees:bug work item as issue_create would, after scoring it against every issue in the repository, open and closed. When any of them scores high enough nothing is filed and the ranked candidates come back instead; override: true files without asking again. |
Every one of them refuses the issue or pull request it acts on when it does
not match the filter, and every write
is a refusal or a single gh call — there is no partial state to clean up.
file_bug creates an issue rather than acting on one: its optional related
only supplies a milestone, and is not checked against the filter, exactly as
issue_create’s is not.
issue and pr default to $BEES_ISSUE/$BEES_PR, so a session rarely
passes them (issue_view and pr_view default their number the same way).
The schemas depend on $BEES_ROLE: done’s status enum is exactly the
role’s valid outcomes (a developer sees
pr-opened, pr-updated, question, failed; a reviewer
approved, changes-requested, failed), and an unknown or empty role gets the
full tool set with no enum, so the server is usable by hand.
bees mcp tools [role]
Prints the tools a role’s session sees, with the enum of every constrained parameter — the part that differs between roles:
$ bees mcp tools developer
mcp__bees__comment Comment on an issue or pull request
mcp__bees__done Report the session outcome
status: pr-opened | pr-updated | question | failed
mcp__bees__issue_create Create a factory issue
mcp__bees__issue_link Attach an issue to a feature
mcp__bees__issue_view Read an issue
mcp__bees__mail_list Read the mailbox
mcp__bees__mail_send Send mail to another role
to: product_manager | project_manager | developer | reviewer | qa
mcp__bees__notes_read Read your notes
mcp__bees__notes_write Replace your notes
mcp__bees__pr_view Read a pull request
mcp__bees__report_factory_error Report an error the factory caused
The tool set differs too: the project manager also sees issue_edit_body
and issue_set_state (state: ready | blocked, size: xs | s | m | l | xl),
the product manager issue_edit_body and issue_question, the reviewer
submit_review (event: approve | request-changes | comment), and QA
file_bug.
Without a role argument it uses $BEES_ROLE, and without that it prints the
unconstrained tool set.
Reviewing a pull request
bees review reviews a GitHub pull request from several angles at once. Its
settings are ~/.config/bees/config.toml: the agent it runs as, where your
reviewer notes and review artifacts live, and how a review ends when the
command line does not say. The repository’s own context.toml says which
angles run there. Reviewing a pull request describes how a
review runs, what it keeps, and every key of both files.
bees review <pr> [--post mode | --report] [--config path]
Reviews a pull request: a github.com URL, owner/name#123, or a bare number
when the current directory is a checkout of the repository. The review
gathers the pull request’s context, runs the distiller session that briefs
it, runs one read-only session per angle the brief’s size calls for and
context.toml enables, merges what they found into one list, most severe
first, and joins the list against the rules in your reviewer notes. Each
step prints a line as it goes, and while the angles run, at a terminal, one
row per angle under it shows a spinner until that angle’s session ends,
then a mark saying it finished or failed and how long it took. The review
is written into its artifact directory as it goes too, so a review that
stopped after the angles has its brief and their sessions; one angle failing
is printed and skipped, and every angle failing stops the review.
$ bees review acme/widgets#7
gathering the context of acme/widgets#7
gathered 6 items from diff, pr_body, linked_issues, style_files
distilling the brief
the review is /Users/me/.config/bees/reviews/acme/widgets/7/20260910-150405
reviewing a size m change from 4 angles: general, docs, test_coverage, acceptance_criteria
✓ general 2m14s
✓ docs 1m02s
✓ test_coverage 1m51s
✓ acceptance_criteria 1m37s
3 findings
1 finding hidden by your reviewer notes
--no-tui, or a stdout that is not a terminal, draws nothing: each angle
prints a line as it starts and one as it ends instead, which is what a
script or a CI log gets. Ctrl-C at the terminal stops the sessions and
waits for them to end.
Triage follows, as triage below describes it, or by an agent with --agent
(see factory mode), and then the review ends one of five ways
with what you selected:
| End | What happens |
|---|---|
approve |
the selected findings are posted as review comments, and the pull request is approved |
comment |
the same comments, as a comment-only review |
reject |
the same comments, as a review that requests changes |
report |
the selected findings are printed as a markdown report, and nothing is posted |
discard |
nothing is posted and nothing is printed |
--post approve, --post comment, --post reject or --report chooses;
with neither, the output key of ~/.config/bees/config.toml does, and its
default, ask, asks at the end:
1 selected, 1 dismissed, 1 deferred, 0 undecided of 3 findings
1 finding selected. a approve and comment · c comment only · r reject and comment · o output the report · d discard
> a
acme/widgets#7: approved with 1 comment
The three that post submit one review in one call, never one comment at a
time. A finding is posted as a comment on its lines when the pull request’s
diff has them, on the side of the diff it is about, with its suggestion as a
suggestion block when GitHub can apply one there (the new side). A finding
on lines the diff does not have, and one about the change as a whole, goes
into the review’s summary instead, with where it points. A comment-only or
request-changes review needs something in it: with nothing selected it is
refused, and at the prompt you are asked again. An approval with nothing
selected approves and says nothing. The review is posted with the
authentication github.token names, or your own gh login without it.
The report is the same selections as markdown, for pasting wherever you like: the pull request, then each finding with where it points, its severity and category, its text and its suggestion as a code block.
--config reads a global configuration other than ~/.config/bees/config.toml.
bees review triage <pr> [--post mode | --report] [--config path]
Opens the latest review of a pull request and triages what is still
undecided. The pull request is a github.com URL, owner/name#123, or a bare
number when the current directory is a checkout of the repository.
Each undecided finding is shown in turn, most severe first: its id, severity, angle and category, the file and lines it is about, its text, its suggestion and evidence, and any question already asked about it with the answer. One key followed by return decides it:
| Key | What it does |
|---|---|
s |
select: the finding goes into the review’s output as written |
e |
open the comment text in $VISUAL or $EDITOR, then select it |
d |
dismiss: leave it out, and record why in your reviewer notes |
f |
defer: leave it out of this review, and record nothing |
a |
ask the angle that found it a question; a finding the answer turns up joins the queue |
n |
leave it undecided for now |
q |
stop |
$ bees review triage acme/widgets#7
acme/widgets#7: the review started 20260910-150405
[1 of 3 findings undecided] 1a2b3c4d · high · test_coverage · missing test
internal/review/gather.go:12-14
Gather has no test for a source that cannot read
the acceptance criterion says a source that cannot read something does not
fail the review, and nothing exercises it
s select · e edit and select · d dismiss · f defer · a ask · n next · q quit · ? help
> d
reason (recorded in your reviewer notes): covered by the table test in gather_test.go
At a terminal, triage opens as a full-screen view beside the diff instead of
the prompts above, the same keys pressed without return. --no-tui, or a
stdout that is not a terminal, keeps the console shown above.
Every decision is written into the review’s artifact directory as it is taken, so stopping loses nothing: run the command again and it offers what is still undecided. The latest select, dismiss or defer on a finding is the one that counts. An ask decides nothing; the finding is shown again with the answer under it. A dismissal needs a reason: it is the line your reviewer notes are made of. A decision that cannot be written into the artifact directory stops the command with the error, and the finding is still undecided the next time.
The review then ends the way bees review’s does, with everything selected
so far, in this run or an earlier one: posted, printed as a report, or
discarded, as --post, --report, the output key or the prompt at the
end says.
--config reads a global configuration other than ~/.config/bees/config.toml.
Factory mode
--agent on bees review <pr> and on bees review triage <pr> hands triage
to an agent session instead of you, for a review nobody sits at a terminal
for. The agent takes the same four actions on the same findings: what it
selects goes into the review’s output, what it dismisses is appended to your
reviewer notes with its reason, and every decision is written into the
review’s artifact directory as it is taken. --instructions tells it what
you want from the review.
$ bees review acme/widgets#7 --agent --instructions "Hold the change to its issue. Never approve."
gathering the context of acme/widgets#7
...
3 findings
triage round 1: 3 findings undecided
select 1a2b3c4d: Gather has no test for a source that cannot read
dismiss 5e6f7a8b: the README is rewritten by the docs change that follows
ask 9c0d1e2f: Does the rename reach a caller outside the package?
No: every caller of Gather is in internal/review.
triage round 2: 1 finding undecided
defer 9c0d1e2f: The change renames Gather, which the issue did not ask for
1 selected, 1 dismissed, 1 deferred, 0 undecided of 3 findings
the agent chose to end the review: comment
acme/widgets#7: commented with 1 comment
The agent triages in rounds. Each round is a new read-only session, run as
the provider and model of ~/.config/bees/config.toml and told the brief,
the findings still undecided with the answers to what it asked, the rules in
your reviewer notes about the repository, and what its last answer could not
do: a dismissal without a reason, a finding the review does not have.
Another round follows one that asked a question, had something refused, or
left findings undecided. Triage stops when nothing is undecided, when a
round takes nothing, or after five rounds, and what is still undecided is
there for bees review triage to offer.
The review ends as --post, --report or the output key says. Where they
leave it to ask, the agent chooses one of the five ends, as the
instructions say. Without instructions about it, the agent is told to
request changes when a selected finding must be fixed before the change
merges, to comment when anything else is selected and to discard when
nothing is, and to approve only when the instructions allow it. An agent
that chooses no end it can take discards: nothing is posted.
bees review consolidate [--notes path] [--dry-run]
Every finding you dismiss during triage is appended to your reviewer notes, one line per dismissal:
- [acme/widgets] [general] [naming] receiver names are short here
consolidate reads those lines and writes the patterns that repeat into
rules. Dismissals of the same repository, angle and category whose reasons
read alike are one pattern: dismissed twice, it becomes a rule that ranks its
findings down; three times or more, one that drops them.
$ bees review consolidate
/Users/me/.config/bees/reviewer-notes.md: 7 dismissals, 2 rules
added:
- [acme/widgets] [general] [naming] drop: receiver names are short here (3 dismissals)
A review joins its findings against the rules before triage sees them. A
finding a rule is about is dropped, or ranked down one severity, and the
angle sessions of the next review are told what has been dismissed from their
angle before. A rule with no text after the action is about every finding in
its repository, angle and category, and * in any of those three matches
whatever it is.
The rules live between two markers in the notes file, and that block is the
only part of it bees writes. Nothing in the block is deleted or reworded: a
rule you rewrote, or whose action you changed, stays as you wrote it and only
its count moves. --notes works on a file other than the configured one, and
--dry-run prints what consolidation would write without writing it.
Misc
bees cost [--since 24h] [--by role|issue|day] [--json]
Reports what finished sessions cost, summed from <state_dir>/ledger.jsonl:
one JSON line per session, appended when it ends, with its role, issue, PR,
turns, cost, duration and outcome. The numbers are what the agent reported;
nothing is reconciled against billing.
$ bees cost --since 72h --by role
role sessions turns cost
developer 12 214 $6.10
product_manager 1 11 $0.32
reviewer 9 74 $1.70
total 22 299 $8.12
--since is a Go duration (default 24h). --by issue groups by issue
number and collects sessions that belong to no issue (the singleton roles)
under -; --by day groups by local calendar day. --json prints the same
groups plus the total. An empty ledger prints no sessions recorded.
bees version
Prints bees <version>, resolved from the binary itself:
| Build | Output |
|—|—|
| go install github.com/kpenfound/busybees/cmd/bees@latest (or @v0.2.0) | The module version Go recorded: a tag (bees v0.2.0) or, for an untagged module, the pseudo-version @latest resolves to (bees v0.0.0-20260829201307-b24a0605c2a1). |
| go build ./cmd/bees in a clone | The version Go stamps from the checkout — on Go 1.24+ a pseudo-version, with +dirty appended when the working tree has uncommitted changes. |
| A build whose module version is (devel) but that carries VCS stamps | bees dev (b24a0605c2a1) — the 12-character commit, with ` modified appended when the working tree was dirty. |
| Built with -ldflags “-X main.version=v1.2.3” | bees v1.2.3. The override wins over everything else. |
| A binary from a [GitHub release](/busybees/releasing.html) | bees v0.2.0 — the tag the release was cut from, stamped through that same override. |
| No build information at all | bees dev`. |
bees completion <shell>
Generates shell completion scripts (bash, zsh, fish, powershell).