One file configures a factory: bees.toml, in the root of a git clone of the
project being built. Without --template, bees init writes it with every
option listed and the optional ones commented out at their default, so
configuring is uncommenting and editing lines; see
config templates for the alternative. bees config validate
checks the file and bees config show [role] prints the settings each role
ends up with.
The file starts with a version key, followed by these tables:
| Table | Purpose |
|---|---|
[project] |
The git remote, repository, default branch and state directory |
[filter] |
Which GitHub issues and pull requests the factory can see |
[github] |
The GitHub account the factory acts as |
[scheduler] |
Concurrency, polling, retries, budgets and the review loop |
[logging] |
Console log format and level |
[notes] |
Where a role’s notes live: files, or Neo4j Agent Memory |
[global] |
Prompt, skills, MCP servers, model, sandbox and environment for every role |
[roles.<name>] |
The same keys per role, plus a few that only one role takes |
An unknown key anywhere in the file is a load error, so a typo cannot pass as a default. Every validation error names the key and what to change.
Durations are written the way Go reads them: "30s", "5m", "1h30m".
bees review reads none of this file. Its settings are
~/.config/bees/config.toml and the project’s context.toml, both described
in Reviewing a pull request.
version
version = 2
The format version of the file, not of bees. bees init writes the current
one, 2. A file without the key is version 0.
- A file newer than the running bees understands is refused with
upgrade bees. - An older file is migrated on load, one version at a time.
bees run,tick,exec,status,issue createandissue linkthen write the migrated file back, keeping the original asbees.toml.v<old>.bakand logging that they did.bees config migratedoes the same and nothing else, so the diff can be read before the factory starts;bees config validateonly reports that a migration is pending. - Migrations rewrite the text of the file, so comments and the commented-out
defaults survive. The migration from 0 to 1 adds the
versionkey and changes nothing else. The migration from 1 to 2 removesroles.reviewer.stagesand leaves a comment in its place: no stage maps onto a review angle, so the reviewer gets the defaultangles.
Adding an optional key never bumps the version. Renaming or removing a key, or changing what one means, does, and the release notes of the bees version that bumps it say what changed.
A bees release can also start refusing a value it accepted before, without a
migration. The file then fails to load with an error naming the key, because
the value is yours and bees cannot guess the replacement. The MCP server name
bees is one such value (see MCP servers).
[project]
| Key | Type | Default | Description |
|---|---|---|---|
remote |
string | "origin" |
Git remote the factory fetches from and pushes to. A name; spaces and / are rejected. |
repo |
string | derived | GitHub repository as owner/name. Derived from the remote’s URL when unset (https, ssh:// and git@github.com: forms). Set it when the URL is not a github.com one. |
default_branch |
string | derived | Branch developers branch from, reviewers diff against and QA tests. Read from the remote’s HEAD when unset. |
state_dir |
string | ".bees" |
Where mail, notes, session logs and scheduler state live. A relative path is resolved against the directory holding bees.toml. bees init adds it to .gitignore when it is inside the clone. |
branch_prefix |
string | "bees/" |
Prefix of developer branches: bees/issue-12. |
What the product is and how to build, test and run it are not configuration. Every role reads the repository’s own README, CONTRIBUTING and CLAUDE.md, and keeps what it learns in its notes file.
The clone holding bees.toml is the main checkout. Every session runs in a
temporary git worktree cut from it, so remote has to point at the GitHub
repository.
[filter]
The filter decides which issues and pull requests the factory sees and touches.
Configured criteria are ANDed, and everything the factory creates stays
visible: it is made to match label, assignee and milestone, and the
account the factory acts as always passes creator.
| Key | Type | Default | Description |
|---|---|---|---|
label |
string | "bees" |
The factory’s label. It is the base name of every workflow label (bees:triage, bees:ready, …) and, while require_label is true, the visibility gate. Spaces and colons are rejected. |
require_label |
bool | true |
With false, assignee, milestone and/or creator alone decide visibility. The factory still puts label on everything it creates. |
assignee |
string | "" |
Only see items assigned to this GitHub login. "@me" is resolved to the machine’s own gh login at startup, even with [github] set. Everything the factory creates is assigned to this login so it stays visible. |
milestone |
string | "" |
Only see items in this milestone, by title. Also the milestone for issues the factory creates when neither --parent nor --related gives one, and the one put on the pull requests it opens. People manage milestones; bees inherit them, or pick among them for the features of an agreed design. |
creator |
string | "" |
Only see items opened by this GitHub login or by the account the factory acts as (github.login, else your own gh login). An issue or pull request the factory opens is authored by that account, so it is visible whatever creator says. |
require_label = false without assignee, milestone or creator is
rejected: it would make every open issue in the repository visible.
A factory acting as a bot that should pick up only the issues one person files, plus the ones it opens itself:
[filter]
creator = "kyle"
[github]
login = "busybees-bot"
token = "$BEES_GITHUB_TOKEN"
One person running busybees for their share of a team repository needs no special label on the issues:
[filter]
label = "kyle-bees" # still the base of the kyle-bees:* labels
require_label = false
assignee = "@me"
An issue assigned to you with no state label and neither kyle-bees:feature
nor kyle-bees:feedback gets kyle-bees and kyle-bees:feedback on first
sight and goes to the product manager as feedback; label it kyle-bees:triage
or kyle-bees:ready yourself to have it built. Because every label carries
the prefix, two people can run two factories in one repository with two
labels.
Workflow labels
Every label is derived from filter.label, shown here for bees. bees init
and bees labels sync create them in GitHub.
| Label | Meaning |
|---|---|
bees |
Visible to the factory |
bees:feature |
Feature issue owned by the product manager, which makes it detailed enough and breaks it into work items. Outside the state machine |
bees:bug |
Bug work item, filed by a developer, the reviewer, QA or a person. It says what the issue is, not where it goes |
bees:feedback |
Feature idea, product feedback or bug report for the product manager. Outside the state machine |
bees:question |
The product manager is waiting for a person to answer on a feature or feedback issue; removed by the orchestrator when they reply |
bees:proposal |
A feature issue a bee wrote. It sits next to bees:feature, and a person removes it to approve the breakdown |
bees:planning |
A person and the product manager are still agreeing a feature or feedback issue; the product manager discusses and breaks nothing down. Not a state label; only a person sets or removes it. See Planning with the product manager |
bees:planned |
A person ended planning: the scope is agreed and the product manager breaks the issue down on its next run. Not a state label; only a person sets or removes it |
bees:priority |
A person wants this next: dispatched before the rest of the bees:ready queue. Not a state label. People set it; the project manager may add it to a work item that unblocks the factory itself, and the product manager carries one from a feedback issue onto the work item it creates from it |
bees:review-requested |
On a pull request, not an issue: a person asks the reviewer for one review pass, whoever opened the pull request. Not a state label; the orchestrator removes it as the review starts. See Asking for a review of any pull request |
bees:triage |
Needs refinement by the project manager |
bees:ready |
Detailed enough for a developer |
bees:in-progress |
A developer worker owns it |
bees:blocked |
Waiting on an answer to a question |
bees:review |
Pull request open and under review |
bees:approved |
Reviewer approved; waiting for a person to merge |
bees:needs-human |
The factory gave up, or a person is holding the issue. A person may add it on top of another state label to hold the issue where it is; it wins while it is there |
An issue also carries at most one size label, independent of its state. The
project manager sets it when it moves a work item to bees:ready, and the
orchestrator adds bees:size/m to a ready issue that has none. See
Sizing.
| Label | Meaning |
|---|---|
bees:size/xs |
One file, obvious change, no design |
bees:size/s |
A few files, clear approach, existing tests cover it |
bees:size/m |
A feature slice across several packages, needs new tests |
bees:size/l |
Crosses subsystems or needs a design decision |
bees:size/xl |
Too big for one pull request; split it instead |
[github]
With the table unset, the factory acts as whatever account the machine’s gh
is logged in with, and everything it writes on GitHub looks like it came from
the person running it. [github] gives it an account of its own.
[github]
login = "busybees-bot"
token = "$BEES_GITHUB_TOKEN"
git_name = "busybees"
git_email = "busybees@example.com"
| Key | Type | Default | Description |
|---|---|---|---|
login |
string | "" |
The account the factory acts as. bees init and bees doctor check the token belongs to it, bees status reports it, and the orchestrator reads every comment that login posts as the factory’s own. It must be the login GitHub reports as the token’s user. |
token |
string | "" |
A token for login, passed as GH_TOKEN to every gh call the orchestrator makes and to every session. A "$VAR" or "${VAR}" value is read from the environment bees runs in, so the secret stays out of the file. A reference that expands to nothing is a load error naming the variable. |
git_name |
string | "" |
Author and committer name for the commits developer sessions make (GIT_AUTHOR_NAME, GIT_COMMITTER_NAME). |
git_email |
string | "" |
Author and committer email for those commits (GIT_AUTHOR_EMAIL, GIT_COMMITTER_EMAIL). |
login and token go together: either alone is rejected. git_name and
git_email are an identity rather than a credential, so each may be set alone,
and whichever is unset leaves that half of the commit identity to the machine’s
own git configuration.
The token is a fine-grained personal access token belonging to a user account,
a bot account or your own, scoped to the one repository: read and write on
Issues, Pull requests and Contents, read on Metadata. Issues and pull requests
cover labels, comments, milestones and reviews; contents covers the pushes
developer sessions make. A classic token with the repo scope also works.
With scheduler.report_factory_errors on, the same token files
the factory-error reports against kpenfound/busybees, so a fine-grained
token scoped to your repository alone leaves them in the queue: give it issue
write on kpenfound/busybees too, or leave the key off. The
token has to authenticate as a user, because login is compared with the
account GitHub says the token belongs to.
bees init checks the token before the factory uses it: GitHub accepts it, it
belongs to login, and it can read the repository. bees doctor asks the same
questions of whatever token is configured, and two more: that the account can
write an issue and that it can push a branch. Repository access does not
imply either. A fine-grained token’s permissions sit on top of the repository
role, so a token can read the repository as an admin and still be refused
every label edit or every push.
The token covers everything the factory does on GitHub: polling, label edits,
review requests, the escalation comment, bees init’s label creation, bees
doctor’s checks, bees issue, and the built-in MCP tools. Sessions get it
too, so a session’s own gh pr create, gh api and git push act as the
factory. With git_name and git_email set, its commits are the factory’s as
well.
Comments the factory posts still end with the
comment marker, because [github] is optional and
with it unset every comment arrives under your own login. With it set, the
orchestrator reads a comment as the factory’s when it carries the marker or
when login wrote it, so the escalation comment, which carries no marker, is
not mistaken for a person’s.
A session runs with GH_TOKEN set to the token, the GIT_AUTHOR_* and
GIT_COMMITTER_* variables from git_name and git_email, and git configured
to answer https pushes through gh auth git-credential. Your own credential
helper is reset first so it cannot answer the push, and your stored credentials
are neither read nor written. The helper only steers https remotes: on an
ssh:// or git@github.com: remote the commits are the factory’s but the push
authenticates with the machine’s ssh key. When token is a "$VAR" reference
the session is also given that variable, holding the resolved token, because
the bees commands a session runs load bees.toml themselves. It goes into
the session’s environment and never into a file. The full list is under
Exported into every session.
filter.assignee = "@me" still means you. It says whose work the factory picks
up, and is resolved with the machine’s own gh login before any token is used,
in the orchestrator, the MCP server and bees doctor alike. To pick up the
bot’s issues instead, write its login out:
[filter]
assignee = "busybees-bot"
[scheduler]
| Key | Type | Default | Description |
|---|---|---|---|
poll_interval |
duration | "5m" |
How often GitHub is polled. A poll costs two API calls (gh issue list, gh pr list); see API budget. Also the minimum gap between two runs of one singleton role. |
rate_limit_backoff |
duration | "15m" |
How long to pause polling after a poll fails with a GitHub rate-limit error, instead of retrying after poll_interval. Also how long the whole factory pauses when a session hits the claude session limit and no usable reset time came with it; see The claude session limit. |
max_developers |
int | 1 |
Concurrent developer workers. Each owns one issue and runs its developer, reviewer and checks stages one after another, so reviewer concurrency follows developer concurrency. 0 means the default; a negative value is rejected. |
max_review_rounds |
int | 3 |
Developer and reviewer rounds before an issue is escalated with bees:needs-human. 0 means the default; a negative value is rejected. |
retries |
int | 1 |
Extra attempts a session gets after failing for infrastructure reasons: it timed out, ran out of turns, hit an API error or rate limit, or the agent crashed. A session that ran and reported with bees done, failed included, is not retried, and neither is one that hit the claude session limit. 0 disables retrying; 0 to 5. See Escalation. |
retry_delay |
duration | "10m" |
Wait before a retry. "0s" retries at once; a negative value is rejected. |
retry_with_fallback |
bool | true |
Run the retry with the role’s fallback_model as its primary model. A role without one reruns as it was. |
triage_batch_size |
int | 5 |
Most issues handed to the project manager in one session. 0 means the default. |
notes_consolidate_every |
int | 10 |
Sessions a role runs between two in which it is also asked to consolidate its notes. 0 means the default; a negative value is rejected. |
notes_max_bytes |
int | 32768 |
Ask for consolidation early, whatever the session count, once a role’s notes are larger than this. Measured in the backend notes.backend names. 0 means the default; a negative value is rejected. |
dispatch_order |
string | "small-first" |
Which bees:ready issue a free developer takes next: small-first, oldest or large-first. Ties go to the older issue, and an issue without a size ranks as m. Issues carrying bees:priority come first whatever this says, and issues already in flight (bees:in-progress, bees:review, a bees:ready issue with an open pull request, a bees:approved issue whose checks were interrupted) are resumed before new work. See Sizing. |
max_large_in_flight |
int | 1 |
How many bees:size/l issues developer workers may hold at once. A large issue over the cap is skipped and the worker takes the next issue that fits. 0 means no cap; a negative value is rejected. |
pr_fix_conflicts |
bool | true |
Hand an open pull request that conflicts with the default branch back to its developer: the developer is mailed from orchestrator to merge, resolve, test and push, and an approved issue goes back to bees:ready ahead of new work. See Conflicts with the default branch. |
pr_keep_updated |
bool | false |
Do the same when a pull request is merely behind the default branch. |
stacked_prs |
bool | false |
Build a blocked_by chain of work items under the same feature as a stack of branches and pull requests, each depending on its predecessor’s, instead of independently. false keeps every work item branching from the default branch on its own, as it does today. See Stacked features. |
review_assigned_prs |
bool | false |
Ask the reviewer for one pass over every open pull request in the filter whose head branch does not start with project.branch_prefix, without waiting for bees:review-requested. With filter.assignee set, that is the pull requests assigned to the factory. A new push to one earns another review. See Asking for a review of any pull request. |
report_factory_errors |
bool | false |
Let a role record a draft issue about an error the factory itself caused: a tool that misbehaved, a prompt that contradicted the code, an orchestrator mislabel it had to work around. The report_factory_error tool writes the draft, scrubbed by the role of repository names, tokens, paths and people, to <state_dir>/feedback/<id>.json, and every full pass files the queue against the busybees repository: a report that looks like an issue already there is a comment on it, one that looks like nothing is a new issue with no label and no assignee. Off, the tool records nothing, says so, and nothing is filed. |
feature_proposals |
bool | true |
A feature issue a bee creates is a proposal: it carries bees:proposal and a person approves it by removing the label. false lets a bee break its own features down without a person’s approval: bees issue create --feature writes no bees:proposal, and --parent and bees issue link do not refuse a parent for carrying one. See Feature issues. |
notify |
string list | [] |
GitHub logins and org/team slugs the factory turns to when it needs a person. No leading @, at most one /. See Notifying a person. |
product_manager_interval |
duration | "1h" |
Minimum time between product manager runs. Unread mail in its inbox starts one earlier. |
qa_interval |
duration | "30m" |
Minimum time between QA runs. QA runs when something was merged since its last run (the first run always happens); mail in its inbox starts one earlier. The merged-PR query runs at most once per interval. |
max_cost_per_issue |
float | 0 |
USD one work item may cost across every session run for it. 0 is unlimited; a negative value is rejected. See Cost budgets. |
max_cost_per_day |
float | 0 |
USD the whole factory may spend over a rolling 24 hours. 0 is unlimited; a negative value is rejected. |
max_cost_per_day_resume_percent |
float | 100 |
How far the rolling 24 hours has to fall back before dispatch starts again, as a percentage of max_cost_per_day: at 80 a factory paused at $100.00 stays paused until the window is under $80.00, rather than resuming at $99.99 and going straight over again. 100 (the default) resumes as soon as the window is under budget, 0 means that default, and a value outside 0-100 is rejected. See Cost budgets. |
max_cost_per_session |
float | 0 |
USD one session may cost. 0 is unlimited; a negative value is rejected. |
keep_workspaces |
bool | false |
Leave temporary worktrees on disk after a session, for debugging. |
workspace_root |
string | "" |
Directory temporary worktrees are created under. Empty means bees under the system temp directory. |
work_hours |
string | "" |
Daily window during which GitHub is polled every poll_interval, as "HH:MM-HH:MM" on a 24-hour clock. Empty polls around the clock, and the three keys below are ignored. See Work hours. |
off_hours_poll_interval |
duration | "1h", or poll_interval when that is longer |
How often GitHub is polled outside work_hours. Must be at least poll_interval. |
work_days |
string list | ["mon","tue","wed","thu","fri"] |
Days the window applies to, as lowercase three-letter names from mon to sun. At least one, and each must be a known day. |
timezone |
string | "" |
IANA name the window is read in, such as "America/New_York". Empty means the machine’s local time. |
Notifying a person
By default the factory and the people it works for share one GitHub account, so
nothing it writes notifies anybody: you wrote the comment, and GitHub sends no
mail for your own. notify says who to reach:
[scheduler]
notify = ["kpenfound", "myorg/bees-team"]
- The
bees:needs-humanescalation comment starts with@kpenfound @myorg/bees-team. See Escalation. - The product manager’s
bees:questioncomments start with the same line. - A pull request the reviewer moved to
bees:approvedis waiting for a person to merge it, so a review is requested from every entry.
The review request is best effort: a failure is logged and the pull request
still reaches bees:approved. GitHub refuses to request a review from a pull
request’s own author, and with a shared account the configured login usually
is the author, so a login often gets the mention and not the request. Teams
are always accepted; list one if you want the request too.
Work hours
Most polling exists to notice what people did: new issues, feedback, reviews,
merges. Outside working hours that is rare, so work_hours polls GitHub less
often then:
[scheduler]
poll_interval = "5m" # inside the window
off_hours_poll_interval = "1h" # outside it
work_hours = "09:00-18:00"
work_days = ["mon", "tue", "wed", "thu", "fri"]
timezone = "America/New_York"
Only the GitHub polling cadence changes. The scheduler still ticks every
poll_interval, and a finished session wakes it in between; a tick that is not
due for a poll runs a local pass over the last poll’s issue and PR lists (see
The scheduler loop). Everything driven by
the local mailbox, which is the developer and reviewer loop, the checks stage
and an answered question moving bees:blocked back to bees:ready, runs at
full speed at every hour. bees tick and bees exec ignore the window.
The poll before the window opens is scheduled for the moment it opens, so the
first poll of the day is at 09:00, not an interval later.
A rate limit never speeds polling up. After a rate-limited poll the next one is
due after the longer of rate_limit_backoff and the interval in force, so off
hours with off_hours_poll_interval = "8h" that is 8h.
A window whose start is later than its end wraps midnight and belongs to the
day its start falls on: "22:00-06:00" with work_days = ["fri"] covers
Friday 22:00 through Saturday 06:00 and nothing else. A window whose start
equals its end is rejected, as is a window that is not "HH:MM-HH:MM", an
unknown or empty work_days, a timezone the zoneinfo database does not know,
and an off_hours_poll_interval shorter than poll_interval.
bees status prints a work hours: line either way, with the cadence in force
and when the next poll is due. See bees status.
API budget
The orchestrator is frugal with the GitHub API, because the sessions call gh
freely on top of it:
| What | Cost | When |
|---|---|---|
| A poll | 2 calls (gh issue list, gh pr list) |
every poll_interval, or every off_hours_poll_interval outside work_hours |
| Human PR feedback | 3 calls per PR (reviews, review comments, comments) | only for PRs whose updatedAt moved since the last look |
| Human issue comments | 1 call per issue | only for issues whose updatedAt moved since the last look: bees:in-progress, bees:review, bees:approved and bees:blocked unconditionally, and, once [github] names an account to mention, bees:triage, bees:ready, bees:feature and bees:feedback too, to look for an @-mention. A pass with no [github] login configured, or one that sees an issue with no recorded time, records the time and fetches nothing |
| Product-manager has-work check | 1 issue view per feedback or feature issue |
only for issues whose updatedAt is newer than the last product manager run. Noticing that a feature’s sub-issues have all closed costs nothing: it compares the numbers recorded on the last run with what the poll found open |
| Product-manager run | 1 issue view per open feedback or feature issue, 1 REST call per open feature (sub-issue progress) and 1 GraphQL call per open work item (parent feature) |
every run |
| Planning mode | 1 extra issue view per bees:planned issue the has-work check did not already fetch |
every product manager run while an issue is agreed and not yet acted on |
| QA merged-PR check | 1 call | at most once per qa_interval |
| Checks | 1 call per poll of a checks stage, 2 when the branch requires no check | every roles.reviewer.checks_poll_interval while waiting |
| Visibility backstop | 2 list calls | after every session |
| Refreshing what a session changed | 1 issue view per issue |
after a session, for each issue it created or relabelled through the MCP server; none when it changed no issue |
| The marker audit | 1 comment read per issue or pull request | after a session, for its issue, the pull request it was given or opened, and each issue it changed, only with [github] set, and nothing at all without it |
| Parent feature lookup | 1 GraphQL call per triage item, per open work item and per developer session | per project manager run, product manager run, developer session |
bees issue create --parent |
3 calls (parent details, create, attach as sub-issue); --related 2; plain 1 |
whenever a role files an issue |
| Worker stage transitions | a few issue view, pr view and issue edit calls |
per transition |
An idle factory costs two calls per poll. When GitHub does rate-limit the
process, polling pauses for rate_limit_backoff and tries again.
What the factory spends on Anthropic is a separate budget, capped by the three
max_cost_* keys.
Cost budgets
All three budgets are 0 by default, which is unlimited. They are spent
against the session ledger, the numbers
bees cost reports,
so a retried session counts like any other:
[scheduler]
max_cost_per_issue = 25.00 # every session run for one work item
max_cost_per_day = 100.00 # whole factory, rolling 24 hours
max_cost_per_session = 10.00 # a single session
A running session is never interrupted on cost. Each budget is enforced at the first moment the factory can act on it:
- Per issue, between the stages of a developer worker. The session that took
the issue over budget finishes and its work stays on the branch; the worker
then stops and the issue is escalated with what it spent (“Issue #12 has
cost $26.40 across 7 sessions, over the
max_cost_per_issuebudget of $25.00”). - Per day, before anything is dispatched. At or over the budget the scheduler
keeps polling and reconciling labels but starts no session; workers already
running finish their loop. Dispatch starts again once the window has fallen
back to
max_cost_per_day_resume_percentof the budget, by default the budget itself, so at80a factory paused at $100.00 resumes under $80.00 rather than at $99.99. The pause is logged once, the release names the threshold it crossed, andbees statusnames the pause while it lasts. - Per session, after the session ended. An over-budget session is treated as
failed whatever it reported, so it is retried once, with the role’s
fallback_modelwhenretry_with_fallbackis on. Two over-budget sessions in a row for one work item escalate it: the role’smax_turnsortimeoutis the wrong shape for that work.
Budgets are money, not turns: max_turns already caps how long one session may
go on.
The claude session limit
Every role shares one Anthropic account, so when one session runs out of
capacity the whole factory has. claude reports it two ways and either counts:
a rate_limit_event in the session’s stream whose status is neither allowed
nor allowed_warning, or, from a session that failed without reporting an
outcome, a result text naming a session or usage limit (“You’ve hit your
session limit · resets 11:50pm (America/Detroit)”). The text is only read this
way when the session reported nothing, because it is the session’s own prose.
A session that ends that way is not retried, since every attempt would hit the same wall, and its issue is not escalated, since the limit says nothing about the work: the issue keeps its state label and is picked up afterwards. The scheduler pauses all dispatch, developers and singletons alike, until the limit resets. Running sessions finish on their own, and polling and label reconciliation carry on.
The pause lasts until the reset time the event carried, with two limits: a
reset that is missing or already past falls back to rate_limit_backoff, and
one more than 8 hours ahead is clamped to 8 hours. The pause is logged when it
starts and when it lifts, and bees status names the time it lifts. It is held
in memory only, so restarting bees run clears it and the first session
re-learns the limit.
[logging]
[logging]
format = "text" # text | json
level = "info" # debug | info | warn | error
| Key | Type | Default | Description |
|---|---|---|---|
format |
string | "text" |
Console log format: text or json. |
level |
string | "info" |
Console log level: debug, info, warn or error. |
Logging is a property of the bees process, so the table is top-level and
applies to every command. It is the lowest-priority source: a flag beats an
environment variable, which beats bees.toml, which beats the default. So
bees run --log-format text gives a readable terminal in a project whose file
says json, and -v wins over level = "info".
Commands that never read bees.toml, such as bees version and bees done,
and any command run against a file that fails to load, use the flag,
environment and default settings only.
There is no quiet key. --quiet is a shorthand for one invocation; level =
"warn" is the service-shaped equivalent, and it also drops the one-line
session summaries that --quiet keeps.
bees run in a terminal draws the live view instead of logging and silences
console logging while it is up; --no-tui, a redirected stdout and every
other command log as this table says. The bees.log file in the state
directory is not configurable: it gets every record at debug level, in JSON.
See bees run.
[notes]
[notes]
backend = "file" # file | neo4j
# neo4j_url = "https://memory.neo4jlabs.com/v1"
# neo4j_api_key = "$BEES_NEO4J_API_KEY"
| Key | Type | Default | Description |
|---|---|---|---|
backend |
string | "file" |
Where the notes_read and notes_write tools keep a role’s notes: file (the state directory) or neo4j (Neo4j Agent Memory). |
neo4j_url |
string | "" |
The base URL of the Neo4j Agent Memory REST API, including its version segment: the hosted service’s (https://memory.neo4jlabs.com/v1) or a self-hosted deployment’s. Required, and must be an http(s) URL, with backend = "neo4j". |
neo4j_api_key |
string | "" |
An API key for neo4j_url, written as a "$VAR" reference so the secret is not in this file. Required with backend = "neo4j"; a reference that expands to nothing fails to load, naming the variable. Redacted as written in bees config show. |
bees neither runs nor embeds Neo4j Agent Memory; with backend = "neo4j"
the notes tools talk to the REST API at neo4j_url, and so do bees status
and the scheduler consolidation trigger when they measure a
role’s notes. neo4j_url and neo4j_api_key are ignored with
backend = "file", so they can stay in the file across a switch.
bees notes show|edit|reset|add are the exception: they always act on the
state directory’s file, whatever backend is.
[global] and [roles.<name>]
[global] and each [roles.<name>] table take the same keys. The role name
is one of product_manager, project_manager, developer, reviewer, qa.
The CLI accepts aliases such as pm and dev; the TOML keys do not.
| Key | Type | Default | Description |
|---|---|---|---|
prompt |
string | "" |
Text appended to the role’s built-in prompt. |
prompt_file |
string | "" |
Path, relative to bees.toml, whose contents are appended after prompt. The file must exist when the file loads. |
skills |
string list | [] |
Skills by git URL. See Skills. |
skills_refresh |
string | "24h" |
[global] only. How stale a skill clone may get before it is pulled when a session needs it: never, always or a duration. |
mcp.<name> |
table | MCP servers keyed by name. See MCP servers. | |
model |
string | "opus" |
Model alias or full id, passed as claude --model, codex --model or opencode --model (provider/model for opencode). The default is claude’s: a codex or opencode role with no model passes none and runs with the model its own configuration names. |
fallback_model |
string | "sonnet" |
Passed as claude --fallback-model, which Claude Code switches to when model has reached its usage limit. Not passed when it equals model. A codex or opencode role has none by default, and one it names is not passed: neither has such a flag, so a retry with the fallback model runs the same model again. |
agent |
string | "claude" |
CLI a session runs as: claude (claude -p), codex (codex exec) or opencode. An unknown value is a load error. See Running a session for what each is started with. |
effort |
string | "" |
Passed as claude --effort when set: low, medium, high or max. A codex role gets it as its model_reasoning_effort setting; codex’s levels stop at high, so max is passed as high. An opencode role ignores it: opencode’s variants are names the model defines, not levels. |
max_turns |
int | 200 |
Agentic turns per session (claude --max-turns). 0 means the default. Codex and opencode have no such limit and a codex or opencode role ignores it. |
timeout |
duration | "45m" |
Wall-clock limit for one session; the session’s process group is killed when it expires. "0s" means the default. |
allowed_tools |
string list | [] |
Passed as claude --allowedTools. A codex or opencode role ignores it. |
disallowed_tools |
string list | [] |
Passed as claude --disallowedTools. A codex or opencode role ignores it. |
shell |
string | the shell bees runs under | Exported into sessions as $SHELL. Claude Code discovers its Bash tool’s shell from $SHELL, so this is the lever, without being a guarantee. Must be an existing file. |
sandbox |
string | "none" |
How much of the machine a session of this role can reach: none, claude or container. See Sandboxing. |
sandbox_image |
string | "" |
The image a container session runs in: it must hold the role’s agent, git and gh. A container role without one or container_use_environment is refused at bees run. See The container mode. |
container_use_environment |
string | "" |
Path, relative to the project repository root, to a dagger/container-use environment definition to build the container sandbox’s image from, instead of sandbox_image. Requires sandbox = "container" and is a load error together with sandbox_image on the same resolved role. See Building from container-use. |
env |
table | {} |
Environment variables exported into every session: the agent, its shell tool and git see them, and so do MCP servers under claude and opencode (codex starts a server with only the variables its entry names). A $VAR value is expanded from the bees process environment when the session starts. A name may not be empty or contain = or a space. See Exported into every session for how it meets the variables bees sets itself. |
enabled |
bool | true |
Roles only. false takes a role out of the rotation. Disabling reviewer makes a developer’s pull request count as approved the moment it is opened, and with auto_merge it goes straight to the checks stage. Under [global] the key is an error. A named set of these decisions is a config template. |
[roles.reviewer] only: checks and auto-merge
The reviewer owns the checks and the merge. These keys are accepted only under
[roles.reviewer]; under [global] or another role they are a load error.
| Key | Type | Default | Description |
|---|---|---|---|
auto_merge |
bool | false |
Merge a pull request the reviewer approved once its checks are green. Off means people merge. A named set of these decisions is a config template. |
merge_method |
string | "squash" |
squash, merge or rebase (gh pr merge --<method> --delete-branch). |
checks_wait |
duration | "1m" |
Wait after approval before polling the checks, because some take a moment to report they started. |
checks_poll_interval |
duration | "2m" |
How often the checks are polled while waiting. One API call each, two when the branch requires nothing. |
checks_timeout |
duration | "30m" |
How long to wait for the checks before escalating with bees:needs-human. |
max_check_fix_rounds |
int | 2 |
Rounds of the reviewer diagnosing and the developer fixing a failed check before escalating. |
pre_review_checks |
bool | true |
Read the pull request’s checks before the first review, so the reviewer starts from a green pull request or is told it is not. Independent of auto_merge. |
pre_review_checks_timeout |
duration | "10m" |
How long that pre-review read waits for pending checks before reviewing anyway. |
[roles.reviewer]
auto_merge = true
merge_method = "squash"
checks_timeout = "20m"
With pre_review_checks on, a developer worker reads the pull request’s
checks once, between opening it and the first review: checks_wait, then a
poll every checks_poll_interval, bounded by pre_review_checks_timeout.
Green, and the review starts with the checks listed in the reviewer’s prompt.
A failure goes to the reviewer in checks mode and then to the developer for a
fix round first, sharing max_check_fix_rounds with the post-approval stage,
and the review happens once the pull request is green. Still pending at the
timeout, no check reported at all, or a read that fails, and the review
happens anyway with the reviewer told nothing was verified. Later review
rounds go straight to the reviewer with no second read. bees status shows
the worker in the pre-review checks stage while it waits.
With auto_merge on, after approval the worker waits checks_wait and polls
the checks every checks_poll_interval. All green merges. A failing check
gets the reviewer a checks-mode session to find the main error and mail it to
the developer, who pushes a fix, and the checks are polled again, up to
max_check_fix_rounds. Still pending at checks_timeout, or a merge GitHub
refuses (branch protection that needs a human review, say), escalates with
bees:needs-human. See Merging.
Which checks are the gate: the required checks (gh pr checks --required)
whenever the default branch requires any, and only those. A repository with no
branch protection requires nothing, and gating on nothing would merge with
nothing green, so there every check the pull request reports is the gate: a
failing one blocks and a pending one is waited for. To take a check out of the
gate, mark the ones that must block as required in the default branch’s
protection rules; bees never reads those rules to change them. With no check
reported at all, it merges after two consecutive empty polls and logs that no
check was reported. bees doctor says which of the three is in force, and
bees status shows it in the worker stage (checks (required), checks
(reported), checks (none)).
[roles.reviewer] only: the review pipeline
The reviewer’s review runs a brief, then one session per angle, then a
judge that merges what they found; these keys size and model each step.
Like the checks keys, they are accepted only under [roles.reviewer].
| Key | Type | Default | Description |
|---|---|---|---|
angles.<size> |
string list | the built-in list for that size | The angles a pull request of that size is reviewed from. <size> is xs, s, m, l or xl; one or more of quick_general, general, docs, test_coverage, acceptance_criteria, side_effects. An unknown size or angle, or an empty list, is a load error. A size this does not name keeps the built-in list. |
brief_model |
string | model |
The model of the session that distills the brief. |
angle_models.<angle> |
string | model |
The model of that angle’s session. An angle not one of the six above is a load error. |
judge_model |
string | model |
The model of the reviewer session that posts the judge’s findings and decides the verdict. |
| Size | Built-in angles |
|---|---|
xs, s |
quick_general, docs |
m, l |
general, docs, test_coverage, acceptance_criteria |
xl |
general, docs, test_coverage, acceptance_criteria, side_effects |
The brief and every angle session run read-only, with no MCP server and no tool that writes, runs or fetches; only the reviewer session that posts the judge’s list and decides the verdict runs with the factory’s tools. An angle that fails is named in that session’s task and the rest are judged; a review where the brief or every angle failed escalates the issue instead.
[roles.reviewer]
angles.xs = ["quick_general"]
angle_models.docs = "haiku"
judge_model = "sonnet"
See Review pipeline for what each angle looks at.
[roles.product_manager] only: minimum issue size
This key describes the product manager, so it is accepted only under
[roles.product_manager].
| Key | Type | Default | Description |
|---|---|---|---|
min_issue_size |
string | "" |
The smallest work item the product manager aims for when it breaks a feature down: xs, s, m, l or xl. Unset, it splits at whatever boundaries the work has. See Sizing. |
[roles.product_manager]
min_issue_size = "m" # fewer, larger work items per feature
It is a hint on the split, not a rule: nothing checks the size of an issue that gets created, and the project manager still sizes a work item as it finds it during triage. Work that is genuinely smaller still gets its own issue, whoever files it, so a bug found mid-implementation is unaffected.
[roles.developer] only: commit flags, max size and per-size models
These keys describe the developer, so they are accepted only under
[roles.developer].
| Key | Type | Default | Description |
|---|---|---|---|
commit_flags |
string | "" |
Extra flags for every git commit the developer makes, appended to its prompt verbatim. |
max_size |
string | "l" |
The largest work item a developer takes: xs, s, m, l or xl. A bees:ready issue sized above it is moved back to bees:triage for the project manager to split; the project manager is told the limit. See Sizing. |
model_by_size |
table | {} |
The model per work item size, keyed by xs, s, m, l, xl. An unknown key or an empty value is a load error. A size with no entry, and an issue with no size label, uses model. |
[roles.developer]
commit_flags = "--gpg-sign --signoff"
max_size = "m" # anything bigger goes back to triage to be split
[roles.developer.model_by_size]
xs = "sonnet" # a typo fix does not need the strongest model
s = "sonnet"
model_by_size is read once per session from the size label the issue carries
when the developer picks it up: bees:size/xs above runs as --model sonnet,
every other size as the developer’s model. fallback_model is unchanged, and
a retry that runs with it still overrides the size’s choice. The reviewer is
told the size too and always runs its own model.
Signing (--gpg-sign, -S) happens inside a headless session on the machine
running bees, so a signing key and agent must work for that user without a
prompt. --signoff needs user.name and user.email, as any
commit does.
[roles.developer] only: best of N
Best of N runs several developer sessions on the same issue, each on its own branch, and has an assembler session pick the pull request that goes to review. It is off until a size names a count.
| Key | Type | Default | Description |
|---|---|---|---|
best_of_n_by_size |
table | {} |
Attempts per work item size, keyed by xs, s, m, l, xl. An unknown key, or a value below 1, is a load error. A size with no entry, and an issue with no size label, gets one attempt. |
best_of_n_model |
string | "" |
The model every attempt runs when a size fans out. Empty: an attempt resolves its model the way a single session does, through model_by_size and model. |
best_of_n_prompt |
string | "" |
The prompt every attempt runs with. Empty: the developer’s own prompt. |
assembler_model |
string | "" |
The model the assembler session runs. Empty: the developer’s model. |
assembler_prompt |
string | "" |
The prompt the assembler session runs with. Empty: the developer’s own prompt. |
[roles.developer]
best_of_n_by_size = { l = 3 } # three attempts on a large issue
best_of_n_prompt = """
Do not read the other attempts. Solve the issue your own way.
"""
An entry of 1 is one attempt, the same as leaving the size out, so a table
that names no size above 1 is best of N off. A size above 1 runs that many
developer sessions at once for the issue’s first round, each on the branch
<branch_prefix>issue-<n>-attempt-<i> and each in a max_developers slot of
its own, so the count is clamped to max_developers. When every attempt has
ended, one assembler session runs on the issue’s own branch with the attempt
branches listed in its task, puts the result there (one attempt as it stands,
or a synthesis) and opens the pull request from it, which goes to review as
any developer’s does; the attempt branches are then deleted. An attempt that
pushed no commits is listed as not a candidate; when none did, the issue is
handed to a person, bees:needs-human.
[roles.developer] only: mixture of experts
A mixture of experts runs several developer sessions on the same issue, each one to a brief of its own, and has an assembler session combine their work into the pull request that goes to review. It is off until a size names its experts.
| Key | Type | Default | Description |
|---|---|---|---|
moe_experts_by_size |
table | {} |
The experts a work item of that size fans out to, keyed by xs, s, m, l, xl, each an ordered list of names from moe_experts. An unknown size, an empty list, a name moe_experts does not define, or a size that is also in best_of_n_by_size, is a load error. A size with no entry, and an issue with no size label, runs a plain developer round. |
moe_experts |
table | {} |
The experts themselves, one sub-table per name. prompt is what that expert’s session runs with in place of the developer’s own prompt, model the model it runs. Both are optional: an expert that names neither runs the developer’s prompt and resolves its model the way a single session does, through model_by_size and model. |
moe_assembler_model |
string | "" |
The model the assembler session runs. Empty: the developer’s model. |
moe_assembler_prompt |
string | "" |
The prompt the assembler session runs with. Empty: the developer’s own prompt. |
[roles.developer]
moe_experts_by_size = { xl = ["backend", "frontend"] }
moe_assembler_model = "opus"
[roles.developer.moe_experts.backend]
prompt = """
Solve it in the server code.
"""
model = "opus"
[roles.developer.moe_experts.frontend]
prompt = """
Solve it in the interface.
"""
A size that names two or more experts runs that many developer sessions at
once for the issue’s first develop round, one per name and in the order the
list gives them: expert i works on the branch
<branch_prefix>issue-<n>-attempt-<i>, with that expert’s prompt and model,
in a max_developers slot of its own, so the count is clamped to
max_developers and the experts past the clamp do not run. A size that names
a single expert runs the plain developer session that leaving the size out
would run, so that expert’s prompt and model go unused.
When every expert has ended, one assembler session runs on the issue’s own
branch. Its task lists each expert branch and the expert it came from; it
combines their work into one implementation on that branch and opens the pull
request from it, which goes to review as any developer’s does. The expert
branches are then deleted. An expert that pushed no commits is listed as not
a candidate; when none did, the issue is handed to a person,
bees:needs-human.
Sandboxing
sandbox says how much of the machine a session of that role can reach. It is
a [global] key with a per-role override, so the roles that run untrusted code
can be boxed harder than the ones that only read the repository:
[global]
sandbox = "none"
[roles.developer]
sandbox = "container"
| Mode | What a session can reach |
|---|---|
none |
Everything the user running bees can: the home directory, credentials, the network and every other checkout on the machine. |
claude |
Claude Code’s own sandbox: writes to the worktree and the state directory, network to GitHub. See The claude mode and Security. |
container |
A container holding the worktree, the repository’s .git and the state directory, and nothing else of the host. See The container mode and Security. |
none is the default. bees run checks before it starts that every role in
the rotation can have the box it asks for (the programs a claude box needs
on Linux, an agent that runs under it, and a container role’s image,
credentials and engine) and refuses to start while one cannot, naming the
role: a factory that fell back to running that role unboxed would give it
exactly what it was configured to be kept away from. bees exec and
bees tick refuse the same session for the same reason.
bees config show prints the resolved mode per role, and
bees status the mode of the session each worker
is running right now.
See Security for what each mode protects and what it does not, filesystem, network and credentials in turn, including what a sandboxed session’s own GitHub credentials mean for it.
The claude mode
A session in claude mode runs under
Claude Code’s sandbox, with a
settings block bees writes for it. Every shell command, and every process it
starts, runs inside a box the operating system enforces: Seatbelt on macOS,
bubblewrap on Linux. The built-in file and web tools run in the claude
process itself, outside that box, so Claude Code’s permission rules hold them
to the same boundary, and anything those rules would ask a person about is
refused instead.
What the session can reach:
- Writes: the worktree, the state directory, the session’s own temporary
directory and the repository’s shared
.gitdirectory, sogit commitworks in a linked worktree. Anything else is refused,~/.zshrcand a toolchain’s cache under the home directory included, and Claude Code protects its own configuration inside the writable directories too (.claude/,.mcp.json,.git/hooks,.git/config), so a session cannot loosen its own box. - Reads: everything the user running
beescan read, credentials included. The mode fences writes and the network, not reads. - Network:
github.comand*.github.comonly, from shell commands and from theWebFetchtool alike, soghandgit pushover https work. Every other host is refused: a module proxy, a package registry, an ssh remote (no host name resolves inside the box), Docker and Dagger. - Tools: the built-in
beesMCP server and every MCP server of the role run on the host, outside the box, so the mail, issue, outcome andgh-backed tools work unchanged. The[github]token and the role’senvreach the shell inside the box.WebSearchis refused.
On macOS nothing needs installing. On Linux the box needs bubblewrap and
socat on PATH, which bees run checks before it starts. The box is
Claude Code’s, so a role whose agent is codex
or opencode cannot use it: bees run refuses to start, naming the role.
Commit signing
through gpg does not work inside the box, because gpg writes under
~/.gnupg.
To widen the box for one project, commit a .claude/settings.json to the
repository: Claude Code merges its sandbox lists with the ones bees writes.
A Go project on macOS needs its build cache and module proxy, for example:
{
"sandbox": {
"filesystem": { "allowWrite": ["~/Library/Caches/go-build"] },
"network": { "allowedDomains": ["proxy.golang.org", "sum.golang.org"] }
}
}
The settings of the user running bees (~/.claude/settings.json) merge the
same way, and an excludedCommands entry there runs that command outside the
box.
The container mode
A container session is the agent’s command line, unchanged, run inside
docker run. It needs docker on PATH with a daemon that answers, and the
image sandbox_image names on the machine: pull or build it yourself, bees
never pulls. bees run checks all three ahead of the doctor, whatever
--skip-doctor says, and refuses to start naming the role that fails. A role
with container_use_environment instead of sandbox_image has its image
built at session start, and docker build pulls the definition’s
base_image then; see
Building from container-use.
The container sees three things of the host, each bind-mounted at its host
path so every path in the prompts and the environment means the same inside:
the worktree, the repository’s .git (a linked worktree’s .git file points
into it, and commits write there) and the state directory (mail, notes, the
session directory). A role with skills also gets the skills cache,
read-only. Nothing else: no home directory, no other checkout, no credential
store. The session runs as the user running bees (claude refuses to skip
permissions as root), with a HOME of its own on a tmpfs that is gone when
the session ends; a settings directory baked into the image is reached by
setting CLAUDE_CONFIG_DIR in the role’s env.
Its environment is built from nothing rather than from the one bees runs
in, and holds, in this order: the agent’s credential forwarded from the bees
environment when it is set there (ANTHROPIC_API_KEY or
CLAUDE_CODE_OAUTH_TOKEN for claude, OPENAI_API_KEY or CODEX_API_KEY for
codex), the role’s env (which may name the credential itself), SHELL, the
BEES_* variables, the [github] token and git identity, the git
configuration every session runs with plus safe.directory = * and an
insteadOf that pushes an ssh remote over https (the container has no ssh
keys), and HOME. Values reach the engine by variable name, never on a
command line. A container role therefore needs [github] (or GH_TOKEN in
its env) and a credential for its agent; bees run refuses the role
without them, because inside the container there is no other gh login and no
keychain.
The bees binary is not in the container. The built-in MCP server runs on the
host as bees mcp serve --listen, with the environment a session on the host
would have started it with, and the session reaches it over HTTP at
host.docker.internal with a bearer token of its own, carried in the
session’s mcp.json. The tools work unchanged; the session’s prompt does not
offer the bees commands. A stdio MCP server configured in bees.toml starts
inside the container, so its command must be in the image; a remote one is
reached as configured (host.docker.internal reaches a server on the host).
The image must hold the agent, git and gh. A Dockerfile that does:
FROM node:22-bookworm
RUN apt-get update \
&& apt-get install -y --no-install-recommends git curl ca-certificates \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
-o /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main" \
> /etc/apt/sources.list.d/github-cli.list \
&& apt-get update && apt-get install -y --no-install-recommends gh \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code
A role that runs the product (QA, most often) names an image carrying the
product’s toolchain too, under [roles.qa].
While the session runs, <session>/container-id holds the container’s id
and <session>/mcp-server-pid the pid of the built-in MCP server bees
started on the host for it, and the container is named
bees-<session>-<random> and labelled
bees.session=<session directory>, so docker ps --filter
label=bees.session lists a factory’s sessions. Stopping the session (its
timeout, or bees run stopping) removes the container. So does stopping it
from outside: bees kill
and the live view’s k key find a container session through that label and
remove its container, which is what stops the agent: it runs in the
container, not on the host, and killing the docker run client alone
leaves it running. They stop the MCP server too, from its pid file: it runs
on the host in a process group of its own, so a crash that takes bees down
would otherwise leave it holding its port and serving the factory’s tools.
What the box holds: the session cannot read or write anything of the host
outside the three mounts, and cannot reach the host’s credentials or its
other checkouts. What it does not: the session has the network, the bot’s
GitHub token, the Neo4j Agent Memory API key with the neo4j notes backend,
its agent credential, and everything in the image; and the mounted state
directory holds every role’s mail and notes, not only its own.
On Linux the container runs as the user running bees so what it writes stays
theirs, and reaches the host at the docker bridge gateway; the mode has been
exercised on macOS with Docker Desktop.
Building from container-use
sandbox_image is the way to run a container session: point it at an
image you built and published yourself, and bees runs it unchanged. This is
a niche opt-in for a project that already keeps a declarative, buildable
environment definition in the
dagger/container-use format, and
would rather bees build from that than from a hand-built image. Most
projects should keep using sandbox_image instead.
Setting container_use_environment to a path builds the image from
<path>/.container-use/environment.json in the project repository, at
session start, instead of requiring sandbox_image. bees reads only that
file’s format: it does not depend on container-use’s Go package, the
Dagger SDK, or a running Dagger engine. Of the definition’s fields, bees
uses:
base_image: the image the build startsFROM.env:KEY=VALUEentries, set before any command runs.setup_commands, theninstall_commands: oneRUNeach, in that order.container-useitself runssetup_commands, copies its environment’s source tree in, then runsinstall_commands, so an install command there can see a checked-in file such aspackage.json. bees has no such copy step. The worktree is bind-mounted when the container runs, not baked into the image, so the two lists run back to back. An install command that depends on source files being present must tolerate their absence, or move to a role’s own session-start mechanism instead.
workdir, secrets and services are read and ignored, without an error.
The container always runs at the worktree’s host path regardless of
workdir. secrets names Dagger secret-provider URIs bees has no pipeline
to resolve, and services describes more than one container, which the
sandbox does not support.
The built image must still hold the agent, git and gh, the same
requirement a hand-built sandbox_image has. Providing them is the
definition’s setup_commands/install_commands job. The image is tagged
after a hash of the resolved build inputs (base_image, env,
setup_commands, install_commands, in that order), so a definition whose
formatting changes but whose build inputs do not reuses the image already
built for it, and a change to any of those inputs builds a new one.
docker build runs once per definition, the first time a session needs it.
A missing or malformed environment.json, or a failing build, fails that
session with an error naming the path or the build command, not a
bees.toml load error.
How global and role settings merge
| Setting | Rule |
|---|---|
prompt, prompt_file |
Concatenated, separated by blank lines: global prompt, global prompt_file, role prompt, role prompt_file. The result is appended to the role’s built-in prompt under an “Additional instructions from bees.toml” heading, followed by the repository’s project prompt files. |
skills |
Union, global first, order kept, duplicates dropped. |
mcp |
Union by name; a role server replaces a global one of the same name. |
env |
Union by name; the role wins. |
model, fallback_model, agent, effort, max_turns, timeout, shell, sandbox, sandbox_image, container_use_environment |
Role value if set, else global, else the built-in default. |
allowed_tools, disallowed_tools |
Global list followed by the role list. |
enabled |
Role only. |
skills_refresh |
Global only. |
min_issue_size |
roles.product_manager only. |
commit_flags, max_size, model_by_size, best_of_n_by_size, best_of_n_model, best_of_n_prompt, assembler_model, assembler_prompt, moe_experts_by_size, moe_experts, moe_assembler_model, moe_assembler_prompt |
roles.developer only. |
auto_merge, merge_method, checks_wait, checks_poll_interval, checks_timeout, max_check_fix_rounds, pre_review_checks, pre_review_checks_timeout, angles, brief_model, angle_models, judge_model |
roles.reviewer only. bees config show reviewer prints the resolved policy. |
bees config show <role> prints the result.
Only the contents of prompt_file are re-read for every session. Everything
else, prompt included, comes from the bees.toml that bees run loaded when
it started, so an edit reaches no session until the scheduler is restarted.
The built-in role prompts are compiled into the bees binary and need a
rebuild as well as a restart. bees status names the build the running
scheduler was started from, and bees doctor warns when
that build is behind the commit the repository has checked out.
Project prompt files
A project can keep role instructions in the repository instead of in
bees.toml, so they are versioned and reviewed like code, and a branch can
carry its own.
bees/prompts/common.md every role
bees/prompts/developer.md the developer
bees/prompts/product_manager.md the product manager
bees/prompts/common.md is appended to every role’s prompt and
bees/prompts/<role>.md to that role’s. Nothing is configured; a repository
without the directory renders the prompt it would render anyway. The sources
land on the built-in prompt in this order, each under a heading naming where it
came from, so a bees.toml override still wins over what the repository says:
[global]promptandprompt_file[roles.<name>]promptandprompt_filebees/prompts/common.mdbees/prompts/<role>.md
The directory is bees/, without a dot. bees init adds /.bees/ to
.gitignore, so files under .bees/prompts/ would be untracked, the opposite
of instructions reviewed like code.
Sessions read the files from their own worktree at session start, so a session
sees the files on the branch it is working on, and an edit takes effect on the
next session with no rebuild and no restart. bees prompts show <role>
--rendered has no worktree and reads the checkout bees.toml sits in; it says
so when it finds any.
A file bees cannot use, unreadable or larger than 64 KiB, never stops a
session: the session warns, skips that file and runs with the rest. bees
doctor fails on such a file, and on a file no role reads (a misspelled name
such as bees/prompts/develloper.md).
Anyone who can land a commit on a branch can change what the sessions on that
branch are told to do. That is the point of the feature, and it is the same
trust boundary as a CI configuration in the repository: review changes to
bees/prompts/ as you would review a workflow file.
Skills
Skills are referenced by git URL and cloned into a cache directory,
~/.cache/bees/repos on Linux (~/Library/Caches/bees/repos on macOS)
unless BEES_CACHE_DIR says otherwise. They reach Claude
Code as plugin directories (claude --plugin-dir), so the worktree is never
modified. Skills in the repository’s .claude/skills/ and in
~/.claude/skills/ are available without being listed.
skills = [
"https://github.com/acme/skills", # whole repository
"https://github.com/acme/skills#skills/tdd", # one directory inside it
"https://github.com/acme/my-plugin@v1.2.0", # pinned tag or branch
"git@github.com:acme/private-skills.git", # ssh works too
]
The URL is <git-url>[@<ref>][#<sub/dir>]. The selected directory is one of
three layouts, checked in this order:
- A Claude Code plugin, with
.claude-plugin/plugin.json. Used as-is. - A single skill, with
SKILL.mdat its root. Wrapped in a generated plugin exposing that skill. - A skills collection, with a
skills/directory. Wrapped in a generated plugin exposing every skill in it.
Anything else is an error. Clones and wrappers are reused; a wrapper is
rebuilt only when it is missing or its #sub/dir changed, because a session
may be running with it.
A clone is pulled (git pull --ff-only) when a session needs it and it was
last fetched more than skills_refresh ago, 24h by default. "always" pulls
before every session, "never" never pulls. A failed pull is logged as a
warning and never stops a session; a reference pinned with @tag is a
detached checkout and cannot be pulled, which is the point of pinning.
bees skills list shows the cache and
bees skills update pulls everything now, whatever the policy says.
MCP servers
A claude session is given the servers in a per-session --mcp-config file
loaded with --strict-mcp-config, so it sees the servers configured here plus
the built-in one, and none of your own. A codex session is given the same
servers as -c mcp_servers.<name>.<key>=<value> overrides on its command
line, next to whatever ~/.codex/config.toml configures. Codex starts a
stdio server with a fixed handful of variables plus the entry’s own env,
not with the session’s environment. An opencode session is given the same
servers as the mcp table of a per-session opencode.json, handed to it
through OPENCODE_CONFIG, next to whatever its global configuration and
the project’s own opencode.json name.
bees is reserved. Every session gets a server called bees carrying the
factory’s own tools; see bees mcp serve.
It needs no configuration and cannot be turned off. Defining
[global.mcp.bees] or [roles.<role>.mcp.bees] fails to load with
mcp server name "bees" is reserved for the built-in server.
| Key | Description |
|---|---|
type |
stdio, http or sse. Defaults to stdio when command is set, http when only url is. |
command |
Executable of a stdio server. |
args |
Arguments for command. |
env |
Environment of the server process. $VAR and ${VAR} are expanded from the bees process environment. |
url |
Endpoint of an http or sse server. |
headers |
HTTP headers for a remote server; $VAR expansion applies. |
Either command or url is required.
[global.mcp.github]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_PERSONAL_ACCESS_TOKEN = "$GITHUB_TOKEN" }
[roles.qa.mcp.browser]
type = "http"
url = "https://mcp.example.com/browser"
headers = { Authorization = "Bearer $BROWSER_MCP_TOKEN" }
Examples
Solo project, two developers
version = 2
# repo and default_branch are derived from the origin remote.
[filter]
label = "bees"
[scheduler]
max_developers = 2
qa_interval = "1h"
[global]
prompt = """
Use conventional commits. Never add a dependency without a comment saying why.
"""
[roles.developer]
commit_flags = "--signoff"
[roles.reviewer]
model = "sonnet"
prompt = "Be strict about error handling and test coverage."
auto_merge = true
checks_timeout = "20m"
[roles.qa]
skills = ["https://github.com/anthropics/skills#skills/webapp-testing"]
timeout = "30m"
Team repository, only work assigned to me
version = 2
[project]
remote = "upstream" # my origin is a fork; the team repository is upstream
[filter]
label = "kyle-bees" # the kyle-bees:* labels are mine
require_label = false
assignee = "@me"
[scheduler]
product_manager_interval = "4h"
[global]
prompt_file = "docs/engineering-conventions.md"
[roles.product_manager]
enabled = false # the team's own product manager owns the roadmap
[roles.developer]
prompt = "Only touch files under services/billing unless the issue says otherwise."
Requirements
bees run, tick, exec, status, issue create and issue link check
the tools they drive before doing anything and refuse to start when one is
missing or too old; bees init checks gh.
| Tool | Minimum | Why |
|---|---|---|
gh |
2.50.0 | gh pr checks --json (2.50.0) and gh api --slurp (2.49.0). |
Claude Code (claude) |
2.1.76 | claude --name (2.1.76); --append-system-prompt-file, --effort, --plugin-dir, --strict-mcp-config and --fallback-model are older. |
The Claude Code check only runs when at least one enabled role resolves to
agent = "claude", the default (see
agent). A factory where every enabled role is
agent = "codex" or agent = "opencode" never runs it, so claude does
not need to be installed.
Set BEES_SKIP_VERSION_CHECK=1 to run with an unsupported version anyway.
Environment variables
Honoured by the bees command
| Variable | Effect |
|---|---|
BEES_CONFIG |
Path of bees.toml when --config is not given. Set inside sessions. |
BEES_CLAUDE_BIN |
The claude executable to run. Default claude on PATH. |
BEES_CODEX_BIN |
The codex executable to run for a role whose agent is codex. Default codex on PATH. |
BEES_OPENCODE_BIN |
The opencode executable to run for a role whose agent is opencode. Default opencode on PATH. |
BEES_CACHE_DIR |
Cache directory for skill clones and generated plugins. Default ~/.cache/bees on Linux, ~/Library/Caches/bees on macOS. |
BEES_SKIP_VERSION_CHECK |
When non-empty, skip the gh and claude version checks. |
BEES_STATE_DIR |
bees mail and bees notes use this state directory without loading bees.toml, unless --config is given, in which case that file’s state directory wins. Set inside sessions. |
BEES_SESSION_DIR |
Where bees done writes outcome.json; bees done refuses to run without it. Set inside sessions. |
BEES_ROLE |
Default --from of bees mail send, and the role whose bees done statuses are validated. Set inside sessions. |
BEES_ISSUE, BEES_PR |
Defaults for the --issue and --pr flags of bees mail send and bees done. Set inside sessions. |
BEES_LOG_FORMAT, BEES_LOG_LEVEL |
Fallbacks for --log-format and --log-level. A flag beats them, and they beat [logging]. |
The variables marked set inside sessions are the only ones a session
inherits. BEES_CLAUDE_BIN, BEES_CODEX_BIN, BEES_OPENCODE_BIN,
BEES_CACHE_DIR, BEES_SKIP_VERSION_CHECK, BEES_LOG_FORMAT and
BEES_LOG_LEVEL configure
the bees process you start
and are not passed on, so a bees command a session runs itself sees their
defaults. To give sessions one of them, put it in
[global.env].
Exported into every session
A session runs with the bees process environment, minus every BEES_*
variable that process inherited, plus:
| Variable | Value |
|---|---|
configured env |
Every [global.env] and [roles.<name>.env] entry, $VAR-expanded. Set first, so everything below wins over it. |
BEES_ROLE |
The role name (developer, reviewer, …). |
BEES_SESSION_DIR |
This session’s directory: prompts, transcript, outcome.json. |
BEES_STATE_DIR |
The state directory. |
BEES_CONFIG |
Path of bees.toml. |
BEES_REPO |
owner/name. |
BEES_LABEL |
filter.label. |
BEES_ISSUE |
The issue the session works on, when any. |
BEES_PR |
The pull request, when any. |
BEES_BRANCH |
The checked-out branch, when any. |
BEES_BIN |
Path of the bees executable. Its directory is also prepended to PATH, so a session can run bees mail and bees done. Not set in a container session, which has no bees binary. |
BEES_REVIEW_MODE |
checks in a reviewer session that diagnoses failed checks; unset otherwise. |
SHELL |
The configured shell, when set. |
GH_TOKEN |
github.token, when set, so the session’s gh acts as the factory. |
the variable github.token names |
When github.token is a "$VAR" reference, that variable, holding the resolved token. The bees commands a session runs load bees.toml themselves, and a reference that expands to nothing is a load error, so the name has to survive the BEES_* strip. It is set in the environment only; nothing writes it into the session directory. |
the variable notes.neo4j_api_key names |
The same, for a "$VAR" notes.neo4j_api_key, with notes.backend = "neo4j": the value the scheduler resolved, so the session’s own notes_read and notes_write can load [notes]. |
GIT_AUTHOR_NAME, GIT_COMMITTER_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_EMAIL |
github.git_name and github.git_email, when set. |
GIT_CONFIG_COUNT, GIT_CONFIG_KEY_n, GIT_CONFIG_VALUE_n |
push.autoSetupRemote=true and push.default=current, so a plain git push works on a fresh branch without touching the clone’s git config; with github.token set, also an empty credential.helper followed by credential.helper=!gh auth git-credential, so an https push authenticates as the factory rather than through your stored credentials. Left alone when GIT_CONFIG_COUNT is already in the bees environment. |
BEES_* variables are always set by bees for each session and never inherited,
so a session started from inside another one, by a nested bees run or bees
exec, never sees a stale issue, PR or branch. The BEES_* names bees puts
back after the strip are the ones a "$VAR" github.token and a "$VAR"
notes.neo4j_api_key read.