Skip to main content
Glama
README.md
<p align="center">
  <img src="brushrouterlogo.png" alt="BrushRouter — three AI mascots flying toward a raccoon carrying image.png" width="720">
</p>

# brushrouter

**Generate images with the AI you already pay for.** One CLI + MCP server that routes every request to the best image backend on the machine — your ChatGPT subscription, your Google login, your OpenRouter key — in that order, falling back automatically. Logins you already pay for first, the pay-per-image key last. No new API balance, no browser tabs, and no credential ever handed to a model.

```console
$ brushrouter gen "a red fox in snow" -o fox.png
✓ generated with gemini (gemini-3.1-flash-image-preview)
  /home/you/fox.png (image/png, 1250 KB)
```

It also ships a prompt library of 27 tested recipes and 137 vendored visual styles, because routing a bad prompt to a working backend just gets you a bad image faster.

- **Zero new spend** per image on routes that reuse a login you already pay for
- **One command to start** — `brushrouter setup` detects what's installed and connects it
- **No tokens exposed** — models never see your credentials, enforced by a redactor on every output
- **164-prompt library** — an agent asks for "a nice product shot" and gets a tested skeleton to fill in, not its own invention
- **MCP-native** — `brushrouter install` wires it into any agent's image work in one command

## Install

Requires Node.js 20+.

```bash
curl -fsSL https://raw.githubusercontent.com/avinaxhroy/brushrouter/main/install.sh | bash
```

Or manually:

```bash
git clone https://github.com/avinaxhroy/brushrouter.git
cd brushrouter
npm link
brushrouter setup
```

Or instantly via npx: `npx brushrouter setup`.

`setup` auto-detects image backends (ChatGPT desktop CLI, ChatGPT subscriptions, Google/Antigravity CLI, agent auth stores), offers one-command login if none are found, verifies `brushrouter` is on your PATH, wires every detected agent's MCP config, and offers an optional test generation. Diagnose any time:

```bash
brushrouter doctor     # diagnoses all provider backends + agent MCP integrations
```

## Use

```bash
brushrouter gen "<prompt>" [options]

  -p, --provider <id>   Force a provider (see `brushrouter providers`)
  -f, --fallback        With --provider, fall back to others on failure
  -o, --out <path>      Output path (default: brushrouter-<timestamp>.png)
  -s, --size <WxH>      e.g. 1024x1024, auto
  -q, --quality <lvl>   low | medium | high | auto
      --background <b>  opaque | transparent (codex route sends the API field;
                        other routes ask for it in the prompt)
      --images <path>   Reference image for image-to-image (repeatable)
  -m, --model <id>      Pin a model for this call
      --dry-run         Print the route order that would be attempted, then stop
      --force           Overwrite --out instead of writing a new versioned file
      --json            Machine-readable output
  -v, --verbose         Show routing decisions
```

All commands (`br` works everywhere as a shorthand for `brushrouter`):

```bash
br providers        # what is connected, and how
br routes           # which routes auto-routing may use
br models           # model ids (live for OpenRouter)
br prompts          # 27 recipes + 137 looks for briefing an image
br doctor           # full connectivity diagnosis
br setup            # first-run auto-connect
br login            # sign in to ChatGPT for the codex route (browser OAuth, no API key)
br vendor gptweb2api  # install the browserless ChatGPT-web gateway binary
br batch            # generate several prompts in parallel across backends
br init             # write a starter config
br install          # add brushrouter to detected agent MCP configs
br rules            # inject compact guidance into AGENTS.md / .cursorrules
br update           # update to latest version and verify health
br serve            # run as an MCP server on stdio
```

### Reference images

`--images` only works on routes that accept it; `brushrouter providers` marks the ones that don't. Passing it to a route that can't is an error when you forced the provider, a warning when auto-routing picked it.

| route | reference images |
| --- | --- |
| `codex-cli`, `codex` | yes |
| `openrouter` | yes (`input_references` data URLs) |
| `custom-http` | yes (`{image}` data URL in your `body` template) |
| `gemini`, `native-web`, `gptweb2api`, `fal` | no — text-to-image only |

### Transparent backgrounds

```bash
brushrouter gen "a red paper lantern sticker" --background transparent -o sticker.png
```

Best on the `codex`/`codex-cli` routes, where transparent is a real API field (`output_format: "png"` makes the backend honor it). Other routes can only ask in the prompt, so check the file. Even with the field set, the model decides — subject-shaped prompts (*sticker*, *isolated object*) reliably come back RGBA; full-scene wording may stay flattened.

### Codex size behavior

Codex doesn't take pixel dimensions and reads aspect ratio from the prompt. brushrouter appends your `-s 1672x941` as "width 1672 pixels, height 941 pixels (16:9)" — common aspect ratios land exactly or within 1px; other exact pixel counts aren't guaranteed.

### Pinning a model

`brushrouter models` lists what each provider can do; for OpenRouter the list is live, so it shows exactly what your key can reach.

```bash
brushrouter models openrouter
brushrouter gen "a red fox" -p openrouter -m x-ai/grok-imagine-image-2.0
```

A pin (`-m`, or `providers.<id>.model` in config) is never silently swapped. With no pin, brushrouter walks a preference list and takes the first model that works. Pins are honored by `codex-cli`, `codex`, `openrouter`, `gemini`, and `custom-http`; `gptweb2api` reports that it ignored the pin rather than pretending otherwise.

Models are resolved at runtime, not hardcoded — the Codex backend reads your `~/.codex/config.toml`, and a different model is only tried when the error is specifically "model not found". Quota (`429 usage_limit_reached`), auth (`401`), and regional/balance errors are reported as-is and never masked by a retry.

### Turning routes off

```bash
brushrouter routes                    # show state, in auto-routing order
brushrouter routes disable gptweb2api # skip it during auto-routing
brushrouter routes enable  gptweb2api
```

A disabled route is still reachable with `--provider <id>`. Non-interactive: `BRUSHROUTER_DISABLE=gemini,fal` and `BRUSHROUTER_ENABLE=openrouter`.

## Auth sources for ChatGPT routes

The `codex` and `codex-cli` routes use ChatGPT browser login — **no API key anywhere**. Credentials come from the first of:

1. `providers.codex.authFile` in config, or `BRUSHROUTER_CODEX_AUTH_FILE` (explicit)
2. **`~/.config/brushrouter/auth.json` — written by `brushrouter login`**
3. `~/.codex/auth.json` — Codex CLI / ChatGPT desktop app
4. `~/.local/share/opencode/auth.json` — OpenCode login
5. `~/.omp/agent/auth.json`, `~/.pi/agent/auth.json` — pi and oh-my-pi OAuth logins

`brushrouter login` opens a browser and signs in via the codex CLI's public OAuth client (PKCE, localhost:1455 callback) — nothing else to install, and the token refreshes itself. It prints `plan go/plus/...` so you know which subscription is billing the route. Sign out by deleting `~/.config/brushrouter/auth.json`. Free-plan users get a plain "plan-gated" message, not a raw 403.

## Providers

| id | needs | notes |
| --- | --- | --- |
| `codex-cli` | ChatGPT/Codex desktop app | **Native, preferred.** Drives the app's own signed CLI — already logged in, brushrouter never sees the token. |
| `codex` | ChatGPT login with a paid plan (`brushrouter login`, or any login on disk) | Direct backend call (`gpt-image-2`). Shares the Codex text quota. |
| `gemini` | Antigravity (`agy`) or Gemini CLI login | Delegates to the CLI; Google tokens never touch brushrouter. |
| `openrouter` | `sk-or-...` key | Off by default — the only route billed per request. Key auto-discovered from env or existing agent stores. |
| `fal` | fal.ai key | |
| `gptweb2api` | ChatGPT login (free plans work) + vendored gateway — `brushrouter vendor gptweb2api` | Off by default. Browserless web-session route, separate quota bucket (~2-3 free-plan images/day; see `docs/TECHNICAL.md`). |
| `custom-http` | your own endpoint | Template-based; point it at any image API. |

Default auto-routing order:

```text
codex-cli → codex → gemini → openrouter → fal → gptweb2api → custom-http
```

Three routes are off by default: `openrouter` (spends money), `gptweb2api` (needs `vendor`), `custom-http` (needs an endpoint). Enable with `brushrouter routes enable openrouter`. The `priority` list is authoritative — anything not listed is never auto-selected, though `--provider` still works.

OpenRouter runs two generation surfaces: multimodal chat models on `/chat/completions`, image-only models on `/images`. A "no endpoints found for output modalities" rejection is auto-retried on `/images`; auth, region, and credit errors are surfaced as they came.

## The prompt library

An agent asked for "a nice product shot" will invent a worse prompt than one already tested. So the tool that routes the generation also hands the agent a skeleton to fill in.

164 entries:

| `kind` | what it is | count | source |
| --- | --- | --- | --- |
| `job` | a layout to fill in — a product shot, a character sheet | 27 | written here |
| `look` | a fixed visual system to borrow — an editorial or poster style | 137 | vendored, MIT |

```bash
brushrouter prompts                        # the map: what kinds of image exist
brushrouter prompts "product hero shot"    # 1 best match + 3 similar
brushrouter prompts "flyer" -n 1           # force exactly one
brushrouter prompts -c infographic --full  # a whole category, full skeletons
brushrouter prompts --input image          # only recipes that can use a photo
brushrouter prompts --kind look "mono noir editorial"
brushrouter prompts --id logo-mark --full --set text=ACME --set subject="a peak"
```

Design facts, briefly: every recipe works with no reference image (each has a text skeleton, plus an optional `image` upgrade naming what it accepts); unfilled `{slots}` are reported in `missing` rather than shipped to the image model; a query that matches nothing returns nothing — search is lexical with a hand-written alias map (*hero shot* → `product-hero`, *dashboard* → `app-screen`). Results are previews by default (id, goal, when to use it, missing slots, credit); `--full` for the skeleton; `limit` is capped at 10 because this fills the caller's context window.

Modes: `--mode categories` for a vague request, `--mode related` (default) for one best match plus alternatives, `--mode one` for a single entry.

### Attribution

The 137 looks come from [VigoZhao/AI-Visual-Prompt-Cookbook](https://github.com/VigoZhao/AI-Visual-Prompt-Cookbook), published under MIT. They're vendored — pinned by commit, verified by hash, normalized into the same schema, shipped with the upstream licence and `ATTRIBUTION.md`. Every look carries its credit on the result itself, previews included; the licence requires it. Point `BRUSHROUTER_STYLES_FILE` at nothing and the library runs jobs-only instead of crashing.

Reproduce the data — neither source is read at runtime, neither is hand-edited:

```bash
npm run recipes:check    # validate every recipe and look against the schema
npm run styles:vendor    # re-vendor the looks from the pinned commit
npm run styles:check     # re-fetch upstream, recompute stylesSha256, compare
npm run corpus:analyze   # reproduce the structural findings behind the design
npm run corpus:check     # re-fetch upstream, recompute corpusSha256, compare
```

The 150-prompt corpus in `data/prompts/` is a development fixture, excluded from the npm tarball; nothing at runtime reads it. Schema, retrieval design, and failure modes are in [`docs/TECHNICAL.md`](docs/TECHNICAL.md).

## MCP server

`brushrouter install` detects and wires brushrouter into your installed agents (Claude Code, Claude Desktop, Cursor, Windsurf, OpenCode, Pi, Oh-My-Pi, CommandCode, Glass, shared MCP configs). Configs are backed up first (`.bak-...`), comments and formatting preserved, and no existing servers removed. Or add it by hand:

```json
{
  "mcpServers": {
    "brushrouter": { "command": "brushrouter", "args": ["serve"] }
  }
}
```

Exposed tools:

- **`generate_image`** — `{ prompt, provider?, model?, fallback?, size?, quality?, out?, images?, async? }`. Returns the saved path plus the image, all text through the redactor. `async: true` returns a `jobId` immediately instead of blocking for a minute or two.
- **`image_result`** — `{ jobId }`. Collects an async generation; poll again on a status while the job runs.
- **`find_image_prompts`** — `{ query?, category?, kind?, input?, shape?, mode?, limit?, full?, id?, values?, seed? }`. Returns previews or a full skeleton to build from before generating. Its description tells the agent every entry works from text alone, that `{braces}` must be filled first, and to pass `input: "image"` only when it actually holds a photo.

An agent's normal loop is `find_image_prompts` → fill the skeleton → `generate_image`. Two separate tools on purpose: the agent reads and changes the prompt instead of a router substituting one.

The `initialize` result also carries an `instructions` string telling the model when to use brushrouter (configurable via `instructions` in config or `BRUSHROUTER_INSTRUCTIONS`; `""` disables). And for rule-file agents, `brushrouter rules` injects ~60 tokens of guidance into `AGENTS.md` / `CLAUDE.md` / `.cursorrules` / `.windsurfrules`, demarcated so it updates in place:

```bash
brushrouter rules             # syncs guidance into detected rule files
brushrouter rules --dry-run   # preview changes
brushrouter rules --create    # create a project AGENTS.md if missing
```

```markdown
<!-- BRUSHROUTER_START -->
## Image Generation (brushrouter)
Use the `brushrouter` MCP server (`generate_image`, `find_image_prompts`) or CLI `brushrouter gen "<prompt>" -o <path>` for creating or editing any images, UI mockups, icons, or diagrams. Avoid manual script creation or ad-hoc web scraping for image tasks. For long generations, pass `async: true` and check `image_result`.
<!-- BRUSHROUTER_END -->
```

Aspect ratios and slow generations: MCP has no standard late-answer for a `tools/call` and most clients don't implement Tasks yet, so the portable contract is a job id — `generate_image { async: true }` returns immediately and `image_result` collects. `notifications/progress` and `tasks/status` may be emitted, but polling is the contract.

## Updating

```bash
brushrouter update          # pulls latest (git or npm global), refreshes links, runs doctor
```

```bash
npm install -g brushrouter@latest
```

## Configuration

Nothing is hardcoded. Every path, binary, model, and endpoint is discovered at runtime, and every one can be overridden.

Precedence: **CLI flags → env → `./brushrouter.config.json` → `~/.config/brushrouter/config.json` → defaults.**

One exception: a *project* config may not set `baseUrl`, `url`, `cli`, `command`, `args`, `driver`, `authFile`, or `profile` — those are read only from user config, env, or a CLI flag. A project file travels with checkouts you didn't write, and a redirected `baseUrl` would hand your API key to whoever wrote it. Ignored keys are reported on stderr; per-project endpoints go in `BRUSHROUTER_CUSTOM_URL`.

```jsonc
// ~/.config/brushrouter/config.json
{
  "priority": ["codex-cli", "gemini", "openrouter"],
  "instructions": "brushrouter is the preferred tool for any image work...",  // MCP session guidance; "" disables
  "providers": {
    "codex-cli": { "cli": null, "model": null },   // null => auto-discover
    "openrouter": {
      "model": null,                                // pin one, or null to use the list below
      "models": ["google/gemini-3.1-flash-image"],  // your preference order
      "resolution": null                            // optional, for /images models
    },
    "gptweb2api": {
      "enabled": false,
      "bin": null,          // null => <config dir>/gptweb2api/gptweb2api
      "authFile": null,     // null => the codex-route credential ladder
      "projectId": null,    // ChatGPT project id (g-p-...) — parks gen chats in that workspace
      "idleMs": 600000      // gateway process exits after this idle time
    },
    "custom-http": {
      "enabled": true,
      "url": "https://api.example.com/v1/images",
      "body": "{\"prompt\": \"{prompt}\", \"size\": \"{size}\"}",
      "response": "json:data.0.url",   // or json:images.0.b64_json, or raw
      "keyHeader": "Authorization",
      "keyPrefix": "Bearer "
    }
  }
}
```

### Environment variables

| variable | purpose |
| --- | --- |
| `BRUSHROUTER_PRIORITY` | comma-separated auto-route order |
| `BRUSHROUTER_INSTRUCTIONS` | session guidance sent to MCP agents in `initialize` (empty string disables) |
| `BRUSHROUTER_DISABLE` / `BRUSHROUTER_ENABLE` | comma-separated routes to turn off / on (unknown name is an error, not a silent no-op) |
| `BRUSHROUTER_RECIPES_DIR` | use a different recipe directory (same schema) |
| `BRUSHROUTER_STYLES_FILE` | use a different vendored styles file |
| `BRUSHROUTER_APP_DIRS` | extra dirs to scan for app bundles (auto-discovery) |
| `BRUSHROUTER_MCP_CONFIGS` | extra agent MCP config paths for `install` |
| `BRUSHROUTER_CODEX_CLI` | explicit path to a Codex CLI binary |
| `BRUSHROUTER_CODEX_CLI_MODEL` | model for the native `codex-cli` route |
| `BRUSHROUTER_CODEX_MODEL` | model for the direct `codex` provider |
| `BRUSHROUTER_CODEX_AUTH_FILE` | explicit auth file for the direct `codex` provider |
| `BRUSHROUTER_CODEX_CLIENT_ID` | override the derived OAuth client id |
| `BRUSHROUTER_CODEX_ENDPOINT` | override the Codex responses endpoint |
| `BRUSHROUTER_OAUTH_TOKEN_ENDPOINT` | override the OAuth token endpoint |
| `BRUSHROUTER_ORIGINATOR` | `originator` header sent to the Codex backend |
| `BRUSHROUTER_GEMINI_CLI` / `BRUSHROUTER_GEMINI_MODEL` | Gemini CLI path / model |
| `BRUSHROUTER_OPENROUTER_KEY` / `BRUSHROUTER_OPENROUTER_MODEL` | OpenRouter key / pinned model |
| `BRUSHROUTER_OPENROUTER_MODELS` | comma-separated OpenRouter preference order |
| `BRUSHROUTER_OPENROUTER_RESOLUTION` | image resolution requested from OpenRouter |
| `BRUSHROUTER_FAL_KEY` / `BRUSHROUTER_FAL_MODEL` | fal.ai |
| `BRUSHROUTER_GPTWEB_COOKIE` | cookie fallback for the vendored gptweb2api route |
| `BRUSHROUTER_GPTWEB_PROJECT_ID` | ChatGPT project (`g-p-...`) that anchors gptweb2api generations |
| `BRUSHROUTER_CUSTOM_URL` / `BRUSHROUTER_CUSTOM_KEY` | custom HTTP |
| `BRUSHROUTER_OPENAI_KEY` | fallback key name read for the OpenAI-compatible routes (`OPENAI_API_KEY` also works) |

Discovery scans `$PATH`, `/Applications`, `~/Applications`, the npm global root, and `~/.cache/codex-runtimes`, so a Codex CLI installed any of the usual ways is found without configuration.

## Security

A model never sees your credentials. Design constraint, not a setting:

- Keys live in the environment or a `0600` `~/.config/brushrouter/keys.json` inside a `0700` directory. Config files hold non-secret settings only.
- Every string brushrouter emits — CLI output, `--json`, errors, `doctor`, MCP results — passes through a redactor that strips known secret values and well-known token shapes (JWTs, `sk-...`, `ya29.…`, `Bearer ...`, refresh tokens), plus tokens registered by value from disk. The test suite asserts a fake secret survives no output path.
- `codex-cli` and `gemini` never read a token at all: they spawn a child process that owns its own authentication.
- What it does *not* confine: `--out` writes anywhere you say and `--images` reads any local file that sniffs as an image. Deliberate — the caller is your own agent, which already has those abilities; the MCP tool description states the capability rather than implying a sandbox.

## Limits

brushrouter doesn't pretend a broken backend works.

- Quota (`429 usage_limit_reached`) is reported as quota, with reset time, and never triggers a wasteful model retry.
- Auth (`401`, or not logged in) is reported as auth, with the command to fix it.
- A retired model (`404 model_not_found`) is the only case that tries the next model.
- A deprecated free tier (e.g. Google's retired Gemini CLI individual tier) is labeled as such.

The Codex **text quota and the native image path share a bucket**; when exhausted, the native route says so and routing moves on. The ChatGPT *web* Images bucket is separate, but reaching it needs the gptweb2api route — the app's own conduit is App-Attest protected and can't be forged.

## Why I built this

Image generation is the one thing I refuse to pay per image for. I already pay for ChatGPT and already have a Google login — two capable image generators sitting on this machine — and I was still opening a browser tab or topping up an API balance. So the route order falls straight out of "spend what I already pay for first". The other two rules: ship the prompts too (a free image from a bad prompt is a wasted call), and never let a model see a token or pretend a broken backend works.

## Development

```bash
npm test                        # 144 tests, no dependencies
npm run recipes:check           # validate every recipe and look against the schema
npm run styles:check            # re-fetch the pinned cookbook, verify stylesSha256
node bin/brushrouter.js doctor
```

Zero runtime dependencies, ESM, Node 20+.

## Credits and license

MIT for the code, for the recipe library in `data/recipes/`, and for the normaliser that produces `data/styles/`. We wrote the recipes.

The 137 looks in `data/styles/` are vendored from [VigoZhao/AI-Visual-Prompt-Cookbook](https://github.com/VigoZhao/AI-Visual-Prompt-Cookbook) at a pinned commit, MIT (`Copyright (c) 2026 VigoZhao`); upstream `LICENSE` and `ATTRIBUTION.md` ship with them.

The 150-prompt corpus in `data/prompts/` is a development fixture, not shipped and not covered by that licence — third-party text redistributed under its upstream terms (115 rows CC0, 35 rows CC-BY, authors credited in `data/prompts/ATTRIBUTION.md`; full terms in `data/prompts/LICENSES/`). Quote a prompt along with its `author` and `sourceUrl`.