Skip to content

The bnerd team CLI

The bnerd team command group is the headless entry point for agent teams. It runs in pure line-based I/O — no Bubbletea, no terminal control codes — so it works in pipes, CI, and SSH without a PTY.

Not reachable via bnerd mcp-server

A bnerd team run process is its own process, separate from bnerd mcp-server. The bnerd_team_* MCP tools only resolve a team running in the same process as the MCP server, so a standalone bnerd mcp-server cannot see or drive a bnerd team run invocation — see MCP reality.

Commands

bnerd team list

List all team recipes known to the CLI (from your mission repo and fallback locations).

bnerd team list
# pr-review            Run a multi-role review of a pull request
# cloud-app            Build/change the cloud product across its surfaces
# incident-response    Triage and respond to an active incident

Flags:

Flag Default Description
--mission-repo <path> auto-discovered Override the mission repo path
--runs off Also list persisted run directories (id, status, updated) after the recipe listing — see Team state directory below

bnerd team describe <slug>

Show the full definition of a team recipe: its coordinator hint, safety mode, members (always-on vs on-demand), and workspace.

bnerd team describe pr-review
# pr-review — Run a multi-role review of a pull request
# coordinator hint: product-orchestrator
# safety: read-only
# members:
#   - code-reviewer              (always-on)
#   - security-reviewer          (always-on)

The lead: field in a recipe is optional — it only names which role's persona the coordinator driver adopts when the team runs. A recipe that declares none prints coordinator hint: (built-in) and runs under the built-in read-only coordinator (see Coordinator driver below).

bnerd team run <slug> [prompt]

Spawn the named team and drive it headlessly. The initial prompt positions the coordinator driver; it can also be provided via --prompt.

bnerd team run pr-review "review PR #142 in cloud/app/hq"

Confirmations and clarifying questions surface inline on stderr:

[lead] spawning code-reviewer, security-reviewer
[code-reviewer] task #1: review PR #142 — code correctness

  ─── CONFIRM ────────────────────────────────────────────────
  code-reviewer wants to run:  gh pr view 142
  context: reading PR body before reviewing
  Approve? [y/N/explain]: ▮

  ─── ASK ────────────────────────────────────────────────────
  code-reviewer: Should I check the migration for reversibility?
  Answer (or "skip"): ▮

The [lead] tag is the coordinator driver's mailbox/event identity — a naming holdover, not an operator-controlled teammate. There is no session waiting for you at the finish line: the driver ends the run itself (see below).

Those prompts are the only thing the CLI reads from stdin. There is no free-text channel into the running team: once the run starts, the initial prompt is the last instruction you give it. To steer a team mid-flight, use the TUI — focus the teammate from the team panel (Enter) and type to it directly, or let the coordinating conversation route it.

Coordinator driver

bnerd team run always drives the team through a coordinator driver: a scripted chat.Session holding a read-only coordination toolset, run alongside the roster rather than as one of its members. The driver:

  • creates and routes tasks (team_task_create, hinting a role or assigning a teammate directly),
  • spawns on-demand members as work requires them (team_spawn_teammate),
  • can stop or replace an underperforming or stuck teammate (team_stop_teammate, team_replace_teammate),
  • reports a structured snapshot on request (team_status — roster, tasks, pause state, pending-approval counts), and
  • ends the run itself once the goal is met or no further progress is possible, by calling team_finish with a closing summary.

This is what closed the old deadlock: a team used to wait at the finish line for an operator-facing "lead" role that nothing was driving. Now the run always terminates on its own — via team_finish — instead of idling forever.

If the recipe names a lead: role, the driver adopts that role's persona (system prompt, model) as a coordination hint; without one, the driver falls back to a built-in, deliberately read-only auto-coordinator (Read/Grep/Glob only — it plans and routes, teammates execute). Either way the driver's toolset never includes an approval-resolution tool: see Safety Model → Human-gate tasks for why that boundary is load-bearing.

No --delegate flag

bnerd team run always drives the team through the scripted coordinator driver described above — a headless run has no user conversation for a delegated coordinator to relieve, so there is no --delegate flag to start one pre-delegated. (The driver's toolset does technically include team_delegate, since it is bound the same way for every driver identity — but nothing in a headless run's scripted prompt asks it to use it, and delegating away from a driver nobody is watching buys nothing.)

Flags:

Flag Default Description
--prompt <text> (empty) Initial prompt for the coordinator driver (alternative to positional arg)
--safety <mode> read-only Safety floor: read-only, non-destructive, or full. Can only tighten the recipe's declared floor, never loosen it.
--auto-approve <policy> never Approval policy. never: prompt for every tool confirm and every task-completion approval. safe: auto-approve task-completion approvals only (requires_approval: true tasks) — tool confirms are still prompted on stdin. all: auto-approve both — only meaningful with --safety=read-only. See Safety Model → Human-gate tasks.
--auto-skip-questions off Answer every ask_question prompt with "skip" instead of blocking on stdin. Required for unattended / CI runs.
--json-events off Reserved — not yet implemented. Intended to emit a JSONL event stream on stdout (see Event stream below); today the flag is accepted and ignored, and events print human-readable either way.
--mission-repo <path> auto-discovered Override the mission repo path

Security note for unattended runs

--auto-approve=all with --safety=read-only does not restrict outbound web-tool URLs. A prompt-injection in fetched content could exfiltrate model-context data via web_fetch GET URLs. For unattended (cron/CI) runs, also pass --auto-skip-questions — otherwise ask_question blocks on stdin and hangs the job.

Roster cap

The roster (always-on members plus anything the driver spawns on demand) is capped by the team.max_members config key (default 6, env BNERD_TEAM_MAX_MEMBERS). A team_spawn_teammate call beyond the cap fails with an error naming the limit — see Configuration File Reference.

Storyboard A — interactive headless (default)

bnerd team run cloud-app "add POST /networks endpoint" \
    --workspace ~/cloud/app

Teammates stream their activity to stdout, confirmations and questions pause on stderr, and you answer inline. Same level of control as bnerd pa today — just multi-threaded.

Storyboard B — pre-authorised autonomy

bnerd team run inbox-triage "review yesterday's tickets" \
    --safety=read-only --auto-approve=all --auto-skip-questions
[ops-investigator] spawned as ops-investigator
[task] #1 pending: summarise #7703 progress
[task] #1 claimed: summarise #7703 progress
[AUTO-APPROVED] ops-investigator: openproject_read
[task] #1 completed: summarise #7703 progress
…final report…

The safety floor is read-only, so auto-approving everything risks nothing. --auto-approve=all is what makes the run truly unattended: safe would still stop at each tool confirm. Pair it with --auto-skip-questions so a clarifying question can't block on stdin. The final report is the last thing printed.

Not machine-readable yet

The output above is the human-readable stream. --json-events is reserved and does nothing today — see Event stream.

Exit behavior

If the run ends without team_finish having been called — the driver session errors out, disconnects, or otherwise stops on its own, or the process receives SIGINT/SIGTERM — bnerd team run runs the same bounded exit grace the TUI and bnerd web run on quit: deny any pending approval/question first, give a teammate genuinely mid-turn up to 30 seconds to finish, flush every teammate's history, persist the task board, mark the run interrupted, and clear the lease.

Ctrl-C triggers it

bnerd team run catches SIGINT/SIGTERM and cancels the run — the exit-grace sequence above then runs before the process exits, exactly like the TUI (q/Ctrl-C in-app) and bnerd web (SIGINT/SIGTERM on the server process) — see AI Assistant → Exit grace and bnerd web → Exit grace. A second Ctrl-C/signal during the grace window abandons the remaining wait and lets the process exit immediately instead of holding the terminal for the full 30 seconds:

^C
second interrupt: abandoning the remaining team grace

An interrupted run exits 0: it did what the signal handler exists to do, and its state is on disk. You get one line on stderr and no usage dump:

team run-cloud-app-20260828-143000-a1b2c3 interrupted — state saved (interrupted)

bnerd team status <team-id>

Prints a non-blocking snapshot of a persisted team's state, read directly off disk (see Team state directory below) — there is no live coordinator to query, since a status invocation is always a separate process from whatever (if anything) is actually running the team:

  • driver — conversation, or <name> (delegated) when a teammate has been handed coordination via team_delegate (meta.Delegate). For a finished team this is who drove at the moment the team finished, not who drives now — a run whose delegate saw it through to team_finish still reports that delegate afterwards, which is what a post-mortem is asking. A team that was undelegated before it finished (the delegate was stopped or replaced) reports conversation, and so does a run the conversation drove throughout.
  • conversation-id — the launching conversation's session ID, when one exists (see Team state directory below)
  • roster — one line per persisted teammate: name, role, status (teammates/*/meta.json)
  • task counts — total / pending / claimed / awaiting-approval / completed (tasks.json)
  • lease — the owning PID, session, and heartbeat, and whether it's stale (lease.json; see Team state directory below)
$ bnerd team status run-cloud-app-20260828-143000-a1b2c3
team run-cloud-app-20260828-143000-a1b2c3: running
driver: conversation
conversation-id: sess-01HX...
roster:
  code-reviewer        code-reviewer    running
  security-reviewer    security-reviewer stopped
tasks: total=3 pending=1 claimed=1 awaiting_approval=0 completed=1
lease: pid=48213 session=sess-01HX... beat=2026-08-28T14:31:05Z

An unknown id fails with a clear error naming both the id and the teams root searched, rather than a bare "file not found". status also honors the global -o json/-o yaml output flag for scripting.

bnerd team list --runs lists every persisted run dir (id, status, updated) alongside the normal recipe listing — useful for finding an id to pass to status without knowing it ahead of time.

No resume or cleanup subcommand

There is no bnerd team resume and no bnerd team cleanup subcommand today. The team command registers exactly four subcommands: list, describe, run, and status.

A team that ends without calling team_finish is recorded as interrupted on disk, with its task graph, mailbox, and every teammate's chat history flushed (see Exit behavior above and Team state directory below) — but resuming one back into a live run is not a bnerd team CLI feature. It's a TUI feature: reopening the conversation that launched the team (bnerd x --ai-continue) detects the interrupted team and offers to resume it inline — see AI Assistant → Resuming an interrupted team. That only works for a team launched from a TUI conversation in the first place (its meta.json carries the launching conversation's ID); a bnerd team run or bnerd web team has no resume path — the only way forward for those is to start the run again. Archiving a finished team's state directory is still unbuilt, for any launch surface.

Team state directory

A running team persists to <teams-root>/teams/<team-id>/: meta.json (coordinator status, recipe, delegate, and — for every launch surface, when a launching conversation exists — its session ID as conversation_id), tasks.json (the task graph), the mailbox log, and one teammates/<name>/ directory per roster member holding its chat history and lifecycle status.

Every bnerd team run writes a fresh directory — run-<slug>-<timestamp>-<hex> — so a second run of a recipe can never clobber the first one's history, and nothing removes them afterwards: state accumulates until you delete it yourself (bnerd team list --runs enumerates what is there; a cleanup/retention verb is on the roadmap).

<teams-root> is ~/.bnerd whenever a home directory can be resolved, and ./.bnerd in the working directory when it cannot (a container or CI runner with no HOME). All three surfaces — bnerd team run, bnerd web and the TUI — use the same rule, so a team started under one is found by the others.

A team launched from a TUI conversation is named tui-<recipe>-<short conversation id>; the suffix is what keeps two conversations running the same recipe out of each other's state directory. bnerd team run names each launch run-<slug>-<UTC yyyymmdd-hhmmss>-<6 hex> — unique per invocation, so two runs of the same recipe get distinct state directories instead of colliding on run-<slug> (the trailing random suffix, not just the timestamp, is what keeps two launches within the same wall-clock second apart too). There is no explicit resume-by-id for a headless run (see No resume or cleanup subcommand above) — bnerd team list --runs or bnerd team status <id> are how you find and inspect one after the fact.

Alongside these, the coordinator writes lease.json — a liveness marker (owning PID, session ID, and a heartbeat renewed roughly every 10 seconds) recording which process currently owns the run; a lease is considered stale once it has missed 3 heartbeats (~30s) without a fresh beat timestamp.

bnerd team run and bnerd web treat the lease as informational only — neither refuses to start a team whose lease looks fresh. The TUI is where the lease is load-bearing, in two places and only two:

  • Resume. Reopening a conversation whose team is still Status: "running" checks the lease first — a fresh lease means another process currently owns that run, so the TUI prints an ownership notice (team "tui-…" is owned by another process (pid N) — its state is read-only here) rather than offering to resume it; a stale or absent lease means whatever process held it is gone, so the team is offered for resume the same as one already marked interrupted.
  • Launch. team_start refuses outright when the team ID it is about to use already has a fresh lease held by a different process. Because the ID is scoped to the conversation (see below), that can only mean the same conversation is live in a second process, and two coordinators sharing one task board, mailbox and set of teammate histories is corruption rather than concurrency. A lease from your own process never blocks you: finishing a team and starting another in the same conversation is normal, and only one team at a time can be live per conversation anyway.

The lease is a liveness marker, not a lock: nothing prevents another tool from reading or editing the state directory.

Event stream

Today bnerd team run prints its event stream human-readable on stdout — one line per event ([teammate] …, [task] #3 claimed: …, [APPROVED] …) — with confirmation and question prompts on stderr. That is the only format currently produced.

--json-events is reserved

The --json-events flag is accepted but has no effect yet: the JSONL encoder is not implemented, so the run prints the same human-readable stream with or without it. Don't build a pipeline against it yet. See Roadmap for when it lands.

The planned JSONL format emits every team event as one JSON object per line on stdout, with the human-readable log moved to stderr so events pipe cleanly. Planned event types:

Event Fields Description
spawned teammate, role A teammate came online
task_created id, subject, by A task was added to the shared list
task_claimed id, by A teammate claimed a task
task_complete id, by, summary A task was marked done
gate_satisfied task_id, gate, by, at_ref, decision A review gate was satisfied
confirm_requested id, from, tool, args, context A tool confirmation is needed
question_requested id, from, question A teammate has a question
approval_requested task_id, from, summary A human-gate task needs approval
tip_mismatch task_id, gates Gates were satisfied at different commits; task held
team_paused reason, by Team entered pause state
team_done report Team finished; report is the final synthesis