bbq-mcp
by adesombergh
README.md
# bbq 🔥🍖
**Don’t grill your brain.**
A browser UI for Claude Code grilling sessions.
Long grilling sessions are cognitively expensive: code-heavy questions are
information-dense, while the terminal mixes questions, options,
recommendations, and answers into one scrolling stream.
bbq moves grilling into a dedicated UI. Questions, choices, context, and
follow-ups are structured and easy to scan, so you can spend your attention on
the decisions—not on parsing the conversation.
It is an MCP server plus a `bbq` skill that runs the `grilling` skill (a
relentless, multi-round interrogation of a plan) in the browser. Claude pushes
rounds of questions into it; you answer with buttons or free text, and on any
question you can ask for a re-pitch (**Wait what**), a visual (**Show me**,
which may come back as a diagram) or a plain-language explainer (**ELI5**),
which open in a contextual panel on the right.

## Install
bbq needs [Bun](https://bun.sh) 1.4 or newer; the server is built on
`Bun.serve`. The browser UI comes prebuilt inside the `bbq-mcp` package, so
there is nothing to build.
### As a Claude Code plugin (server + skills in one go)
```
/plugin marketplace add adesombergh/bbq
/plugin install bbq-mcp@bbq
```
That registers the MCP server (pinned to the plugin's version) and installs
the `bbq` skill plus its `barbecue` and `churrasco` aliases, and `bbq-offload`,
namespaced as plugin skills are: `/bbq-mcp:bbq`, `/bbq-mcp:barbecue`,
`/bbq-mcp:churrasco`, `/bbq-mcp:bbq-offload`. Then: "grill me on X with the UI",
or `/bbq-mcp:bbq`.
### As a plain MCP server
Add to `.mcp.json` in the project you grill from, or to your user-level config:
```json
{
"mcpServers": {
"bbq-mcp": {
"command": "bunx",
"args": ["bbq-mcp"]
}
}
}
```
Then copy or symlink `skills/bbq` from this repo where your skills live
(`.agents/skills`, `.claude/skills`…), and `skills/barbecue` and
`skills/churrasco` beside it if you want those words to work too — they are
short stubs that invoke `bbq`, because skill frontmatter has no `aliases` field
(`docs/adr/0018-aliases-are-stub-skills.md`). Add `skills/bbq-offload` too if you
want to answer other skills' questions in the browser.
### Optional aside skills
Two of the three aside kinds can call an aside skill, both third party and both
optional:
```sh
npx skills add humanlayer/skills --skill show-me # Show me
/plugin install eli5@claude-community # ELI5
```
**Wait, what?** has no skill: the one of that name in `mattpocock-skills` is
`disable-model-invocation`, a slash command for you rather than something Claude
can invoke, so its brief is the implementation.
Nothing here is required. Every kind's brief in `src/aside-brief.ts` is the
floor, not a fallback — Claude answers an aside from it alone, and where a skill
would have done better it says so at the end of the aside with the command
above. The browser never learns what you have installed
(`docs/adr/0015-skill-availability-is-not-session-state.md`).
### From a clone
```sh
bun install # one workspace: server + ui deps, installs git hooks
bun run build # builds ui/dist once; the MCP server only serves it
bun run demo # plays Claude against the server, opens the browser
```
See `CONTRIBUTING.md` for running your working copy from Claude Code and for
the release process.
### Step 0
Before the browser opens, Claude settles three things — asking only for what you
did not already say when you invoked it:
- **Mode** — `grill-me`, or `grill-with-docs` to also read this project's
`GLOSSARY.md` and `docs/adr/` and write new terms and decisions as they settle.
- **Question budget** — a maximum number of questions, no maximum by default. It
counts questions inside rounds; asides and the last round are free. Spent in
full, Claude names what it did not get to and what it is assuming.
- **Language** — English or French. It covers what Claude writes — questions,
notes, asides — and not the browser's own words or this repo's documents.
### The last round
When the frontier empties, Claude posts the full shared understanding as a note
and asks one short question whose options are the four **destinations** — where
the plan goes next:
| | |
| ----------------------- | ------------------------------------------------------------ |
| **Just send to Claude** | Claude writes the plan into the terminal and stops. |
| **`/implement`** | …and ends with the line you type to build it. |
| **`/to-spec`** | …and ends with the line you type to publish it as one issue. |
| **`/to-tickets`** | …and ends with the line you type to cut it into tickets. |
Three of the four are `disable-model-invocation` skills, so the answer never
starts anything — it says what to set you up for, and all three read the
conversation they are typed in. A free-text answer to that question means "not
yet": the decision it names reopens and the grilling carries on. Nothing about a
destination reaches the Store or a snapshot; it is an ordinary answer to an
ordinary round, and the menu never varies with what you have installed.
### Offloading another skill's questions
Grilling is one kind of session. The other is an **offload**: type
`/bbq-offload` (`/bbq-mcp:bbq-offload`) and whatever skill Claude runs next —
`superpowers:brainstorming`, a wrapper around it, your own interview skill —
keeps its flow in the terminal while every question it would ask you arrives in
the browser, one at a time, as it comes up.
- The running skill owns the ending. There is no frontier, no last round and no
destination; bbq only asks.
- A round sends itself on the answer that completes it: no **Send answers to
Claude** press. Picking and confirming an option still take two presses.
- There is no Step 0. The language follows the conversation (`/bbq-offload fr`
overrides it).
- Close the tab's session and Claude asks the remaining questions in the
terminal; it never reopens one on its own.
- The skill's own browser companion, if it has one, is not offered: **Show me**
answers visual questions.
The kind is set on `open_session` and changes the prose each tool result
carries back to Claude (`docs/adr/0021-offload-is-a-session-kind.md`).
## Design
**Claude never talks to the browser.** Claude talks to state (`src/state.ts`);
state and the browser talk over WebSocket (`src/hub.ts`). The MCP tools
(`src/mcp.ts`) only read/mutate state and `waitFor` changes. That seam is the
whole design.
```
Claude ──MCP stdio──▶ src/mcp.ts ──▶ Store (src/state.ts) ◀── WS ── src/hub.ts ◀── browser (ui/)
```
### Tools
| tool | blocks? | purpose |
| ------------------------------------------------------ | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `open_session({ title, kind? })` | until a tab connects (≤ `waitForBrowserMs`, default 30s) | start a `grilling` (default) or `offload` session, open the browser |
| `ask_round({ sessionId, intro?, questions })` | no | publish one round: a frontier, or an offloaded question |
| `wait_for_answers({ sessionId, roundId, timeoutMs? })` | ≤ `timeoutMs` | poll; returns `answered` / `aside_requested` / `disconnected` / `pending` / `closed` |
| `post_aside({ sessionId, asideId, format, content })` | no | deliver a Wait-what / Show-me / ELI5 result to the panel |
| `post_note({ sessionId, markdown })` | no | chat bubble between rounds |
| `close_session({ sessionId })` | no | mark the session finished |
### Polling contract
No tool ever blocks past its timeout. `wait_for_answers` waits at most
`timeoutMs` (default 240s via `BBQ_WAIT_MS`, hard cap 280s: under Claude
Code's 5-minute MCP idle cutoff) and returns `pending` on timeout. Claude just
calls it again, so a round can sit open for an hour at the cost of a handful of
tool calls. The default is long because every event worth waking for resolves
the wait early: an answer, an aside, a close, and the last browser tab going
away (`disconnected`). Asides in particular interrupt the wait immediately and
are returned before answers so the user is never left staring at a spinner. A
poll that finds nothing is two lines, because it is the result a session
repeats most.
### UI rules
- One question is active at a time; the first unanswered one on a new round.
- Question N unlocks once N-1 is answered. Any answer can be changed until the
round is sent (click an answered question to reopen it).
- Answering advances to the next question. When all are answered, **Send
answers to Claude** submits the round; the round then freezes. In an offload
session the completing answer submits it and there is no send button.
- The active card shows three separated blocks: question, options
(+ manual answer textarea), recommendation (+ _Go with recommendation_, and the
three aside buttons).
- A closed session shows its **debrief** where the round would be: rounds,
questions and elapsed time as tiles, then how often you took the
recommendation, wrote your own answer and asked for an aside. A tab that has
been open since the session started also reports how long you were away from
it and how long you spent on the pastime; a tab that joined late leaves that
block out rather than reporting a share of a session it did not see. All of
it is derived in the browser — Claude never receives it.
- The right panel is per question, tabbed by aside kind, closable (Esc).
`markdown` asides render as rich text; `html` asides render in a sandboxed
frame (no scripts). Claude picks the format per aside — the kind does not fix
it. A ```mermaid block in an aside is drawn: mermaid arrives through a lazy
`import()`, renders to an SVG string and goes through the same frame, and a
block that does not parse falls back to its source.
### Hard constraints honored
1. stdout belongs to MCP; every log is `console.error`. The server never spawns
a build tool or dev server.
2. The UI is prebuilt; `src/mcp.ts` serves `ui/dist` and shows a placeholder
page if it is missing. Startup is instant.
3. `idleTimeout: 0` on `Bun.serve` and on the WS handler.
4. Polling contract above.
5. Binds `127.0.0.1:0`. Every request needs the per-process token: `?token=` on
the first navigation, persisted as an HttpOnly cookie for assets and the WS.
## Layout
```
src/mcp.ts MCP entry (stdio) + tools
src/state.ts Store: sessions, rounds, answers, asides, notes, waitFor
src/round-rules.ts pure round rules shared with the UI (canAnswer, isComplete, sendsOnAnswer)
src/tool-prose.ts the next-step prose tool results carry, per session kind
src/protocol.ts Zod validation of browser -> hub messages
src/hub.ts Bun.serve: static ui/dist, WS relay, token auth
src/browser.ts open the default browser without touching stdio
src/types.ts shared types (imported by the UI as @shared/types)
ui/ Vite 8 + React 19 (React Compiler) + Tailwind v4, builds to ui/dist
src/router.tsx TanStack Router: / and /s/$sessionId (typed search params)
src/lib/ Query options, WebSocket -> cache feeder (Pacer backoff), mutations, derivations
src/components/ Question card, round, aside panel, TanStack Form manual answer, markdown, diagrams
src/components/ui shadcn components on Base UI (ours to edit)
scripts/demo.ts fake Claude for manual testing
skills/ bbq (the grilling protocol), bbq-offload (another skill's questions), barbecue/churrasco alias stubs
.claude-plugin/ plugin + marketplace manifests; .mcp.json is the plugin's pinned server entry
test/ bun tests for state, protocol, hub and release manifests
```
## Tooling
- `bun run check` is the definition of done: `oxfmt --check`, type-aware `oxlint`, `tsc` for both packages, `bun test`, `vite build`.
- Lint and format are [ultracite](https://www.ultracite.ai) presets on top of oxlint + oxfmt (`oxlint.config.ts`, `oxfmt.config.ts`), with the same additions as a sibling internal repo. Zero suppressions.
- `lefthook` runs format + lint on staged files before each commit and `bun run check` before each push.
- Releases: tag `vX.Y.Z`, CI publishes `bbq-mcp` to npm with provenance (`CONTRIBUTING.md`, `docs/adr/0020-distribution-is-npm-wrapped-by-a-plugin.md`).
- `.claude/settings.json` runs `ultracite fix` after every file Claude writes. `CLAUDE.md` holds the rules an agent must know; `AGENTS.md` is the generated ultracite standard.
- UI state model: TanStack Query owns the session snapshot (fed by the WebSocket), TanStack Router owns the view state in the URL, there is no `useEffect` in `ui/src` (the import is banned by lint) and the React Compiler handles memoisation.
Env: `BBQ_WAIT_MS` default poll length, `BBQ_NO_OPEN=1` to not launch a browser.
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues