A local web app for chatting with LLMs and comparing their answers side by side. Send one message to up to four models at once, each in its own pane with its own system prompt and settings, and watch the replies stream in parallel.
- Providers: OpenRouter, OpenAI, Anthropic, Google, Ollama, LM Studio, and any OpenAI-compatible endpoint
- Image generation: point a pane at an image model (Nano Banana and other Gemini image models, gpt-image-1, dall-e) and sending it a prompt generates an image instead of streaming text. See Image generation
- Text to speech: a pane on a speech model reads each message aloud and answers with audio, and any reply can be read aloud with a speech model you choose. See Text to speech
- Comparison: up to 4 panes per chat, each with its own stop button and a regenerate action; a single composer can target all panes or only some. On the new chat page, each pane can get its own profile, instructions and generation, image or speech settings before the first message is sent. The model picker filters by provider and by type (text, image, speech), which helps when a model is offered by several providers. "Compare replies" shows each exchange's speed, tokens and cost with totals, and a word-level diff of any two panes. One reply per exchange can be marked as the best
- Editing: edit a message you sent and every pane that has it answers again; branch any reply into a new chat; a failed reply can be retried or sent to another model
- Queuing: send a message while a pane is still answering and it's queued — shown in the transcript right away, sent once that reply finishes, so it can be cancelled before then or left to send itself
- Export: a chat as Markdown, or as a single HTML page with the replies side by side
- History: messages are saved to SQLite through repolayer. A chat can also be temporary, in which case nothing is written. Ctrl+K searches saved messages, and a model you choose can write chat titles
- Profiles: reusable system prompts that can also limit tools and set their own tool round limit and generation settings, plus custom instructions for each pane. Starter profiles are added once: General assistant, Research analyst, Coding assistant, Data analyst, Writing editor and Tutor. Deleted ones don't come back
- Web search: models can search the web and read pages, with Tavily, Brave, or the provider's own search. Each reply shows what was searched and which sources were used
- Tools: besides the web, a JavaScript sandbox, the current time, search across earlier chats, memory, and any tools from MCP servers you add. Each tool can run automatically, ask you first, or be off
- Workspaces: a chat can have a folder of files that models create, read and edit, and, if you allow it, a model can compile and run code there. A Files panel in the chat shows, previews, uploads and downloads them
- Attachments: images, PDFs and text files can be attached to a message
- Memory: facts about you that are added to the system prompt. Models can save, update and forget facts when you ask them to, and a model you choose can suggest new ones for you to keep or dismiss
- Generation settings: temperature, top P, output limit, reasoning effort and thinking budget, as defaults, per profile or per pane. Each provider gets the nearest setting it supports
- Stats: time to first token, tokens per second, token counts, and cost where the provider reports it
- Usage: a page with token usage, cost and speed per model over time, so you don't need each provider's dashboard
Node 22.18 or newer. The server runs TypeScript directly through Node's type stripping, and repolayer needs
node:sqlite.
npm install
cp .env.example .env # then add the keys you have
npm run dev # API on :8787, app on http://localhost:5173API keys go in .env and stay on the local server:
| Variable | Used for |
|---|---|
OPENROUTER_API_KEY |
OpenRouter |
OPENAI_API_KEY |
OpenAI |
ANTHROPIC_API_KEY |
Anthropic |
GEMINI_API_KEY |
Google (Nano Banana and other Gemini image models, Gemini TTS) |
CUSTOM_API_KEY |
Optional key for a custom OpenAI-compatible endpoint |
TAVILY_API_KEY |
Web search through Tavily |
BRAVE_API_KEY |
Web search through Brave Search |
PORT |
API port, default 8787 |
DATABASE_FILE |
SQLite file, default ./data/switchbox.db |
WORKSPACE_DIR |
Folder for chat workspaces, default ./data/workspaces |
LOG_LEVEL |
debug, info (default), warn or error |
LOG_FORMAT |
json for one JSON object per line instead of text |
Everything else is configured on the Settings page and stored in the database: enabled providers, base URLs
for local servers, generation defaults, prompts, memory options, theme, and so on. Restart the server
after changing .env.
For local models, start Ollama or LM Studio and turn the provider on under Settings → Providers. The model picker also accepts any model id typed directly, which helps when a server doesn't list its models.
Set a pane's model to an image model — Nano Banana or Nano Banana Pro (Gemini image models), gpt-image-1, or dall-e — and sending it a message generates an image instead of streaming text. The image comes back as an attachment on the reply. Its save button offers the original file, a copy converted to PNG, JPG or WebP, or the image on the clipboard. Image models are marked "Image" in the model picker.
Aspect ratio, resolution and quality are set under Settings → Generation as defaults, and per pane in the pane's settings. Each provider uses the options it supports and skips the rest: resolution (1K/2K/4K) applies to the Gemini 3 image models, and quality to OpenAI's. OpenAI takes fixed sizes rather than ratios, so the ratio picks square, wide or tall.
There are three ways to reach Nano Banana:
- OpenRouter, which proxies it (and other image models) through the same endpoint as everything else. No
extra key beyond
OPENROUTER_API_KEY - Google, directly, with
GEMINI_API_KEY. Turn the Google provider on under Settings → Providers - OpenAI's own
gpt-image-1anddall-emodels, withOPENAI_API_KEY
Attach an image to a message in a pane using OpenRouter or Google and the model edits it instead of starting from nothing — editing isn't a separate mode, it's just what happens when a prompt has an image attached. OpenAI's image models don't support this: they only take a text prompt.
A few limitations: the image appears all at once when generation finishes rather than streaming in, a pane doesn't automatically feed its own earlier image back in for a follow-up edit (re-attach it to do that), and cost is only reported for OpenRouter — Google and OpenAI image replies show "no price" on the Usage page.
A pane set to a speech model reads each message you send aloud. The reply is an audio player with a save
button that offers the original file or a copy as MP3, AAC (.m4a), AC-3 (.ac3, Dolby Digital) or WAV.
Conversion happens in the browser with Mediabunny and its WASM encoders, which load
the first time you save, so nothing extra has to be installed. The compressed formats are encoded at 96 kbps
per channel, which is plenty for speech, and AC-3 is resampled to 48 kHz, the highest rate it supports. Speech models are marked
"Speech" in the model picker. They come from three places:
- OpenAI:
gpt-4o-mini-tts,tts-1andtts-1-hd - OpenRouter: every model it lists as speech output, such as Gemini Flash TTS, Voxtral, MAI-Voice and Grok Voice. OpenRouter leaves these out of its main model list, so Switchbox asks for them separately
- Google, directly:
gemini-3.8-flash-ttsandgemini-3.8-flash-lite-ttsthrough Google's Interactions API. Turn the Google provider on under Settings → Providers
Voice, speed, format and style are set under Settings → Generation as defaults and per pane in the pane's
settings. The voice field suggests each provider's known voices and accepts any other name. A voice meant for a
different provider, such as alloy on a Gemini model, is replaced with that model's default, so one default
voice doesn't break other providers. The style ("calm and warm, unhurried") goes to OpenAI's instructions,
Gemini's style note, and OpenRouter's per-provider options; tts-1 ignores it. Gemini has no numeric speed, so
a speed below 0.9 or above 1.1 becomes "speaking slowly" or "speaking quickly" in its style. OpenRouter returns
MP3 or raw PCM, which Switchbox wraps as WAV.
With Google directly, a message written as a script with two speakers, one Name: line per line, is read as
a conversation using the first and second voice. Inline tags such as <laugh> and <sigh> pass straight
through.
Read aloud. Choose a speech model under Settings → General and every text reply gets a Read aloud button. Markdown is cleaned up first: code blocks and tables are announced rather than read out, and formatting marks are dropped. Long replies are spoken in pieces of up to 4,000 characters and joined into one file, up to 40,000 characters. Audio is kept in the browser for the session, so reading a reply again doesn't pay twice.
Audio is never sent back to a model as history, and audio files can't be attached to a message. Speech costs aren't on the Usage page yet, since none of these endpoints report a price.
Choose how search runs under Settings → Web search. Each chat has its own Web on/off toggle.
| Mode | How it works |
|---|---|
auto (default) |
Tavily if TAVILY_API_KEY is set, else Brave if BRAVE_API_KEY is set, else native |
tavily |
A local web_search tool. Results come back as summarized passages, so models can often answer without opening every link |
brave |
A local web_search tool with ordinary web results. Cheaper and more neutral, but models open more pages |
native |
No local search tool. The model's provider searches on its servers: OpenRouter's web plugin, Anthropic's web_search server tool, or OpenAI's web_search_options (search-enabled models only). No extra key, but quality varies by provider, and Ollama, LM Studio and custom endpoints have none |
none |
No web search |
Separately, a web_fetch tool lets models read a page by URL; you can turn it off in the same settings tab. It
refuses localhost and private-network addresses, including redirects and hostnames that resolve to them.
With Tavily or Brave, web_search is a tool like web_fetch, and both follow the tool loop described next.
Models call tools, the server runs them and sends back the results, and this repeats up to the "Tool rounds per reply" limit under Settings → Tools. After that, the model is asked to answer with what it has.
| Tool | Group | What it does |
|---|---|---|
web_search, web_fetch |
Web | See Web search. Follows each chat's Web toggle |
memory_save, memory_update, memory_forget, memory_list |
Memory | See Memory. Follows each chat's Memory toggle |
run_js |
Run JavaScript | Runs JavaScript for arithmetic, dates and data processing, and returns console output and the last value |
current_time |
Current time | The date and time in any time zone |
conversation_search |
Search earlier chats | Finds messages in other saved chats that contain all the given words |
read_attachment |
Read attached files | Reads the rest of a long text file attached to the chat |
list_files, read_file, write_file, edit_file, delete_file, move_file, find_in_files |
Workspace files | See Workspaces. Follows each chat's Files toggle |
run_command |
Run commands | Runs a command in the chat's workspace folder and returns its exit code, output and changed files. Asks first by default |
mcp_<server>_<tool> |
One per MCP server | Tools from the MCP servers you add |
- Chat toggles: the Tools menu in a chat's header turns optional groups on or off for that chat. Web and memory keep their own toggles
- Policies: each tool is Auto (runs without asking), Ask (the reply pauses and shows the call's
arguments until you allow or decline it) or Off (not offered). MCP tools default to Ask, the others to
Auto, except
run_command, which asks. A declined call tells the model you declined, and stopping a reply declines anything still waiting - Profiles: a profile can limit its panes to some tool groups and set its own round limit
- Parallel calls: calls from one round run a few at a time, set by "Tool calls at once"
- Later messages: follow-up questions see earlier tool calls and their results, shortened by default, so a model can refer to a page it read without fetching it again. Settings → Tools can keep them in full or leave them out. The history is built on the server from saved messages; temporary chats keep theirs in memory
- Context window: when a request gets close to the model's context window, long tool results are shortened first, then the earliest exchanges are left out, and the reply says so
- Outside content: results from web pages and MCP servers are wrapped in
<tool_output>tags, and the system prompt tells the model to treat them as information, not instructions - Models without tools: local models often reject tool definitions. They get a retry without tools, and for the next hour that model is sent requests without tools
The run_js sandbox runs in a worker thread with a memory cap and a 5 second limit, inside a V8 context with no
Node globals (no require, process, network or file access) where compiling code from strings is disabled.
Turn on Files in a chat's header, or on the new chat page, to give the chat a workspace. It is a folder of
its own under WORKSPACE_DIR, made the first time a file is written, so chats that never use it leave nothing
on disk. Every pane in the chat shares it. The system prompt lists the files already there, and the folder
button next to the switch opens the Files panel. The panel previews text and images, and you can upload by
button or drag and drop, download one file or all of them as a ZIP archive, and delete files.
- Paths: every path is checked to stay inside the chat's folder. Absolute paths,
.., Windows device names and alternate data streams are refused, and so are symbolic links or junctions that lead out of it - Limits: 100 MB per chat and 20 MB per file by default, under Settings → Tools → Workspaces. A folder bound as below (see next) is exempt from the total limit, since it's your own disk rather than chat storage, but the per-file limit still applies
- Lifecycle: turning Files off keeps the files. Branching a chat copies them, and deleting a chat deletes them. Temporary chats lose theirs on restart, like their messages
- Serving: the panel serves files as images or plain text, sandboxed and never sniffed, so an HTML or SVG file a model wrote can't run scripts in the app
Binding to a real folder: the link icon next to the Files switch points a chat's workspace at an existing folder on this computer (paste its full path) instead of the chat's own hidden folder — useful for letting a model work directly in a project directory. The same path checks above still apply, rooted at that folder. A few things to know:
- Deleting the chat never deletes the bound folder or anything in it, only the chat's own (empty, unused) hidden folder — the two are never the same folder. The Files panel's "Delete the workspace" button does empty the bound folder's contents, since that's an explicit action on it, not a side effect of chat deletion
- Branching a chat does not carry the binding over; the branch gets its own hidden folder, so two chats never end up writing into the same real folder implicitly
- The workspace usage figure shown in the Files panel is informational for a bound folder; it isn't enforced
Run commands is a separate tool group, off in new chats and turned on in a chat's Tools menu. It only works
while Files is on. run_command starts a shell in the workspace folder (or the bound folder, if any), so models
can use whatever compilers and runtimes you have installed. The tool's description tells the model which common
ones it can see.
By default, commands are not sandboxed: run_command runs the system shell (cmd.exe on Windows, /bin/sh
elsewhere; PowerShell or bash can be chosen) as an ordinary program with your user account, and an approved
command can read or change any file you can. What Switchbox does regardless of sandboxing:
run_commanddefaults to Ask, and the approval card shows the command line itself- API keys and other variables from
.envare left out of a command's environment. Only what shells and compilers need (such asPATH,SYSTEMROOTandHOME) is passed on, andTEMPpoints into the workspace - stdin is closed, so a program waiting for input can't hang forever. It runs until the timeout (60 seconds by default), and then it and everything it started are killed. Stopping the reply kills it too
- output is capped at 20,000 characters per stream, keeping the start and the end
- an import never changes the Run commands policy
Sandbox commands, under Settings → Tools → Workspaces, runs each command inside a
bubblewrap jail in WSL2 instead, on Windows with WSL installed. The
jail sees only the workspace (or bound) folder plus a minimal read-only system (/usr, /bin, /lib), gets a
fresh /proc, /dev and /tmp, and has no network access unless "Network access by default" is on or the
model's call asks for it (subject to approval, like any other command). This needs bubblewrap installed once
in the WSL distro:
wsl -d <distro> -- sudo apt install -y bubblewrapToolchain detection then looks inside that WSL distro instead of the Windows PATH, so run_command's
description only lists what's actually installed there (build-essential for C/C++, python3-full for
Python, and so on — install what you need with apt). If sandboxing is on but bubblewrap isn't found,
run_command refuses with the install command above rather than silently running unsandboxed.
Without WSL (macOS, Linux, or Windows without WSL) or with sandboxing off, run Switchbox itself inside a container or VM for real isolation.
Add servers under Settings → Tools → MCP servers, either as a command Switchbox starts (stdio) or as a Streamable HTTP URL with optional headers. "Test connection" lists a server's tools before you save it. Servers connect the first time their tools are needed and stay connected. A server that fails to start is tried again after a minute, and the error shows in Settings and in the chat's Tools menu.
Stdio servers run with your user account, so only add servers you trust. Servers in an imported export start switched off.
Memories are facts about you, listed on the Memory page. In chats with memory turned on, the active ones are added to the system prompt, up to "Most memories per prompt". When there are more, the ones that share the most words with your message are sent. Models get these tools:
| Tool | What it does |
|---|---|
memory_save |
Saves a fact, e.g. when you say "remember that I use metric units". It is active right away and marked "Saved by a model" |
memory_update |
Rewrites a fact that changed, e.g. after you move |
memory_forget |
Moves the matching memory to Forgotten on the Memory page, where you can restore it. When several match, nothing changes and the model gets the candidates back |
memory_list |
Lists everything in memory, including facts left out of the prompt |
Saving a fact that already exists does nothing, or turns it back on if it was paused, dismissed or forgotten. The system prompt tells the model it has long-term memory, so it uses these tools instead of saying it can't remember.
Under Settings → Memory you can also have a model read each exchange and suggest facts. Those wait in Suggestions until you keep or dismiss them. Suggestions are deliberately sparse, since asking a model to remember something or adding it on the Memory page are the main ways in. Only your own messages count as evidence (never the model's replies or tool output), a fact has to be useful in unrelated chats, and each exchange adds at most two. The model is shown what you dismissed recently, and a suggestion worded like any memory already stored, whether saved, waiting, dismissed or forgotten, is dropped. If the suggestion model fails, the Memory page shows the error until the next run works or you dismiss it.
On the Memory page:
- Scope: each memory is used in all chats, or only in panes that use one profile. Deleting a profile moves its memories back to all chats
- Forgetting: the delete button on a memory moves it to Forgotten, where it can be restored or deleted for good
- History: every memory keeps a record of when it was added, changed, forgotten or restored, and by whom, with changes shown word by word. History is included in Settings → Data exports
- Duplicates: memories that say nearly the same thing are listed for review; keep one and the other is forgotten, or keep both and the pair stops coming back in this browser
- Conflicts: "Check for conflicts" asks the suggestion model which memories contradict each other, such as two home cities
The Usage page totals tokens, cost, replies and median speed for the last 7, 30 or 90 days or all time. It can be filtered by provider, and it shows a chart per day (per week for long histories) and a table per model.
- Every saved reply writes a row to
usage_records. The rows stay when a chat is deleted, including through Settings → Data, so totals include deleted chats. Temporary chats aren't recorded - Reported cost is what OpenRouter charged for the reply
- Estimated cost uses list prices from OpenRouter's public model catalog, which needs no key. Anthropic and
OpenAI models are matched by name, e.g.
claude-sonnet-4-5-20250929toanthropic/claude-sonnet-4.5. Prices are cached for six hours - Ollama and LM Studio models count as free. Models with no price show "no price" and are left out of the cost
- Replies saved before usage tracking existed are added on the first start, and so are replies from an import
- Use of the same API keys outside Switchbox doesn't show up
npm run build
npm start # serves the built app and API on http://localhost:8787The server binds to 127.0.0.1 only. It also refuses API requests that aren't addressed to localhost or
127.0.0.1 (this blocks DNS rebinding) and requests sent from other websites. Request bodies must be
application/json, so a web page can't submit a form to the API either.
| Script | What it does |
|---|---|
npm run dev |
API server with watch mode, plus the Vite dev server |
npm run build |
Builds the client into dist/ |
npm start |
Runs the API and serves dist/ |
npm run lint |
Oxlint |
npm run typecheck |
TypeScript, no emit |
npm test |
Vitest: server tests, plus client tests for the chat store and components (jsdom and Testing Library) |
| Keys | Action |
|---|---|
| Ctrl+K | Command palette: jump to a chat or page, or search saved messages |
| Ctrl+Shift+O | New chat |
| Ctrl+\ | Show or hide the sidebar |
| Enter / Shift+Enter | Send / new line (switchable in Settings); queues instead if a reply is streaming |
| Esc | Stop all replies while streaming |
src/
shared/ Types, defaults and the SSE parser used by both sides
server/ Hono API
db/ repolayer schemas and repos (one table per repo), column additions, versioned migrations
providers/ OpenAI-compatible, Anthropic and Google adapters (tool calls, native search, attachments,
reasoning settings, prompt caching, retries, image and speech output)
tools/ tool registry, argument checks, approval policies, built-in tools and MCP servers
web/ Tavily and Brave search, safe page fetching
services/ chat runs and the tool loop, message storage and history, attachments, workspaces and
commands, ZIP archives, memory, search, usage reports, settings, read aloud
routes/ REST endpoints and the /api/chat/stream SSE endpoint
client/ React 19, MUI 9, TanStack Query, Zustand
features/ chat, memory, settings, usage
stores/ live streaming state per pane
A send is one POST /api/chat/stream with the pane ids and the new message, and the server builds each pane's
history from its stored messages. It streams every target pane concurrently and tags each SSE event with its
pane id. Every pane has its own abort controller, so POST /api/chat/stop can stop one pane and leave the
others running. Stopped replies keep their partial text. A tool call waiting for approval is answered with
POST /api/chat/approve. A message sent while a pane is busy is held client-side and sent as its own
POST /api/chat/stream once that pane's run finishes.
A run doesn't depend on the connection that started it. The server keeps every run's events, numbered, in
memory until five minutes after it ends. Closing the tab, reloading or losing the network doesn't stop
generation; only Stop does. A client that drops picks the run back up with
GET /api/chat/runs/:runId/stream?after=<last event id>. A page opened mid-reply finds runs still going with
GET /api/conversations/:id/runs and replays them. A reply is saved to its row every couple of seconds while
it streams, marked finishReason: 'streaming'. At startup, any reply still marked that way (the server stopped
mid-reply) becomes interrupted and keeps what it had written so far.
The server writes a heartbeat comment on the SSE stream every 15 seconds regardless of model or tool activity, so a slow tool call or a quiet thinking step never looks like a dead connection. The client treats 45 seconds without any bytes as a dropped connection: it shows "Reconnecting…" and keeps trying for about two minutes. If the run can't be found again, it loads what the server saved. Only if that fails too does it show an error, which still holds the text that had arrived. The server's own reads from a provider are more patient: 10 minutes for the first byte, since a reasoning model can think silently for a long time, then 5 minutes between bytes. If a message somehow ends up with no reply at all (the connection dropped before generation started), it offers "Try again" directly rather than needing an edit.
A reply that stopped early (stopped, interrupted, cut off at the token limit, or failed partway) and got
somewhere offers Continue next to Retry. The continue stream action sends the history with the unfinished
reply at the end, then a note carrying the latest 20,000 characters of its reasoning and asking the model to
carry on without starting over. The new text is added to the same message, and its usage is recorded as a
separate entry.
Each saved reply keeps its tool loop in the messages.trace column: the model's calls, provider blocks such as
thinking signatures, and tool results cut to 20,000 characters each. It is used to build later history and is
never sent to the browser.
Tables are created on startup with repolayer's ensureTable(), and verifyTable() checks them against the
schemas. repolayer doesn't do migrations, so there are two steps of our own. src/server/db/migrate.ts adds
any column a newer schema declares to an existing database, using repolayer's own column DDL. Changes to data
that already exists go in src/server/db/migrations.ts as numbered steps; each runs once, and the version
reached is stored in the settings table.
Server logs have one line per event, with fields such as the run and pane id, so a reply can be followed from start to finish.
Settings → Data exports everything except API keys as a ZIP archive. The rows, tool traces included, are in
switchbox.json, each attachment's bytes are under attachments/<id>, and each saved chat's workspace is under
workspaces/<chat id>/. Files keep their raw bytes instead of becoming base64 text, and already-compressed
formats are stored as they are. Imports take these archives and the JSON files earlier versions exported.
Imported chats, profiles and memories keep their original dates, workspace files already present are left alone,
and archive entries go through the same path checks as the workspace tools. Like MCP servers (which start
switched off) and Run commands' approval policy (which keeps this machine's, never the import's), a chat's
bound folder is never trusted from an import — it's cleared, since it never went through the folder checks
described under Workspaces and could point anywhere on this machine.
Planned work is tracked in ROADMAP.md.