Skip to content
BeroniPublic

About

No description, website, or topics provided.

Resources

Stars

6 stars

Watchers

0 watching

Forks

Repository files navigation

bazel

Your team's open pull requests, in one place — reviewed by the AI agents you choose.

Go gh CLI Agents Runs on


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.

Highlights

  • 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.

How it works

GitHub to the local bazel server, to a throwaway clone, to your agent, and back to you
  1. gh pr view brings in the metadata (title, author, branch, base, body).
  2. The repository is cloned into a throwaway directory (gh repo clone with --filter=blob:none — full history, no blobs) and the PR is checked out.
  3. 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.
  4. The clone is deleted at the end. --keep preserves 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}}.

Requirements

  • Go 1.26.10+ (to build). An older Go downloads it by itself, unless GOTOOLCHAIN=local is set
  • gh, authenticated (gh auth login)
  • git
  • jq, for publishing to the PR
  • An AI agent on your PATH that reads a prompt on stdin — by default claude in stream mode, which is what feeds the live log
  • The skills you want to use as review lenses, installed in ~/.claude/skills

Install

make install                      # builds into ~/.local/bin
make install PREFIX=/usr/local/bin

Or with Go:

go install github.com/beroni/bazel@latest

The 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).

Quick start

bazel          # serves on 127.0.0.1:7777
bazel --open   # and opens the browser

On first run ~/.bazel/config.yaml is created for you. Then, in the page:

  1. Go to config and add a repository (owner/repo).
  2. On the same page, turn one of your installed skills into an agent.
  3. 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.

The web UI

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 becomes 12 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.

What has been reviewed

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.

Agents are your skills

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 --post and 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.

The skills that ship inside Bazel

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 live log

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-stream

In 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

`: every job then writes each line, as it arrives and without the window's limits, to `-.log`, and the job's Live log and Overview say where. The lines are what the log shows, so with `claude-stream` they are the readable ones, not the raw events. It is off by default. At boot only the newest `log_keep` files stay (200 by default); nothing else in that folder is touched. The files hold whatever the agent wrote, so they are readable by you alone. A folder that cannot be used turns this off with a line on the terminal.

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.

Token usage

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.

Your Claude quota

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.

After a restart

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.

Where reviews go

Every review lands in three places, in this order:

  1. The page — markdown rendered in the right-hand pane.
  2. 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.
  3. 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.

Publishing 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.

Driving Bazel from another agent (MCP)

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_review only accepts PRs from the repositories you watch (repos in the config, compared case-insensitively as owner/repo). A ref outside the list is refused, and with no repos configured nothing can be queued.
  • at most 20 refs per call.

Configuration

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

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.

Agents and pipelines

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-fleet

A 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.

Prompt placeholders

{{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.

Using another agent CLI

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 -p denies every permission that isn't granted, silently. That is why the default args carry --allowedTools Read,Grep,Glob,Bash,Agent — without Agent a fleet can't spawn its lenses, without Bash none 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-config to 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 own GROK_HOME, with Claude and Cursor compatibility (skills, agents, hooks, MCP, rules) turned off. Codex still reads the PR's AGENTS.md as instructions, and any CLI reads the PR's files: a review of an untrusted PR is a run over untrusted text.

Environment variables

Variable Effect
BAZEL_HOME config directory (default ~/.bazel)
BAZEL_NO_SPLASH disables the opening animation
NO_COLOR / CI also disable the animation

Security model

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.json from 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, .CLAUDE is .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 with git 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 through PATH and symlinks, or that speaks Claude's stream-json (format: claude-stream, or that output in its args with no format) gets --setting-sources user --strict-mcp-config --plugin-dir <runtime> ahead of your args, and the built-in skills arrive as a plugin from outside the clone. It also gets a --disallowedTools list of GitHub writes, in the usual spellings (gh pr merge, gh api with 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 or curl with the token walks past it, and only Claude Code gets the list. The agent runs with your gh login. A publishing agent of your own keeps the publishing commands. Any other command, like a wrapper called cc, 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-sources is refused, and so is --settings, --mcp-config, --plugin-dir, --add-dir, the other flags that read configuration, and CLAUDE_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, make and the like), env and relative paths are refused as a command, before the clone, also behind a wrapper such as timeout 600 npx …: they decide what to run from the working directory, and the PR's node_modules, .npmrc or .tool-versions would 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. Point command at an installed binary, and set variables with env:.
  • The clone ignores your git config. The clone and the checkout run with GIT_CONFIG_GLOBAL and GIT_CONFIG_NOSYSTEM set, so a filter, LFS or core.hooksPath from your machine does not run over the PR's files, which is before the sweep. gh brings its own credentials, for the clone and for the files a blobless clone fetches later. A global url.insteadOf or http.proxy does not apply to that clone; use HTTPS_PROXY for a proxy.
  • Git stays out of it. Every agent with a clone runs with GIT_DIR and GIT_WORK_TREE pinned to it, plus safe.bareRepository=explicit, core.fsmonitor=false and core.hooksPath=/dev/null through GIT_CONFIG_*, after your own entries. A repository the PR nests in a folder would otherwise run its own config on an ordinary git diff or git status. gh and git -C work 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, because safe.bareRepository does nothing before that.

What is still on you:

  • sh -c as a command is your own. A shell takes no flags for what it calls, so sh -c 'claude …' runs with the first layer only, and anything your script does is yours.
  • Your own hooks run in the clone. --setting-sources user keeps 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, npx or make runs 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 setsid escapes 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 has Bash and your gh login: 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.

Development

make          # build ./bazel
make run      # serve the web UI and open the browser
make check    # fmt + vet + test, before committing
make help     # every target

The 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).

Starting 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.

About

No description, website, or topics provided.

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages