Skip to the content.

How bees run works, for the person running it: what one pass of the scheduler does and in what order, how a developer worker carries an issue from bees:ready to a merged pull request, what a session is and what it sees, how the roles talk to each other, and what the state directory holds when something looks wrong. For the label state machine see workflow.md, for each role roles.md, for every bees.toml key configuration.md and for the commands cli.md. The package layout and the test-suite are contributor material and live in CONTRIBUTING.md.

The scheduler loop

bees run starts by creating the state directory, pruning stale worktree metadata in the clone, and creating any workflow label the repository is missing: one gh label list, then one gh label create per name it does not find, matched case-insensitively. A label that exists is left exactly as it is, recoloured or not, and a failure here only warns. It then logs scheduler started with the build it runs (the role prompts are compiled into that build; see Prompts under Running a session) and ticks until it is told to stop.

Ticks. A tick comes every scheduler.poll_interval (default 5m), and sooner whenever a local event wakes the loop (Waking up, below). Each tick is either a full pass or a local pass. A full pass runs when the tick is at or past the next scheduled GitHub poll, and schedules the one after it: poll_interval later, or off_hours_poll_interval later when scheduler.work_hours is set and the moment falls outside the window (see Work hours). When the window opens before that interval would elapse, the next poll is scheduled for the opening, so the work day starts on time. Without work_hours every scheduled tick is a full pass, and the local passes are the ones a wake asks for. When the poll fails with a rate-limit error (the message names a rate limit, abuse detection, an overloaded service or a usage limit), the next poll waits scheduler.rate_limit_backoff (default 15m) if that is longer than the interval in force.

Stopping. Ctrl-C, and q in the live view, is the cool-down: polling and dispatch stop, and the work in flight finishes: every running session, and every issue a developer worker holds, on through the stages it has left. A second interrupt stops the running sessions too. Both are described under Stopping in Running a session.

A full pass is:

  1. Poll. gh issue list and gh pr list with the filter’s query (label, assignee, milestone, creator): two calls, or four under a filter.creator that is not the account the factory acts as, because --author takes one login and the factory’s own items must stay visible (github.Query.Self). Every open issue is bucketed by its state label (triage, ready, in-progress, blocked, review, approved, needs-human, or none), each bucket sorted oldest first. An issue carrying bees:feedback or bees:feature is set aside for the product manager instead, so it never gets a state label. The queue counts (the buckets, plus feedback, features, proposals and open_prs) go into status.json. A ready issue that declares a blocker still open is listed there as waiting on it and is not dispatched (see Dependencies), unless scheduler.stacked_prs lets it build on the blocker: a blocker under the same feature whose pull request is open holds nothing back.
  2. Comments on an in-flight issue. For every issue in in-progress, review, approved or blocked whose updatedAt moved past the issue’s issue_human_seen_at clock, the comments written since that clock are fetched (one call) and what people wrote goes out as one message from human (issue == N) to the role that can act on it: the developer for in-progress and approved; the developer and a copy to the reviewer for review, so the round in flight sees it; and for blocked, whoever asked the question, read off the worker’s bookkeeping: a recorded branch or pull request means a developer session asked, and reconcile then moves the issue back to ready; nothing recorded means triage asked, so the project manager gets it. A comment is a bee’s, and dropped, when its last line is a <!-- bees:<role> --> marker or its author is the login [github] gives the factory. With [github] unset, bees and people share one account and the marker is the only signal; only the last line counts, so a person quoting the bee they answer still gets through. The first pass that sees an issue in one of those four states with no clock records the poll time and delivers nothing: a zero clock must not mean “replay every comment this issue ever received”. An issue in triage or ready has its clock refreshed on every pass and delivers one thing only: a comment that @-mentions the login [github] gives the factory, which goes to the project manager. A bees:feature or bees:feedback issue is read the same way, and a mention on one goes to the product manager. Everything else people write on those issues stays where it is, because the session that acts on the issue next renders the whole comment history in its prompt; a mention is a person asking for the role now instead. With [github] unset the factory has no name of its own to mention, and none of them deliver anything. That refresh is what makes an answer to a blocking triage question arrive: the issue was observed in triage before it could be blocked, so it has a clock by the time it is blocked. What the mail adds over the comment history in a prompt is that the comment is fresh, that it is a person’s, that it reaches the reviewer, that it unblocks a blocked issue, and that it wakes the loop. See Commenting on the issue.
  3. Feedback on a pull request. For every open pull request whose closing issue is visible, when its updatedAt is later than the issue’s human_seen_at clock (or the pull request’s creation time), its reviews, inline review comments and conversation comments are fetched with gh api --paginate (three calls). Bee comments and empty approvals are dropped by the rule above. The rest go to the developer as one message from human (issue == N, pr == M) whose body carries each item’s id and the gh command to reply to it, and the clock advances to the newest item. An approved issue that received feedback goes back to ready and its pull request loses bees:approved, so a developer worker picks it up in step 7, unless a worker still owns the issue (the checks stage), whose labels are left to that worker. Issue comments are delivered before pull request feedback on purpose: an approved issue that gets both leaves the in-flight buckets here, after step 2 has read them. The two clocks are separate so neither stream suppresses the other.
  4. Merge state. gh pr list already reports mergeable, mergeStateStatus and the head commit, so this costs no call. For an issue in review or approved, a CONFLICTING pull request (with scheduler.pr_fix_conflicts) or a BEHIND one (with scheduler.pr_keep_updated) gets the developer one message from orchestrator (issue == N, pr == M) asking it to merge the default branch, resolve, test, push and report pr-updated. The head commit is recorded as conflict_notified_sha, so one head is mailed about once; a push changes the head and, if it still conflicts, is notified again. An approved issue goes back to ready as in step 3. An UNKNOWN or empty merge state means GitHub has not computed it yet, and the pull request is left alone for this poll. See Conflicts with the default branch.
  5. Reconcile. Label transitions driven by local state, in this order:
    • an issue with no state label and neither bees:feature nor bees:feedback is a person handing the factory an idea, not a spec: when the product manager is enabled it gets bees:feedback (and the base label, when the filter does not require it) and joins the product manager’s list in the same pass. A person who wants it built without that hop labels it bees:triage or bees:ready themselves;
    • a bees:blocked issue with unread developer mail about it becomes bees:ready; one with unread project manager mail about it becomes bees:triage. Mail from a person counts as an answer too;
    • a bees:ready issue with no size label gets bees:size/m, the default size (see Sizing);
    • a bees:ready issue sized above roles.developer.max_size (default l, so normally a bees:size/xl one) goes back to bees:triage without a comment, for the project manager to split;
    • every feature is checked for bees:proposal, and the pass that sees a person remove it records the approval: a label edit leaves no comment, and nothing else would bring the feature back to the product manager.

    Sizing runs after unblocking so that an issue which becomes ready in a pass is sized in the same pass. Every edit is also written back to the cached poll that local passes classify from; without that they would see the old labels and repeat the edit.

  6. Pauses. Two conditions stop steps 7 and 8 from starting anything; workers already running finish their loop either way. Each pause is logged once when it starts and once when it lifts, and shown by bees status and in the live view’s header. A cancelled loop context gates the same two steps, so a pass still finishing when the factory was asked to stop starts nothing the cool-down promised not to.
    • Daily cost budget. With scheduler.max_cost_per_day set, the ledger is summed over the last 24 hours before anything is dispatched. Dispatch pauses when the sum reaches the budget and resumes only once it has fallen under max_cost_per_day_resume_percent of it (default 100, which is the plain “under budget” test), so the factory backs off instead of oscillating on the edge. The sum is recomputed from the ledger on every pass; a restart loses only the hysteresis. The other two budgets are enforced elsewhere: max_cost_per_issue between a developer worker’s stages, max_cost_per_session after a session ends. See Cost budgets.
    • Claude session limit. Recorded from a finished session rather than computed here: a session whose last rate_limit_event was blocking, or that failed without reporting an outcome and whose result text names a session or usage limit, pauses dispatch until the reset time the event carried (rate_limit_backoff when it carried none or the time is already past, and never more than 8 hours). The limit is per account, so it holds every role. A session that reported no outcome returns to its worker at once, spending no retry; one that did its work and reported is read normally. The pause is in memory only: after a restart the first session that hits the limit re-establishes it. See The claude session limit.
  7. Dispatch developers. The candidates, in order: issues in in-progress and review that no worker owns (resumed after a restart, never reordered); an approved issue whose worker was killed in the post-approval checks stage or in the developer round those checks sent back, told apart from a pull request waiting for a person by the stage recorded in the state directory and an open pull request on the branch; ready issues that already have an open pull request on their branch (sent back by feedback or a conflict; finished before new work, oldest first); then the rest of ready: bees:priority first, then scheduler.dispatch_order (smallest size first by default), ties by age. Priority reorders the queue and lifts no cap. A ready issue whose declared blockers are still open is skipped without taking a slot. With scheduler.stacked_prs, a blocker that is a sub-issue of the same feature and has an open pull request is not waited for: the worker cuts the issue’s branch from the blocker’s branch instead of the default branch, and the developer’s pull request targets that branch. A bees:size/l issue that is new work waits while scheduler.max_large_in_flight of them are owned; the check runs before a slot is taken, so a held issue does not keep a free developer idle. Each remaining candidate takes a slot from a pool of max_developers (default 1), or one slot per attempt, all of them or none, when its size fans out under best_of_n_by_size or moe_experts_by_size; when the pool cannot supply it the pass stops dispatching. A goroutine runs the worker (The developer worker) and returns the slot when done, and the worker records the issue’s size, which is what the cap counts and what bees status shows. A worker that fails with an error, rather than escalating its issue, backs that issue off for five poll intervals. See Size decides what gets built next. Then the requested reviews: every pull request in the poll carrying bees:review-requested that no session is already reviewing gets a reviewer session in a slot from the same pool, so a review a person asked for never starves a ready issue. With scheduler.review_assigned_prs a pull request whose head branch does not start with project.branch_prefix, one the factory did not write, is dispatched the same way without the label, unless it is a draft or a review has already looked at this head. The label is removed before the session starts, which claims the request: one label is one pass whatever the session does, a failure or a killed scheduler included, and the head commit is recorded in issues/<pr>.json before the session for the same reason. Only a full pass dispatches one, because a local pass classifies the cached pull request list, which still carries a label removed on GitHub. The session is a detached checkout of the head branch, or of the default branch when the remote does not have it, and is recorded like a worker under the pull request’s number. A failed session backs the pull request off for five poll intervals; there is no issue to escalate.
  8. Dispatch singletons. The project manager, product manager and QA each run in a goroutine of their own, at most one session per role at a time, in a detached worktree of the default branch. When a session ends the role is not started again for one poll interval; when it fails, five. What starts each:
    • the project manager: triage issues (it takes the first scheduler.triage_batch_size, default 5, each fetched in full with its parent feature) or unread mail;
    • the product manager: unread mail; never having run; scheduler.product_manager_interval (default 1h) elapsed; a proposal a person approved since its last run; a feature whose every recorded sub-issue has closed; or a fresh feedback or feature issue: one updated since the last run, whose comments are fetched (one gh issue view each) and on which a person had the last word by the bee-comment rule of step 2 (a tie in the same second is broken by the comments’ order, so a person answering right after a bee still counts). A fresh issue carrying bees:question has that label removed on the spot. A proposal (bees:proposal), and an issue in planning mode, counts as fresh only once a person has commented: the creation does not count, because nobody has commented on a proposal a bee has just written and it would otherwise be fresh forever. bees:planned wakes nothing; the issue waits for the interval;
    • QA: unread mail; never having run (the first run looks back seven days); or scheduler.qa_interval (default 30m) elapsed since it last ran or last looked, and something merged since its last run. The merged-PR query runs at most once per interval, recorded as last_check in <state_dir>/qa.json.

    The product manager is shown: the fresh feedback issues; the fresh features, with proposals in a section of their own; the issues in planning mode, and the planned ones that still need acting on, each in a section of its own (a planned feature drops off once it has sub-issues, a planned feedback issue once it is closed; a feature whose sub-issue lookup failed waits for the next run rather than being presented as not yet broken down, and one still carrying bees:proposal stays a proposal); every open feature with its sub-issue progress (one gh api repos/../issues/N per feature); every open work item with the feature it belongs to (one GraphQL query per work item, because the progress summary carries counts, not numbers); and the features whose work is done. That last list costs no GitHub call: every product manager run records each feature’s open sub-issue numbers in <state_dir>/issues/<n>.json, and a later pass notices that every recorded number is absent from the poll. Such a feature is presented once and marked; a recorded set that changes clears the mark, so a feature that gains a sub-issue is presented again when that one closes. A run whose parent lookups did not all answer records nothing, since a partial answer would look like children that closed, and a feature no run has recorded children for waits for the interval.

    Planning mode. The planning section of the prompt lists no breakdown step and the planned section says the scope is settled; the enforced half is that bees issue create and issue_link refuse a bees:planning issue as a parent, as they refuse a proposal while scheduler.feature_proposals is on, so a planning issue grows no sub-issues whoever asks. Neither planning label is ever written by the factory.

    Sub-issues and milestones. Work items are native GitHub sub-issues of their feature. Roles create issues through the issue_create tool (or bees issue create), which labels for the filter and for kind and state, resolves the milestone as the explicit one, else the parent or related issue’s, else filter.milestone, creates the issue, and attaches it to its parent as a sub-issue. The factory never creates, edits or closes milestones; people do, and the bees inherit, or pick among them for the features of an agreed design. See Features, sub-issues and milestones.

  9. File the factory errors. With scheduler.report_factory_errors on, the drafts sessions recorded with report_factory_error (<state_dir>/feedback/) are filed against kpenfound/busybees, the busybees project itself, whatever repository this factory builds. A draft is scored against every issue there, open and closed, by a local word-overlap check that needs no session and no search API: the best match gets a comment saying the problem was seen again, and a draft that matches nothing opens an issue. That issue carries no label and no assignee, so a factory building busybees does not pick its own bug report up as a work item; a person reads and labels it, as with any issue somebody files by hand. A draft is removed from the queue once its call succeeded, and one whose call failed stays for the next pass without holding up the drafts behind it. Off, the queue is not even read.

Local passes. A tick that is not due for a poll, and every wake, runs a local pass: it classifies the issue and pull request lists cached from the last successful poll again (reconcile’s write-back and the refresh at the end of every session keep that cache in step), then runs steps 5 and 6, dispatches developers (never a requested review) and starts only the singletons that have unread mail. It skips the poll, steps 2 to 4, step 9 and the product manager’s and QA’s other has-work checks, all of which read GitHub; the label writes reconcile and dispatch make still happen, because what a local pass protects is the polling budget, not every API call. Until the first successful poll there is nothing cached and a local pass does nothing.

The one read a local pass makes is a confirmation. Its snapshot can be stale (an issue a worker has since finished, one a developer parked in bees:blocked, one a person closed or relabelled), so before spending a session on a candidate the pass fetches that one issue (gh issue view) and drops it unless it is still open and in bees:ready, bees:in-progress or bees:review, or in bees:approved for the interrupted checks stage of step

  1. The fresh copy replaces the cached one, so the next local pass does not ask again. That is one call immediately before a whole session, not one per pass. The mailbox is not GitHub: the developer and reviewer loop, the checks stages and mail-driven label transitions run at poll_interval, and sooner when a wake asks, however the window is configured.

Waking up. Waiting out the poll interval for something that happened locally is downtime, so the loop also listens on a wake channel and runs a local pass for every signal. Three things signal it: a session finishing, a developer worker returning its slot to the pool (a worker runs several sessions before its slot comes free), and the two kinds of message the scheduler sends itself (the merge-state notice of step 4 and the feedback of steps 2 and 3). A wake is never a full pass, so the polling cadence stays exactly what poll_interval and the window say. The channel holds one signal: a burst of finished sessions costs one pass rather than one each, and a full pass drops a pending wake because it does strictly more.

Mail written by another process (bees mail send, or the MCP server attached to a session) cannot signal an in-process channel, and the mailbox is deliberately not watched for changes. It does not need to be: the session that wrote the mail signals when it finishes, and the local pass that follows re-reads the mailbox from disk. Mail a person sends by hand while nothing is running waits for the next tick.

A session’s writes on GitHub cross the same boundary. The MCP server cannot reach the cached issue lists either, so every tool that creates an issue or changes one records its number in <session>/touched-issues.txt, and the scheduler reads that list back when the session ends: one gh issue view per issue on it, written into the cache before the wake is signalled. The local pass that follows classifies from what the session did, so an issue the project manager moved to bees:ready goes to a developer and a sub-issue it filed counts as triage work, without waiting for the poll after the session. A session that changed no issue records nothing and costs nothing, and an issue that has since been closed, or that the filter does not match, is dropped rather than cached: the cache holds what a poll would return. Pull requests are not read back, because the developer and reviewer loop runs inside one worker and finds its own pull request.

The marker audit. A comment posted through the comment tool always carries the role’s <!-- bees:<role> --> marker: the tool appends it. A comment a session posts from its own shell with gh does not, unless the session wrote the marker itself, and that path is outside the orchestrator entirely. So the orchestrator looks afterwards: when a session ends it reads the comments left since it started on its issue, on the pull request it was given or reported opening, and on every issue it touched, and logs a warning naming the item, the role and the comment for each one made by the factory’s login without a marker. It reports and does not rewrite: the login already identifies the comment as the factory’s, and editing a comment after the fact would surprise more than the missing marker costs. With [github] unset there is no login to go by, a comment without a marker is indistinguishable from a person’s, and the audit reads nothing.

API budget. Every poll costs two gh calls. Everything else is gated on what those lists report, so an idle factory stays at two calls per poll (and, with work_hours, at two per off_hours_poll_interval outside the window). Comments cost one call per issue whose updatedAt moved past its clock: the four in-flight states, plus, once [github] names an account to mention, triage, ready and the product manager’s issues. Pull request feedback costs three calls per pull request whose updatedAt moved; the product manager’s freshness check one issue view per feedback or feature issue updated since its last run; QA’s merged-PR query at most once per qa_interval; the checks stages poll gh pr checks every roles.reviewer.checks_poll_interval (default 2m), not every poll; the visibility backstop makes two list calls after each session; the refresh after each session one issue view per issue that session created or relabelled; the marker audit, with [github] set, one comment read per issue and pull request that session could have commented on, the one it opened included; each queued factory-error report two calls, one to list the busybees repository’s issues and one to file or comment; and worker stage transitions make a handful of issue view, pr view and issue edit calls. Sessions call gh on their own on top of this, which busybees does not meter. See API budget.

Once mode. bees tick and bees run --once perform a single pass and then wait for everything it started. --roles restricts dispatch to the named roles; a role with enabled = false in bees.toml is skipped regardless.

status.json is rewritten after every pass and whenever a worker or singleton starts or stops; bees status reads it, the mailbox and the size of each role’s notes (from the backend notes.backend names, so with backend = "neo4j" it asks that service), and asks GitHub nothing. Two of its queue counts carry their detail: needs_human names each escalated issue and why, from the reason the escalation recorded, and approved names each pull request waiting for a person to merge, oldest first. Both are built from the snapshot the counts came from plus one state-directory read per escalated issue, so neither costs a GitHub call. degraded lists the operations that are failing (Degraded operations).

The event stream is the live half of the same picture, for a view running in the same process. A subscriber gets a buffered channel of events: a session started (with the model it runs on, whether that is the role’s fallback, and its directory, which is where its transcript.jsonl is and the one thing a view cannot work out from the name), a session ended (with its outcome, turns, cost and duration), a developer worker moved to another stage, a full pass finished. Events are published beside status.json, never instead of it: the event says something happened, status.json says what the factory looks like. The poll event is published after the write, so a view that re-reads the file when one arrives sees the pass that event is about, never the one before it. No scheduler decision depends on whether anyone is subscribed, and publishing never blocks: an event a subscriber has no room for is dropped, so a view that stops reading loses events instead of slowing a pass down.

The live view is the subscriber. Its Now and Recent panels are built from the session and stage events; Needs human, Approved PRs and Queues are status.json, re-read when an event says it changed. Two things come from a session’s own transcript.jsonl, in the directory the started event named, because no event carries them: the transcript the session view shows, and the turn count the Now panel shows for a session still running (an agent reports its turn count in the event that ends its stream and nothing before it). Beyond stopping the factory, its k key is the one thing it asks the scheduler to do: stop one running session by the name the stream published, through the same path bees kill uses, and escalate the issue it was working on. The mark that leaves behind is what keeps the session’s own worker from retrying it or escalating the issue a second time. The view’s one write is the message a person types in the session view: an ordinary mailbox entry from human, addressed to the role on screen and carrying its issue and pull request, which reaches the next session on that work item. A headless session works to the end of the prompt it was started with and ignores a later turn written to its stdin.

The developer worker

One worker owns one issue from claim to approval (or, with roles.reviewer.auto_merge, to merge), or until the factory gives it up. It is a small state machine with five stages:

stateDiagram-v2
    [*] --> develop
    [*] --> prereview: resumed with nothing recorded, an open PR and label bees:review
    [*] --> review: resumed in the stage the issue's bookkeeping recorded
    [*] --> stack_wait: resumed in the stage the issue's bookkeeping recorded
    [*] --> checks: resumed in the stage the issue's bookkeeping recorded
    develop --> prereview: pr-opened / pr-updated (PR found), before the first review
    develop --> review: a later review round, or pre_review_checks = false
    develop --> checks: pr-opened with the reviewer disabled and auto_merge on
    develop --> stack_wait: pr-opened with the reviewer disabled, on a stacked PR
    develop --> [*]: question (issue to blocked)
    develop --> [*]: failed / no PR (escalate)
    prereview --> review: checks pass / none reported / pending at the timeout / read failed
    prereview --> develop: a check failed, reviewer (checks mode) mailed a fix request
    prereview --> prereview: reviewer re-ran the check (approved)
    prereview --> [*]: fix rounds exhausted / reviewer failed (escalate)
    develop --> prereview: pr-updated while fixing them (returns to prereview)
    review --> [*]: approved, auto_merge off
    review --> checks: approved, auto_merge on
    review --> stack_wait: approved, stacked on another pull request
    stack_wait --> [*]: predecessor approved or merged, auto_merge off
    stack_wait --> checks: predecessor approved or merged, auto_merge on
    stack_wait --> [*]: predecessor closed unapproved (escalate)
    review --> develop: changes-requested, round under max_review_rounds
    review --> [*]: changes-requested at max_review_rounds (escalate)
    review --> [*]: failed (escalate)
    checks --> [*]: checks pass (or none are reported), then gh pr merge
    checks --> [*]: pending at checks_timeout / merge refused (escalate)
    checks --> develop: a check failed, reviewer (checks mode) mailed a fix request
    checks --> checks: reviewer re-ran the check (approved)
    checks --> [*]: fix rounds exhausted / reviewer failed (escalate)
    develop --> checks: pr-updated while fixing checks (returns to checks)

Singleton roles share one path: a detached worktree on the default branch, one session, delivered mail marked read, last_run recorded in <state_dir>/<role>.json.

What the orchestrator checks

Every session ends by reporting an outcome, and an outcome is a claim: the session says what it did. Where the claim implies a side effect somebody else can look at, the orchestrator looks, and a claim it cannot see counts as a failure rather than as work done. Where the claim is a judgment, it is the session’s to make and nothing re-derives it.

Checked, in Go, when the session ends:

A reviewer’s approved in the review loop has nothing to check, because the orchestrator performs the approval itself: it labels the pull request and the issue, and requests a review from scheduler.notify. The findings the session was to post on the pull request are looked for after either verdict, and a session that posted none is the review-post degraded operation rather than a failure: the verdict travels by outcome and mail, and the review on the pull request is for the person who merges.

Not checked, because there is nothing to look at afterwards:

Degraded operations

Most things the scheduler does are best-effort: a failed label edit, assignment or mail update warns and the pass carries on. A warning nobody reads is silence, though, so each of those sites reports under a short, stable operation name (poll, assign, label, reconcile, human-feedback, check-prs, list-created, ledger, write-status, project-prompts/<role>, and so on). The record logs what the site logged plus op=<name>, and keeps a per-operation streak of consecutive failures; a success clears the streak. status.json carries the streaks as degraded, so a broken operation is visible in bees status and the live view instead of only in the log. A mutation whose caller reports the failure itself still records its own streak: making an item visible is three independent calls (label, assign, milestone) joined into one warning naming the item, and each of the three keeps a streak of its own.

At three consecutive failures the streak emits one record at error level to the summary stream a person watching the run sees, and only one: the streak is marked so it is not repeated on every pass. Nothing else changes: no retry, no backoff, no GitHub comment and no mail, because there is no issue to comment on for a factory-wide operation and no role can fix a broken credential or a missing label. Purely informational warnings (a declared dependency cycle, worktree housekeeping, the rate-limit backoff, a session retry) are deliberately not operations: they are already visible elsewhere, or are facts rather than failures. See Degraded operations for what bees status prints.

Running a session

A session is one non-interactive run of the role’s agent, executed inside the worktree. With agent = "claude", the default, it is one claude -p:

claude -p \
  --output-format stream-json --verbose \
  --dangerously-skip-permissions \
  --append-system-prompt-file <session>/system-prompt.md \
  --model <model> --max-turns <n> --name bees-<session name> \
  [--fallback-model <fallback>] [--effort <level>] \
  [--resume <session id> --system-prompt-snapshot off] \
  --add-dir <state_dir> \
  [--allowedTools ...] [--disallowedTools ...] \
  --mcp-config <session>/mcp.json --strict-mcp-config \
  [--plugin-dir <skill plugin dir> ...]

The task prompt is written to stdin. Each line of stream-json is appended to <session>/transcript.jsonl; the final result event supplies the result text, is_error, subtype, turn count, cost and claude session id. The --resume pair is passed when the session continues an earlier one’s conversation, a later round of the developer or the reviewer (see Later rounds).

With agent = "codex" it is one codex exec:

codex exec --json \
  --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check \
  [--model <model>] \
  [-c model_reasoning_effort="<level>"] \
  -c mcp_servers.<name>.command="..." -c mcp_servers.<name>.args=[...] \
  -c mcp_servers.<name>.env.<VAR>="..." ... \
  -

Codex has no flag to append to its system prompt, so the system prompt is written to stdin ahead of the task prompt, separated by a rule; it has no --mcp-config, so every MCP server, the built-in one included, is passed as configuration overrides, one per key; and it has no fallback model, turn limit, tool allow-list or plugin directories, so those settings are not passed (see agent). Its stream is appended to transcript.jsonl the same way: thread.started supplies the session id, each item.completed is one turn, the last agent_message item is the result text, and turn.completed, turn.failed or a bare error event says how it ended. Codex reports tokens, never a cost, so a codex session’s cost is unknown rather than zero.

With agent = "opencode" it is one opencode run:

OPENCODE_CONFIG=<session>/opencode.json \
opencode run --format json --auto --title bees-<session name> \
  [--model <model>] [--session <session id>]

The task prompt is written to stdin. opencode has no flag to append to its system prompt and no --mcp-config, but it reads one more configuration file from the path OPENCODE_CONFIG names, so the session is given <session>/opencode.json: its instructions entry is the rendered system prompt file, which opencode appends to its own system prompt, and its mcp table is every MCP server, the built-in one included, with the session’s BEES_* variables as that server’s environment. The file lives in the session directory, never in the worktree; a project’s own opencode.json is read as well. --auto approves what opencode would otherwise ask about (a write outside the worktree, such as into the state directory), the counterpart of --dangerously-skip-permissions; an explicit deny in the project’s configuration still holds. --session continues an earlier session the way --resume does for claude, and there is no snapshot to switch off: opencode reads the instruction files again on every request. It has no fallback model, turn limit, tool allow-list or plugin directories, and effort is not passed either (its --variant takes a name the model defines, not a level), so those settings are not passed. Its stream is appended to transcript.jsonl the same way: every event carries the session id, each step_finish is one turn and carries what the step cost, the last text event is the result text, and a step_finish whose reason is stop, or an error event, says how it ended. The costs add up to the session’s, a known cost even when a local model makes it zero.

For every agent, stderr is saved to stderr.log when non-empty, and result.json summarises the run. A session that ended without a final event (a signalled process, most often) has no known cost: bees status, the live view and the summary line say so rather than printing zero, and its turns are counted from the transcript’s assistant messages or completed items instead.

Unless GIT_CONFIG_COUNT is already set, the runner also exports git configuration through GIT_CONFIG_KEY_n / GIT_CONFIG_VALUE_n, with GIT_CONFIG_COUNT derived from the entries it built: push.autoSetupRemote=true and push.default=current, so a session can run a plain git push on a branch the workspace created with git worktree add --no-track -b; and, when [github] carries a token, an empty credential.helper followed by credential.helper=!gh auth git-credential, so that an https push authenticates as the factory. The empty value comes first because git asks helpers in configuration order and takes the first answer, and GIT_CONFIG_* is read last: without it the machine owner’s own helper would answer and the push would be theirs. busybees never edits the clone’s git configuration.

The mailbox

A message is one JSON file at <state_dir>/mail/<to-role>/<id>.json:

{
  "id": "20260829T151201-9f3a2b1c",
  "from": "reviewer",
  "to": "developer",
  "subject": "Review round 1",
  "body": "...",
  "issue": 12,
  "pr": 34,
  "created_at": "2026-08-29T15:12:01Z",
  "read_at": null,
  "in_reply_to": ""
}

Messages are addressed to a role, not a session. Delivery rules:

Visibility backstop. After every session the scheduler lists the issues and pull requests created since the session started (gh issue list and gh pr list with created:>=<start>, --state all, two calls), whatever their labels and whoever opened them. Anything carrying the base label, or any <label>:* label, but missing part of the filter is repaired the way the developer worker repairs a pull request it opened: the base label, the configured filter.assignee and, for pull requests only, the configured filter.milestone. The search carries no author qualifier because the items it exists for, a pull request a session opened with its own gh pr create and an item a person opened by hand, are not reliably the account bees acts as, so the label gate decides. Both halves of that gate are needed: a pull request a session just opened carries only the base label, and earns its first <label>:* label at approval. Items with no factory label at all are left alone, and one that cannot be repaired is logged and skipped rather than stopping the others.

A milestone is set on pull requests and never on issues: a milestone on an issue is a person’s decision, and an issue the factory creates gets one through bees issue create, inherited or chosen among the existing ones, while a milestone on a pull request is pure filter bookkeeping.

Writes are atomic (a temporary file, then a rename), ids embed a timestamp so a listing sorts oldest first, and bees mail works from any directory because sessions get BEES_STATE_DIR.

State directory

<state_dir>/                     default .bees/ next to bees.toml
  README.md
  mail/<role>/*.json             the mailbox
  feedback/<id>.json             {id, role, session_dir, title, detail, created_at}: a
                                 draft report of an error the factory caused, written by
                                 report_factory_error with scheduler.report_factory_errors
                                 on, waiting to be filed against busybees
  notes/<role>.md                role memory (`bees notes show|edit|reset|add`)
  notes/archive/<role>-<ts>.md   notes replaced by `bees notes reset`
  reviews/<owner>/<name>/<pr>/<started>/
                                 one review of one pull request, as internal/review writes it:
                                 brief.json, angles/<angle>.json, findings.json
  sessions/<ts>-<name>-<rand>/   system-prompt.md, prompt.md, mcp.json (claude), transcript.jsonl,
                                 stderr.log, outcome.json, result.json, pid,
                                 touched-issues.txt (the issues the session changed on
                                 GitHub, one per line, read back into the cached poll
                                 when it ends), interrupted (written by `bees kill`,
                                 the live view's k key and a hard stop)
  issues/<n>.json                {number, round, pr, branch, check_fix_rounds, worker_stage,
                                 after_develop, pre_review_done, session, human_seen_at,
                                 issue_human_seen_at, conflict_notified_sha, cost, sessions,
                                 proposal, proposal_approved_at, open_children,
                                 complete_reported_at, escalation, escalated_at, updated_at}
  <role>.json                    per-role bookkeeping, one file per role that has run:
                                 {last_run, last_check, sessions, last_consolidated}
  status.json                    live scheduler status for `bees status` (queues, workers,
                                 singletons, pauses, degraded operations, last_poll, last_error)
  ledger.jsonl                   append-only, one JSON line per finished session
                                 {time, role, session, issue, pr, turns, cost_usd,
                                 duration_ms, outcome, error_subtype, timed_out}
  bees.log                       every record of the last scheduler runs as JSON, rotated
                                 at 10 MiB into bees.log.1 and bees.log.2

ledger.jsonl is the factory’s accounting: one line for every session that finishes, whatever it reported, and bees cost sums it. Lines are written with a single append so concurrent workers cannot interleave, and a line that does not parse is skipped on read rather than failing it.

<role>.json carries what the scheduler remembers about a role between runs: when the singleton roles last ran (last_run) and last looked for work (last_check), how many sessions of any kind the role has run (sessions) and the count at which it was last asked to consolidate its notes (last_consolidated). Developer workers share developer.json, so every update to it is a read-modify-write under the scheduler’s lock.

bees.log is written only by the commands that run sessions (run, tick, exec) and always holds every record at debug level, whatever the console flags say. bees issue and bees mail run inside sessions, concurrently with the scheduler, so they never open it.

bees init makes sure the directory is ignored by git: when git check-ignore does not already ignore it (and it lives inside the clone), /.bees/ is appended to the repository’s .gitignore, which is a change to commit. bees.toml itself is meant to be committed. Worktrees live under $TMPDIR/bees/ (or scheduler.workspace_root) and are removed after each worker or singleton run; the skills cache lives outside the state directory (see Skills under Running a session).

Crash recovery (bees kill)

The runner writes the session’s pid to <session dir>/pid right after starting the agent and removes it when the session ends. When bees dies, those files, and a marker in every session’s argv (the --name bees-<session> a claude session is started with; for a codex session, the mcp_servers.bees.env.BEES_SESSION_DIR= override that hands the built-in MCP server its directory), let bees kill find the orphans: it merges the pid files with a ps scan restricted to processes whose executable is claude or codex (directly or through an interpreter), cross-checking pid files against the scan so a reused pid is discarded rather than killed. An opencode session is found through its pid file alone: its argv carries no path of the state directory, so the scan does not know it. Both sources are scoped to one factory: a scanned process counts only when its command line also references this state directory’s sessions/ (a claude session’s argv carries --append-system-prompt-file <sessions dir>/<session>/system-prompt.md, a codex session’s the session directory in that override, matched as a path prefix and also in its symlink-resolved form). Sessions of another project’s factory are never reported, so bees kill run with one project’s config cannot strand another project’s issues.

A session in the container sandbox is found through its container, because its agent runs in the container’s own pid namespace where neither source reaches it. The engine is asked which of its running containers carry the bees.session label (docker ps --filter label=bees.session), and the label’s value, the session directory, says which session each one is and scopes it to this factory the same way. The container engine client the process table does show counts as a session too, so the pid file naming it is not discarded as a reused pid; a session directory recording a container the engine no longer lists has its stale container-id file removed, as a stale pid file is. On a machine with no engine to ask there are no container sessions to find.

The bees binary is not in the container either, so the built-in MCP server of such a session runs on the host, in a process group of its own that the scheduler’s own shutdown does not reach. The runner records its pid in <session dir>/mcp-server-pid and removes the file when the session ends, so a file left behind names a server a crash orphaned: bees kill stops it along with the session’s container and its engine client, and drops a file naming a process that has gone, as it does a stale pid file.

Every session bees kill stops through a pid file or through its container is marked: it writes <session dir>/interrupted naming the kill, so the next session for that issue is told the session was stopped rather than left to guess that the machine crashed (see An interrupted session under The developer worker). A process found only in the process table names no directory and is killed unmarked.

The kill sends SIGTERM to the process group (sessions are started in a group of their own, so MCP servers and shells belong to it), waits --grace (default 5s), then SIGKILL. A container is removed first, with docker rm --force: it outlives the engine client that started it, and the agent is inside it. The built-in MCP server goes last, so the tools stay answerable until what was using them is gone. Any of the three can be all there is left to stop. The command then removes every worktree of the main clone that lives under the workspace root, prunes worktree metadata, deletes leftover workspace directories and resets the worker list in status.json. It refuses to run while the scheduler recorded in status.json is alive unless --scheduler is given. See bees kill.