Bazel is a small, single-user web app you run on your own machine. It collects the open pull requests of the repositories you watch, hands the ones you pick to an AI agent, and shows you the review — the agents working live, the report rendered, the cost in tokens. Nothing reaches GitHub unless you say so.
There are no subcommands: the binary is the server. Repositories, agents and reviews are all managed from the page.
- Agents are skills. A new config comes with Bazel's own review skills; add the Claude Code skills installed on your machine.
- Reviews outlive the tab. Each review is a server-side job. Close the browser, come back later, it's still there.
- A terminal per agent. A fleet of lenses running in parallel shows up as one live pane each, not as one scrambled stream.
- You are the gate to the PR. Read the review first; publishing is a separate, explicit action.
- Token spend is reported for every run — per agent in the log, and as a total when the review lands.
gh pr viewbrings in the metadata (title, author, branch, base, body).- The repository is cloned into a throwaway directory (
gh repo clonewith--filter=blob:none— full history, no blobs) and the PR is checked out. - Each agent of your choice runs with that clone as its working directory, receiving the prompt on stdin. A pipeline runs its agents one after another over the same clone — cloning once.
- The clone is deleted at the end.
--keeppreserves it and the path shows up in the review footer.
A step that fails doesn't sink the review: it becomes a section with the error and the rest carries on. Only when no agent returns anything does the whole review fail.
Because the agent browses the checked-out code, the diff is not pasted into
the prompt — it is only downloaded if your template uses {{diff}}.
- Go 1.26.10+ (to build). An older Go downloads it by itself, unless
GOTOOLCHAIN=localis set gh, authenticated (gh auth login)gitjq, for publishing to the PR- An AI agent on your
PATHthat reads a prompt on stdin — by defaultclaudein stream mode, which is what feeds the live log - The skills you want to use as review lenses, installed in
~/.claude/skills
make install # builds into ~/.local/bin
make install PREFIX=/usr/local/binOr with Go:
go install github.com/beroni/bazel@latestThe binary is called
bazel, like Google's build tool. If you use both, rename one of them after installing (mv "$(go env GOPATH)/bin/bazel" ~/.local/bin/bz).
bazel # serves on 127.0.0.1:7777
bazel --open # and opens the browserOn first run ~/.bazel/config.yaml is created for you. Then, in the page:
- Go to config and add a repository (
owner/repo). - On the same page, turn one of your installed skills into an agent.
- Back on the dashboard, tick a PR, pick the agent, hit run.
| Flag | Effect |
|---|---|
--addr <host:port> |
where to listen (default 127.0.0.1:7777) |
--jobs <n> |
concurrent reviews (default 2) |
--open |
open the browser |
--keep |
keep the throwaway PR clones |
--log-dir <dir> |
write each job's whole log to a file in dir |
--no-splash |
skip the opening animation |
--version |
version |
--jobs is what separates "reviewing two PRs" from "melting the laptop": every
review clones a repository and spawns an agent process.
A review takes minutes and no HTTP request survives that, so each one becomes a server-side job. The browser gets an id immediately and the result arrives over SSE.
From the page you can:
- tick PRs and choose which agent runs over them;
- filter the list: by text (title, repo or author), by ownership
(
everyone/mine only), by repository owner (shown only when you watch more than one owner; picking one narrows the repository selector to that owner's repositories), by repository, and by review state —not reviewed,✓ reviewed,⟳ changed since review. With a filter on, the header counter becomes12 of 92 PRs. Filtering never unticks anything, but review only runs on what is currently visible; - collapse the list — the
☰button sits in the list itself and stays behind as a thin rail, so a review can take the full window; the choice is remembered in the browser; - follow the queue, cancel a job, and watch the live log;
- drop a job from the queue with the
✕on its card; if the review is still running it is cancelled along the way, and the saved markdown stays on disk either way; - read the rendered review and decide whether it goes to the PR — inline comments or a plain comment, see Publishing;
- re-read older reviews saved on disk — and still publish them, see below;
- add and remove watched repositories, and build your agent list from the installed skills.
Once a review finishes, the PR is marked in the list — and when it gets new commits afterwards, the check turns into a warning:
#482 ✓ 2h ↑
#479 ⟳ changed
✓ 2h is how long ago it was reviewed, ↑ says the review went to the PR, and
the tooltip names the agent.
The index lives in <BAZEL_HOME>/reviews/.index.json, keyed by the head commit
seen at review time.
The list comes from one gh pr list per repository, up to 100 open PRs each. A
repository with more says so in the banner, and one that fails keeps the PRs it
had and says that too. With authors: set, the filter goes to gh, so the limit
applies to their PRs.
An agent is one of your skills running over a PR. A new config starts with the skills that ship inside Bazel, so the first review works right after install; everything else comes from your own skills.
In config, the page lists what is actually installed — read from
~/.claude/skills, or from skills_dir in config.yaml — and every row turns
into an agent with one click:
installed skills · ~/.claude/skills
/review-fleet Runs a fleet of review lenses over one diff [use] [⇧ publishes]
/exploit-digger Adversarial sweep of a diff [use] [⇧ publishes]
With more than eight skills, the ones that read as reviewing come first, along with those already in use, and the rest fold under Other skills; the search box looks through all of them.
- use creates the agent
review-fleet, with the task/review-fleet {{number}}: it hands the review back to you to read. - ⇧ publishes creates
review-fleet-post, with--postand the prompt template that authorizes writing to GitHub — the page warns you before firing one of those.
The same skill can become both. In the list above each agent shows the skill it calls, a make default button (the first one runs when you don't choose) and remove:
review-fleet default ✓ /review-fleet
review-fleet-post ⇧ publishes ✓ /review-fleet
serial-fleet pipeline ✓ /senior-code-reviewer ✗ /exploit-digger
bazel-post-report used when publishing ✓ /bazel-post-report
The ✗ is the warning that matters: that agent calls a skill that is not on
this machine and would only fail at run time. Skills are usually symlinks into
the repository where you version them, and Bazel follows the links; the list is
read from disk every time you open the page, so installing a skill needs no
restart.
Some entries in that list are marked in Bazel and are never ✗. They travel
inside the binary and, right before a Claude Code agent runs, are written as a
Claude Code plugin — skills and the lens agents — into a folder of Bazel's,
<BAZEL_HOME>/runtime/<id>/, never the PR's clone, that the agent loads with
--plugin-dir. Nothing is installed on your machine, and the folder is removed
when the review ends, however it ends.
| Skill | What it does |
|---|---|
bazel-review-fleet |
three lenses over the same diff, deduplicated into one report and one verdict |
bazel-senior-code-reviewer |
precision lens: a few findings it can defend at confidence 8+ |
bazel-exploit-digger |
adversarial recall lens, class by class, with what needs a human eye |
bazel-lazy-senior-dev |
subtraction lens: code that should not exist |
bazel-history-pr |
the PR's story — commits, reviews, the review→fix loop; not a review, never published |
bazel-post-report |
takes a review you have read to the PR (the post_agent) |
They are written for a run nobody is watching: they never ask a question, take
the scope from the prompt (git diff origin/{{base}}...HEAD in the clone),
print the whole report as their final output in the format the page splits into
findings, and when they cannot review they print a report that says why. The
contract is bazel-review-fleet/references/headless.md, in the binary under
internal/skills/builtin/, which is where these skills are written and
maintained.
A new config.yaml comes with an agent for each review skill
(bazel-review-fleet is the default), the serial-fleet pipeline (the three
lenses, each in its own process) and review-then-publish (the fleet, then you
choose what goes). A config you already have is never touched; add them from the
list with use.
Other agent CLIs. Codex, Grok and the rest do not load Claude Code skills, so
for a step whose command is not claude and whose format is not
claude-stream, Bazel replaces a /bazel-… task with the skill's instructions
and its arguments, and the absolute path of the plugin folder where the files
they mention are. Without
subagents the fleet runs its lenses as sequential passes in one process, which
keeps them less independent; serial-fleet keeps each lens in its own process
on any CLI.
Your own skill of the same name may win. The built-ins reach Claude Code as
plugin skills (bazel:<name>), and a ~/.claude/skills/bazel-<name> may answer
the bare /bazel-<name> instead. That is why they all carry the bazel-
prefix: your review-fleet and Bazel's bazel-review-fleet live side by side,
and you pick one by choosing the agent. The same goes for publishing: point
post_agent.task at your own skill, or build an agent out of it in the panel. A
post_agent you have edited is never rewritten — only the old untouched default
is migrated to the built-in. Publishing is safe from this: the
post_agent always runs /bazel:bazel-post-report, even from a config saved with
the bare name, which is left as it is in the file.
Everything the page does is written to config.yaml, and you can edit it by
hand for what the page doesn't offer — another model, another executable, your
own prompt template. See Configuration.
The default args run the agent in stream mode:
agent:
command: claude
args: [-p, --output-format, stream-json, --verbose, --allowedTools, "Read,Grep,Glob,Bash,Agent"]
format: claude-streamIn that mode stdout is a stream of JSON events: Bazel turns each one into a readable line (the tool called, and the argument that says what it is doing) and takes the final report from the result event.
Every line is signed by whoever wrote it. A fleet spawns its lenses inside
the same process, in parallel; Bazel ties each Task call to the sub-agent it
created and stamps the lines coming out of it:
review-fleet | → Agent(senior-code-reviewer): precision review
review-fleet | → Agent(exploit-digger): adversarial sweep
exploit-digger | → Grep exec.Command
senior-code-reviewer | → Read internal/agent/agent.go
lazy-senior-dev | 40 lines the stdlib already does
Each agent gets its own terminal, with its own name, line count and scroll —
a fleet becomes four panes side by side, its own and one per lens. Two lenses of
the same type in parallel become exploit-digger and exploit-digger 2, not
one muddle.
The log is a window over the last 500 lines per review, held in memory. It does not travel over SSE: the page remembers where it stopped and fetches only what is missing, once a second. The agent's stderr is included, in another color.
To keep the whole log, set log_dir in the config or start with `--log-dir
format chooses how Bazel reads stdout: claude-stream, codex-json,
grok-stream, or plain. Leaving it out preserves the old behavior: arguments that ask for
Claude stream-json use the Claude adapter; all other commands use plain
stdout. Set it explicitly for Codex and other structured CLIs.
When an agent finishes, its last log line says what the run cost:
review-fleet | ✓ done in 4m12s · 1,8M tokens · $2.41
The count climbs while the agent works. Every streamed message carries the
usage of the call behind it, so the queue card — and the review pane — show a
running total as it goes, marked with a ~:
~412k tokens
The tilde is a promise that the number will grow: a partial count only sees the agent's own conversation, and the lenses it spawned as sub-agents land at the close. When the run finishes the closed tally replaces it — on the card, in the footer of the report, and in the session total the top bar carries next to the PR count:
1,8M tokens · $2.41 · 252s
That final number is the per-model tally of the last stream-json event —
input, output and cache added up, sub-agents included. The distinction
matters: the event's plain usage field covers only the main conversation, so a
fleet of three lenses would report a fraction of what it actually burned. In a
pipeline it is the sum of the steps. The same number goes into the header of the
saved markdown:
- Spend: 1,8M tokens (in 12k · out 84k · cache 1,7M) · $2.41
The dollar figure is one number everywhere — the log's done line, the card, the
report footer and the saved file. It comes from Bazel's price table for every
model the table knows (each model charged its own price; agent.pricing in
config.yaml overrides a row), and from the provider's own total_cost_usd
for a model it doesn't.
Plain agents report no spend. Codex JSON reports input, cached-input, and output tokens; it does not currently provide dollar cost or quota information. Grok Build's stream reports token usage and its final USD cost.
The page also shows how much of your Claude allowance is gone — the same two
windows the /usage command reports:
claude session 86% · week 16%
It turns yellow at 75% and red at 90%, and the tooltip carries the reset times.
There is no command to ask for this, so Bazel doesn't ask: the number rides
along in the stream of whichever agent is running (rate_limit_event). What
you see is therefore the reading from the last agent that ran, and the tooltip
says when that was. A fresh server shows nothing until the first review.
What was queued or running when Bazel stopped comes back paused: the card is there, and nothing runs or spends quota until you click Run, or ask for the same review again. Agents are not started on their own. A publication never comes back, because it needs someone who has just read what goes out, and neither does an agent that may publish and had already started, which may have posted. One that never started comes back, and Run asks first. The saved review is where a publication starts over.
The queue lives in <BAZEL_HOME>/queue.json, plain JSON, safe to delete.
Every review lands in three places, in this order:
- The page — markdown rendered in the right-hand pane.
- A file —
<BAZEL_HOME>/reviews/<repo>-<number>-<date>.md, with the PR header, the agent that ran (per-step timings in a pipeline), what it ran on and the token spend. This is the copy that outlives the server: the saved tab reads it back and can still take it to the PR. - The PR on GitHub — only if you ask, and only after you have read it.
Each step says what it ran on, such as claude · opus-5 or codex, on the job's
card, in its step list and in the file's - Ran on: line, section by section in
a pipeline. The label comes from the output format and the model the agent
reported; a CLI Bazel does not know shows its command name. It stays out of the
report's own text, because that text is what goes to the PR.
Three ways in, from the most deliberate to the most direct.
1. Read, then publish (the default). Run a review, read it on screen, then
click publish inline review. That runs the post_agent — the
bazel-post-report skill, which ships inside the binary — over a clone of the PR, with the markdown file you just read in the
prompt and the instruction not to redo the review: it publishes what is in
the file, with inline comments on the right lines, 👍 on what is already flagged
in the PR, and an all-clear when there is nothing to say. It runs in the
review's own card — the card goes back to running with the post agent's step
at the end of the list, log and all, and comes back to the review, now marked
✓ published, when it finishes. Publishing is the end of a review, not a second
job in the queue.
bazel-post-report only takes a report it can check: every step of the review
has to carry the review-fleet:v1 header, which the built-in skills always
print and /review-fleet of your own may not. Anything else is refused, the
card says why, and nothing reaches the PR; use the second way below for those.
It needs jq, and says so if it is missing. The card only says published when
the skill's script says so: a model that stops early leaves an error on it. The
all-clear goes up only for a report that proves it: every step reported, the
report is complete, no lens was skipped, the diff was not truncated, nothing was
unticked. A clean report that falls short of that says why it does not clear the
PR. Text around the report goes to the PR as plain text, under a label saying
Bazel does not vouch for it, and so does a finding that lands in the review's
body: on the PR they are one document, and no HTML in one may hide the next. An
inline comment stays rendered, unless it has a tag in it, code included: then it
goes as plain text too, because a <details> would fold what you approved.
2. As comment. This is Bazel writing, with no agent: the review markdown becomes a single comment, immediately. No inline anchors, but no agent spend either.
Leaving a false positive behind. Every finding on screen — each ###
under ## Findings or ## Cuts, or each **1. …** paragraph in reports that
number their findings in bold instead — carries a report checkbox, ticked
by default. So does each item under ## Needs human verification: a doubt you
have already settled doesn't need to reach the PR. Untick the ones you disagree with and they drop out of whatever you
publish next, on both paths above: the inline publish hands the post agent a
copy of the review without them (written to publish/ inside the reviews
directory, the saved file stays whole), and the pasted comment simply omits
them. The line next to the buttons says how many go and how many stay, with
all / none shortcuts.
3. Publish directly, skipping your reading: pick an agent marked ⇧ in the
selector before reviewing — the one you created with ⇧ publishes. It reviews
and publishes in the same pass.
None of this expires when the server does. The queue lives in memory, but every review is on disk the moment it finishes, so one you read and did not publish is waiting under saved — with the same two buttons, aimed at the PR named in its header. Closing Bazel costs you the queue, not the review.
Agents in paths 1 and 3 carry their own prompt template: the default one forbids
writing to GitHub, and theirs replaces that with explicit authorization. Any
agent of yours can do the same with posts: true, which is what makes the UI
mark it with ⇧ and ask before firing — publishing is a write on someone else's
PR. And if you ask to comment over a review the agent already published, Bazel
warns you first.
The same server answers MCP on /mcp
(streamable HTTP, same port, same loopback guard). Register it once and any MCP
client — a Claude Code session, a chat bot, a scheduled routine — can queue
reviews that outlive the conversation:
claude mcp add --transport http bazel http://127.0.0.1:7777/mcp| Tool | Does |
|---|---|
list_prs |
open PRs, filtered by repo, mine, author, review status |
enqueue_review |
queues refs with an agent → job ids |
list_jobs / job_status |
the queue; job_status long-polls with wait_seconds and carries the review body once done |
job_log |
the agent log from from on (pass back next) |
list_reviews / read_review |
the reviews saved on disk |
list_agents |
the agents and pipelines you can pick |
Every result carries web_ui, the page where a human reads the review.
MCP does not offer publishing. The tool surface is curated so that you stay
the gate to the PR: there is no publishing tool, and a step only runs if it is
known not to publish. Its prompt has to keep the default's "do not publish
anything to GitHub", its task has to be a skill call rather than prose, and its
task, command and args must not name a way to publish: posts: true, --post,
gh, a post skill, a publish step in a pipeline. Anything else is refused. A
skill or a command of your own is outside this: what they do cannot be read from
their names. To
open that door on purpose, set mcp_allow_publish: true in the config — that
also adds a publish_review tool.
This is a curated surface, not a security boundary. /mcp has no authentication
of its own, so any process that can reach it can also call the web API
(/api/jobs/{id}/publish) directly. What the gate prevents is a model
publishing by accident, not a hostile process on the machine; closing that is
tracked in issue #6 (access token).
Two more limits apply only to /mcp (the web page has neither):
enqueue_reviewonly accepts PRs from the repositories you watch (reposin the config, compared case-insensitively asowner/repo). A ref outside the list is refused, and with noreposconfigured nothing can be queued.- at most 20
refsper call.
The interface is two pages, switched from the top right and addressable by URL:
the dashboard, with the PRs, the queue and whatever you are reading, and
config. Switching does not reload anything — the event stream stays open and
a review in flight keeps its log — and reopening the tab at #/config comes
straight back to the configuration.
Everything the page changes — repos, agents, pipelines, the default — is written to one file. The page no longer prints it at you: at the bottom there is save config.yaml, which downloads it, and a collapsed show the file if you want to read it. Dropping that file at the same path on another machine brings Bazel up already configured.
~/.bazel/config.yaml (or $BAZEL_HOME/config.yaml):
repos:
- acme/api-core
- acme/web-app
authors: # filter by PR author; empty means everyone
- beroni
include_drafts: false
reviews_dir: "" # empty = <BAZEL_HOME>/reviews
max_diff_bytes: 400000 # only used if the prompt has {{diff}}
log_dir: "" # empty = job logs stay in memory; see "The live log"
log_keep: 200 # job log files kept at boot
skills_dir: "" # empty = ~/.claude/skills
# The base every named agent inherits from.
agent:
command: claude
args: [-p, --output-format, stream-json, --verbose, --allowedTools, "Read,Grep,Glob,Bash,Agent"]
# Omit this to preserve legacy argument-based Claude detection.
format: claude-stream
checkout: true # clone the repo and check the PR out first
timeout_seconds: 1800
prompt: |-
{{task}}
...
# The lenses the selector offers. A new config comes with Bazel's own; the page
# adds your installed skills. The first one is the default.
agents: []
# Sequences run over the same clone. Built in the page, under "config".
pipelines: []
# Who takes an already-read review to the PR.
post_agent:
name: bazel-post-report
task: /bazel:bazel-post-report {{review_file}}
posts: true
# Who writes the History tab. Optional: the default runs /bazel-history-pr {{number}}
# with the factory prompt, not your agent.prompt.
# history_agent:
# task: /my-history {{number}}config.yaml is yours too: Bazel changes only the keys you changed in the page
and leaves comments, unknown keys and edits by hand alone, including edits made
while it runs. Those only take effect at the next start, and until then the page
refuses to change a key you edited, since saving would undo it. An empty file
stops the boot instead of passing for a new install. If config.yaml is a link,
it stays one and its target is what changes. The file is readable by you alone,
and the page and its export leave out the values under env: and all comments.
Reviews and their folder are yours alone as well, since they quote the PR's code.
The History tab runs history_agent on demand. Bazel offers nothing of its
output for publishing, and it refuses a history_agent that declares or plainly
asks for publishing: posts: true stops the load, and the rest (a post skill,
--post, any gh command, a prompt without the line
do not publish anything to GitHub) stops that history with the reason. That is
a check, not a guarantee. The default agent is instructed not to publish, and
it has Bash and your gh login. A step of your own, whether from history_agent
or a command, args or env inherited from agent, is outside what Bazel can
vouch for, and the tab says so.
An agent only declares what changes: its task goes into the {{task}} of
agent.prompt, and command, args, format, checkout and
timeout_seconds are inherited from the agent block when left out. prompt
replaces the whole template. env is extra process environment for that agent.
args inherit only with command; format inherits independently, so a custom
command should set its own format when the base uses a structured adapter.
agents:
- name: review-fleet
description: three lenses, deduplicated into one verdict
task: /review-fleet {{number}}
- name: exploit-digger
description: adversarial recall, class by class
task: /exploit-digger {{number}}
# This one publishes on its own: `posts` is what makes the UI warn first.
- name: review-fleet-post
task: /review-fleet {{number}} --post
posts: true
# A lens can run on another model, or another executable entirely.
- name: quick-pass
task: /senior-code-reviewer {{number}}
args: ["-p", "--model", "claude-haiku-4-5-20251001", "--allowedTools", "Read,Grep,Glob,Bash"]
timeout_seconds: 600
pipelines:
- name: serial-fleet
description: the three lenses one at a time, each in its own process
steps: [senior-code-reviewer, exploit-digger, lazy-senior-dev]
# `publish` is a step Bazel runs itself: you choose what goes before it goes out.
- name: read before sending
steps: [review-fleet, publish]
# Which choice runs when you don't pick one. Empty = the first in the selector.
default: serial-fleetA pipeline chains agents by name, in order, over the same clone; the report comes out with one section per step. A step pointing at an agent that doesn't exist is skipped.
One step is not an agent — Bazel runs it itself: publish. It has to be
the last step. Bazel's own code publishes nothing on its own: only your clicks
do. The pipeline runs, the review is saved, and the card stops waiting on
you with what would go to the PR on screen, each finding with its report
checkbox. Untick what should not go and hit publish inline review: from
there it is the same path as the button on any review. If nothing should go,
hit keep it here — the card stops waiting and nothing is sent. The card
only waits when there is something to choose: a pipeline whose agents already
post on their own, or whose steps produce nothing publishable, ends as a
normal finished review.
That guarantee is about Bazel's code, not about the agents. They run with
Bash and your gh login inside the code of the PR under review, so a hostile
PR can try a prompt injection that makes an agent comment, or do worse, without
any click of yours. The publish step is a gate in front of Bazel's publishing
path, not a sandbox around the agents: be careful with PRs from people you
do not trust.
The wait lives in memory. It survives in the session history — the queue — until the server restarts, and then it is gone; the review itself is already saved to disk and stays in the Saved tab, where it can be published later with the same ticks. keep it here drops the wait, not the review.
If the PR got commits after the review, the Report and the confirm say so before anything goes. What you untick stays unticked across a reload, in this browser.
The screen shows what goes, as Bazel renders it. Raw HTML in the review is
shown as escaped text (in a block of its own for HTML blocks) instead of being
interpreted or hidden. One exception is the <!-- finding … --> comment right after a finding's ###:
it is metadata for the post agent that GitHub does not show either, so it is
left out, as long as every attribute is one the fleet's report format defines,
in its form. Not everything in the markdown shows on screen, and Bazel cannot
list all of it: some hidden text, such as a [//]: # (comment) or a footnote
nothing refers to, is flagged on top of the report, and the rest may go along
when you publish. The report is data for the post agent, not instructions to
it: bazel-post-report hands it to a script, and a post agent of your own
treats it as its skill says. (The PR description is the author's text, not something Bazel
publishes, and its raw HTML is hidden, as on GitHub.) When a step's output is
not a review (history-pr, say), it shows up apart, under stays in Bazel, and
the checkboxes live only in the part under goes to the PR.
You build one in the page rather than here: under pipelines on the config
page, drag a step out of the tray into the chain and drag the cards to reorder
them — clicking works the same, for when dragging is not worth it. Name it and
create. Only agents already in the list can be steps — that
is what guarantees each step arrives with its prompt, its command and its
publishing flag already settled. The same agent twice in one sequence is refused:
it would be the same work twice over the same clone. The rules around publish
are enforced on the way in, not at run time: it cannot open a pipeline, it runs
at most once and always last, and it needs something publishable before it.
The publishing skill (bazel-post-report, or whatever post_agent.task calls)
is not offered as an agent: on its own, as an ordinary step, it would run
without the review or the prompt that authorizes posting. In a pipeline it is
the publish step. A config saved with it as an agent, or with the old pause
step, is fixed when it loads.
default: names the choice that runs when you don't pick one. A pipeline can be
it — the selector lists agents before pipelines, so being first is not something
a pipeline could win by position. make default writes this field.
With no agents at all, the review button stays disabled and the page tells
you what is missing. If you wrote your own agent.prompt and have no agents:,
the selector shows a single choice — the bare agent block, which is how Bazel
behaved before the selector existed.
{{task}}, {{repo}}, {{number}}, {{title}}, {{author}}, {{url}},
{{branch}}, {{base}}, {{body}}, {{workdir}}, {{diff}}.
{{task}} is the chosen agent's instruction — the only thing that changes from
one lens to the next. A template without {{task}} gets the instruction
prepended on the first line.
The post_agent gets two more: {{review_file}}, the path of the markdown you
read, and {{review}}, its text.
{{diff}} is the only one that costs an extra call to GitHub — if it isn't in
the template, the diff is never downloaded.
Anything that reads a prompt on stdin and writes markdown to stdout
works. With checkout: true it runs inside the PR clone, and whatever it writes
goes to the live log line by line. Use plain when its stdout
is the report itself; use a named format when it has a supported event stream.
# Claude Code on a specific model
agent:
command: claude
args: ["-p", "--model", "claude-opus-5", "--allowedTools", "Read,Grep,Glob,Bash,Agent"]
format: claude-stream
# Codex CLI
agent:
command: codex
args: ["exec", "--json", "--ephemeral", "--sandbox", "read-only", "-"]
format: codex-json
# Grok Build. --prompt-file /dev/stdin lets Bazel provide its generated prompt.
# Unlike `codex exec`, Grok headless still loads the user's interactive MCP,
# plugins, and Claude skills. Bazel points GROK_HOME at a unique dir under
# <BAZEL_HOME>/grok-runtime (auth is reused; MCP/plugins/skills are not)
# unless the step sets GROK_HOME.
agent:
command: grok
args: [--prompt-file, /dev/stdin, --output-format, streaming-json, --max-turns, "8", --no-subagents, --no-plan, --disable-web-search, --always-approve, --reasoning-effort, medium, --tools, "read_file,grep,list_dir,run_terminal_cmd"]
format: grok-stream
timeout_seconds: 300
env:
GROK_MCP_STARTUP_TIMEOUT_SECS: "2"
# A selectable Codex review lens
agents:
- name: codex-review
command: codex
args: ["exec", "--json", "--ephemeral", "--sandbox", "read-only", "-"]
format: codex-json
timeout_seconds: 600
task: >-
Review this PR for actionable correctness bugs. Diff {{base}}...HEAD once,
then inspect only changed files and their direct callers. Do not use the
network, GitHub APIs, CI, or the full test suite. Return the final Markdown
review after at most eight tool calls.
- name: grok-review
command: grok
args: [--prompt-file, /dev/stdin, --output-format, streaming-json, --max-turns, "8", --no-subagents, --no-plan, --disable-web-search, --always-approve, --reasoning-effort, medium, --tools, "read_file,grep,list_dir,run_terminal_cmd"]
format: grok-stream
timeout_seconds: 300
env:
GROK_MCP_STARTUP_TIMEOUT_SECS: "2"
task: >-
Review this PR for actionable correctness bugs. Diff {{base}}...HEAD once,
read only those files, then write the Markdown review.
# Pi against a local OpenAI-compatible server (llama.cpp, Ollama, …).
# Point the model at ~/.pi/agent/models.json. stdout is the report (`plain`);
# a JSONL live-log adapter is tracked separately.
- name: pi-review
command: pi
args: [-p, --no-session, --tools, "read,grep,find,ls"]
format: plain
task: Review this PR for actionable correctness bugs.
claude -pdenies every permission that isn't granted, silently. That is why the default args carry--allowedTools Read,Grep,Glob,Bash,Agent— withoutAgenta fleet can't spawn its lenses, withoutBashnone of them can work out the scope. The agent runs in a throwaway clone and review skills are read-only: they report, they don't fix.
The PR's own agent configuration. Bazel adds
--setting-sources user --strict-mcp-configto every step it recognizes as Claude Code (see Security model). The other CLIs have their own rules: Codex loads a project's.codex/layers (config, hooks, rules) only when the project is trusted, and the clone is a fresh temporary path — do not mark a parent of your temp directory as trusted in~/.codex/config.toml. Bazel also removes.codex/and.grok/from the clone either way. Grok runs with Bazel's ownGROK_HOME, with Claude and Cursor compatibility (skills, agents, hooks, MCP, rules) turned off. Codex still reads the PR'sAGENTS.mdas instructions, and any CLI reads the PR's files: a review of an untrusted PR is a run over untrusted text.
| Variable | Effect |
|---|---|
BAZEL_HOME |
config directory (default ~/.bazel) |
BAZEL_NO_SPLASH |
disables the opening animation |
NO_COLOR / CI |
also disable the animation |
Single-user by construction, and the port is local on purpose. The server
uses the machine's already-authenticated gh — anyone who reaches it can make
it clone repositories and run an agent with Bash enabled. So it listens on
loopback, rejects a Host that isn't local (blocking DNS rebinding) and rejects
POST from another origin (blocking a random tab from firing reviews in your
name). /mcp sits behind the same checks. Don't put this behind a public IP
without authentication in front.
The PR's own agent configuration does not run. The agent works inside the
clone of someone else's PR, and a PR can ship a .claude/settings.json whose
hooks run shell commands — with your gh login at hand —, a .mcp.json that
starts a server, or a command that runs one when it expands. Three things stop
that, in layers, and the first is the one that does not depend on knowing which
CLI is running:
- It is not there. Right before each step starts, of any CLI, Bazel removes
every
.claude/,.codex/,.grok/,.gemini/,.cursor/and.agents/folder and every.mcp.jsonfrom the clone's working tree, at any depth (Claude Code also finds a nested.claude/skills) and in any spelling the disk folds into those names (on macOS,.CLAUDEis.claude), without following symlinks. The job's log says what went. They stay in git, so a review still sees them in the diff and withgit show HEAD:<path>, and the agent is told so. If they cannot be removed, the review stops. - Claude Code is told to ignore it. A step whose command is
claude, or resolves to it throughPATHand symlinks, or that speaks Claude's stream-json (format: claude-stream, or that output in itsargswith no format) gets--setting-sources user --strict-mcp-config --plugin-dir <runtime>ahead of yourargs, and the built-in skills arrive as a plugin from outside the clone. It also gets a--disallowedToolslist of GitHub writes, in the usual spellings (gh pr merge,gh apiwith a method, a field or a mutation,git push…). That is a brake on what a steered agent runs by itself, not a wall: it matches the command typed, so a script, a one-liner orcurlwith the token walks past it, and only Claude Code gets the list. The agent runs with yourghlogin. A publishing agent of your own keeps the publishing commands. Any other command, like a wrapper calledcc, has the first layer only. - Nothing points back into the clone. Every step is checked before the clone
is even made, and again right before it starts, with symlinks resolved: an
earlier step can turn a safe path into a link. One bad step refuses the whole
job. Another
--setting-sourcesis refused, and so is--settings,--mcp-config,--plugin-dir,--add-dir, the other flags that read configuration, andCLAUDE_CONFIG_DIR, with anything but an absolute path outside the clone. - No launchers in the clone. With checkout on, package and version managers
(
npx,bunx,npm,pnpm,yarn,corepack,deno,uvx,pipx,mise,asdf,volta,nix,makeand the like),envand relative paths are refused as acommand, before the clone, also behind a wrapper such astimeout 600 npx …: they decide what to run from the working directory, and the PR'snode_modules,.npmrcor.tool-versionswould decide for them. Bazel says so at boot, on the terminal, and the job is refused when it runs. The list is of known names, so it cannot be complete. Pointcommandat an installed binary, and set variables withenv:. - The clone ignores your git config. The clone and the checkout run with
GIT_CONFIG_GLOBALandGIT_CONFIG_NOSYSTEMset, so a filter, LFS orcore.hooksPathfrom your machine does not run over the PR's files, which is before the sweep.ghbrings its own credentials, for the clone and for the files a blobless clone fetches later. A globalurl.insteadOforhttp.proxydoes not apply to that clone; useHTTPS_PROXYfor a proxy. - Git stays out of it. Every agent with a clone runs with
GIT_DIRandGIT_WORK_TREEpinned to it, plussafe.bareRepository=explicit,core.fsmonitor=falseandcore.hooksPath=/dev/nullthroughGIT_CONFIG_*, after your own entries. A repository the PR nests in a folder would otherwise run its own config on an ordinarygit difforgit status.ghandgit -Cwork as usual; a test suite that creates repositories of its own will not. The pin works on any git; 2.38 or newer is recommended, becausesafe.bareRepositorydoes nothing before that.
What is still on you:
sh -cas a command is your own. A shell takes no flags for what it calls, sosh -c 'claude …'runs with the first layer only, and anything your script does is yours.- Your own hooks run in the clone.
--setting-sources userkeeps your~/.claude: your skills, agents, plugins and hooks load, and your hooks run with the hostile clone as their working directory. A hook that calls a formatter,npxormakeruns the PR's config or scripts with your login. - A step can leave something running only by leaving on purpose. Each agent
runs in a process group of its own, killed when the step ends, however it ends,
and when Bazel shuts down, closing the terminal included. A process that
starts its own session with
setsidescapes that, and could still write to the clone. - The agent still reads what the PR says. A confined Claude Code does not
load the PR's
CLAUDE.md, but an unrecognized wrapper does, and any agent reads the PR's code and comments. That is prompt injection, not execution, but the agent hasBashand yourghlogin: be careful with PRs from people you do not trust. - Codex and Grok. See Using another agent CLI. Their project folders are removed; what else each one reads from a repository has not been verified against the real CLIs.
make # build ./bazel
make run # serve the web UI and open the browser
make check # fmt + vet + test, before committing
make help # every targetThe front end (internal/server/static/) is embedded in the binary with
go:embed — no build step, no CDN: the page works offline.
Packages: server (HTTP, job queue, SSE), agent (runs the agents and
translates the stream), config, gh (talks to the gh CLI), workspace (the
throwaway clone), store (saved reviews and the reviewed index), skills
(discovers installed skills) and splash (the egg).
bazel hatches a Bazelgeuse bomb egg that cracks, heats up and detonates into the logo.Turn it off with
--no-splash or BAZEL_NO_SPLASH=1.
