Check in the starting layout and Web view tabs for the Booth console web UI (the base variant's browser terminal) — so opening the booth looks the same for everyone, on any machine.
The Booth console web UI — "Console UI" for short — is the base variant's browser terminal: it splits into up to six panes, each either a plain terminal or a Web view holding its own tabs. .booth/console.json sets the Console UI's starting split layout and pre-opens Web view tabs in specific panes. It only ever supplies a default: the moment you customize a pane yourself (switch it to Web view, open or close a tab, resize the split), that customization is remembered in your browser and wins over the file on every later load — the file only fills in a pane nobody has touched yet in this browser.
{
"layout": "quad",
"panes": {
"1": { "web": true, "tabs": [":3000", "https://example.com"] },
"3": { "web": true, "tabs": ["myserver:9000/dashboard"] }
}
}Back to README
- Overview
- File Format
- Tab Addresses
- Precedence
- When the File Is Wrong
- Layouts
- Keyboard Shortcuts (Tiling)
- Moving and Naming Panes
- Saving Layout Changes Back
- Why a Separate File
- Examples
The Console UI is the base variant's default screen: a browser terminal that splits into up to six panes, each either a terminal or a Web view holding its own tabs. Normally you build that layout by hand each time — pick a split, click the globe on a pane, type an address. .booth/console.json lets a project check in that starting point instead, so a teammate (or you, on a different machine) opens the booth to the same layout every time, without repeating the clicks.
The file is:
- Per-project — lives inside
.booth/, scoped to this project - Committable — meant to be checked into git, unlike
.booth/cache/ - Optional — the console works exactly as before with no file present, or with any pane the file doesn't mention
{
"layout": "<layout-name>",
"panes": {
"<pane-number>": {
"name": "<label>",
"web": true,
"tabs": ["<address>", "..."],
"force": true
}
}
}layout— one of the nine layout names, four of which have a toolbar button:single,hsplit,left-main,right-main,vsplit,top-main,bottom-main,quad,grid6— or a tiling layout such ash(1,v(2,3)). Anything else is ignored.panes— an object keyed by pane number as a string ("1"through"6"). A pane not listed here stays a terminal.name— the label in the pane's header in place ofSession N: plain text, up to 32 characters, spaces collapsed. A pane can have a name and nothing else —"3": { "name": "server" }is still a terminal. The tmux session behind it stayss3; see Moving and Naming Panes.web— must betruefor the pane to open in Web view at all. A pane listed with"web": false(or without"tabs") is left as a terminal, same as not listing it.tabs— an array of addresses, one per tab to open in that pane, in order. The last one ends up active. An empty or missing list leaves the pane as a terminal even with"web": true.force—truemakes this pane's entry win over whatever this browser already saved for it (see Forcing a Pane). Only meaningful alongside"web": true;"web": falsealready forces on its own.
The file is validated as JSON when the booth starts (start-ttyd-split, via jq); a syntax error is logged to the container's startup output and the console just starts with no preset at all rather than failing to load. Anything else it gets wrong falls back without breaking the console — see When the File Is Wrong.
Each string in tabs is parsed exactly the way typing it into a pane's own address bar would be — the same parser, so anything that works there works here:
| You write | Opens |
|---|---|
:3000 or :3000/path |
This booth's port 3000, proxied through /proxy/3000/... |
booth:3000 |
Same, alternate spelling |
myserver:9000 |
https://myserver:9000/ directly, unproxied — myserver isn't booth/localhost/127.0.0.1 |
https://example.com |
That URL directly, unproxied |
localhost:3000 |
http://localhost:3000/ on the machine running the browser, not this booth |
A bare domain with no scheme (example.com) is assumed to be https; anything that isn't a URL or host shape at all falls back to a Google search, exactly as typing it into the address bar would.
A malformed individual address (an unsupported scheme, a reserved port) is skipped, with a warning in the browser console, rather than blocking the rest of the file's tabs from opening. A bare number in tabs is taken as a port (3000 is :3000).
/proxy/<port>/ rewrites the obvious absolute links in what it serves (href="/…", src="/…", fetch("/…")), but an app's JavaScript can still ask for /v1/… or /@vite/client on the booth's own root. The booth sends such a request to an app port when it can tell which one:
- The Referer — a request made by a
/proxy/<port>/page goes to<port>. - The
cb_proxy_portcookie — otherwise, the port of the last/proxy/<port>/page opened in this browser, in a plain tab or a globe pane. This covers a module imported by another module that was itself served from the root (Vite's dev server), whose Referer no longer carries the prefix.
So a Vite dev server or the Appwrite console works at /proxy/<port>/ in either place. Two such apps open at once in the same browser can still collide on step 2: the one opened last wins, and reloading the other makes it win again.
On each load, in order:
- The URL hash (
#mode=quad) — if you followed a link that names a layout. - What you last set in this browser (
localStorage) — a pane you've ever opened in Web view, even once, keeps whatever it was left as, forever, regardless of what the file says. .booth/console.json— applies only to a layout nobody has picked yet, or a pane with no tabs ever recorded in this browser. A pane'snameworks the same way: it shows until you rename the pane in this browser.- The built-in default — a single terminal pane.
This means editing the file after the fact doesn't retroactively change anyone's already-customized console — it only changes what a fresh browser (or a fresh localStorage) sees — unless the pane is forced.
Two per-pane settings skip step 2 and make the file win on every load, not just the first:
"web": false— the pane is always a terminal. Any Web view this browser saved for it is cleared."web": truewith"force": true— the pane always opens exactly the file'stabs. Whatever tabs this browser saved for it are cleared first, so a changed tab list in the file actually takes effect.
Use it when the pane's contents are part of the project's setup rather than a starting suggestion — for example examples/workspaces/kind-example, which pins its Markdown viewer and the cluster's service tabs to pane 1 and keeps panes 2 and 3 as terminals:
{
"layout": "left-main",
"panes": {
"1": { "web": true, "tabs": ["booth:8765", "booth:30080", "booth:30081"], "force": true },
"2": { "web": false },
"3": { "web": false }
}
}A forced pane's name, when the file gives one, wins the same way: a rename lasts until the next load.
The trade-off: a forced pane never remembers your own changes across reloads. The layout itself (step 2 for layout) is not affected by force.
The console always loads, whatever console.json holds. Each thing it cannot use is dropped with a warning in the browser's developer console (.booth/console.json: …), and the rest still applies:
| In the file | What happens |
|---|---|
| Not valid JSON, or not an object | Ignored; the console starts as if there were no file |
"layout" with stray spaces or capitals (" Quad ") |
Read as quad |
"layout" that is not a layout (a typo, a pane above 6) |
The smallest layout that shows every pane given tabs — pane 5 has tabs, so grid6, shown as Other — or single |
No "layout", but tabs in pane 2 or above |
The same: those panes are shown, not left hidden |
A pane key that is not "1" … "6", or a pane that is not an object |
That pane is ignored |
"web" that is not true / false |
The pane stays a terminal |
"name" that is not text, is empty, or is over 32 characters |
The pane is shown as Session N; the rest of its entry still applies |
| A tab that is not a string or a port number | That tab is skipped; the pane's other tabs open |
A tab with </script> or <!-- in it |
Read like any other text; it cannot end the page's own scripts |
A file with any of these problems is never saved over, even with console-spec set: what it got wrong is still yours to fix, and a save would replace it with only the part that was understood. Your changes stay in the browser until you fix the file and restart the booth.
The toolbar has four buttons: one terminal, two columns, two rows, and the 2×2 grid. Every other arrangement is a keystroke away — see the shortcuts — and the five names without a button still work anywhere a layout name goes (#mode=, console.json, a saved spec): they open exactly as they always did, and show as Other.
| Name | Button | Panes | Looks like |
|---|---|---|---|
single |
✓ | 1 | One terminal |
hsplit |
✓ | 2 | Two columns |
vsplit |
✓ | 2 | Two rows |
quad |
✓ | 4 | Two columns of two: 1 and 2 on top, 3 and 4 below |
left-main |
3 | Session 1 on the left, 2 and 3 stacked on the right | |
right-main |
3 | Session 1 on the right, 2 and 3 stacked on the left | |
top-main |
3 | Session 1 along the top, 2 and 3 side by side below | |
bottom-main |
3 | Session 1 along the bottom, 2 and 3 side by side above | |
grid6 |
6 | Three columns of two: 1, 2, 3 on top, 4, 5, 6 below |
Columns come first: the dividers running the full height split the console into columns, and each column has its own top/bottom divider, so in quad or grid6 dragging one column's divider leaves the others where they are. (top-main and bottom-main are the exception by nature: their wide pane spans every column.) Each preset remembers its divider positions in the browser, so going back to it shows it the way you left it.
Any arrangement without a button of its own — the five names above, five panes, three columns, a tiling layout built with the shortcuts — shows an Other button, lit and drawn in the layout's shape, for as long as it shows. The layout itself is remembered like any other, in the URL, the browser and console.json.
The + and − buttons beside Reload open a pane next to the focused one and close the focused one, the same as Ctrl+Alt+Enter and Ctrl+Alt+Shift+q; they turn grey at six panes and at one. Going from quad to six panes one at a time passes through five; both show as Other. Closing a pane only hides it: its session keeps running, and opening it again shows it as it was. To really end one, reset it — the ⏻ in a terminal pane's header, or Ctrl+Alt+Shift+x on the focused pane. It asks first, then ends that pane's tmux session (s1 … s6), stopping whatever runs there, and the pane reconnects to a fresh shell.
A reset ends the session the way tmux does, with a hangup, and a program that ignores it — or one that left the session on its own (setsid, nohup, a daemon it started) — keeps running. For a pane that is still stuck, Shift+click the ⏻ to force-reset it: after asking, it kills everything started from that session (each pane's process tree, plus any process still carrying its TMUX_PANE) with SIGTERM, then SIGKILL a second later for what is left, with no chance to clean up, and then resets it. A process run with sudo is killed too. It is deliberately mouse-only, with no shortcut. The same command works outside the page, for when the browser tab itself is frozen: booth--console-reset 3 --force from another terminal in the booth, or booth exec -- booth--console-reset 3 --force on the host (without --force it is a plain reset). It only ever reaches the six pane sessions, never the tmux server or the console itself.
Reload reloads what every visible pane shows. A terminal reattaches to its tmux session, so the shell and anything running in it carry on; a web tab reloads its page — for an outside site, the page the tab opened on, since where it has navigated since cannot be read.
The Console UI tiles its six sessions the way the i3 window manager tiles windows, with Ctrl+Alt as the modifier — Control+Option on a Mac, where Option is the Alt key — the same keys as the i3 template's Ctrl+Alt bindings, so one set of habits works on both. The toolbar layouts still work as before; the shortcuts reach every other arrangement of up to six panes as well, one pane at a time.
| Keys | Action |
|---|---|
Ctrl+Alt+h / j / k / l (or arrows) |
Focus the pane left / down / up / right |
Ctrl+Alt+Enter (or +) |
Open a pane next to this one (up to six) |
Ctrl+Alt+b / Ctrl+Alt+v |
The next pane opens beside / below this one |
Ctrl+Alt+1 … 6 |
Go to session 1 … 6, opening it again if it was closed |
Ctrl+Alt+f |
The pane fills the console; again to restore |
Ctrl+Alt+w |
The pane's Web view; again for its terminal |
Ctrl+Alt+m |
The pane's Markdown viewer — starts viewmd if needed; again for its terminal |
Ctrl+Alt+n |
Rename the pane |
Ctrl+Alt+e |
Turn the pane's split the other way (side by side ↔ stacked) |
Ctrl+Alt+= |
Equalize: every pane in the layout the same size, same layout |
Ctrl+Alt+r |
Resize mode: h j k l or arrows resize the focused pane, Enter, Esc or Ctrl+Alt+r leaves |
Ctrl+Alt+Shift+h / j / k / l (or arrows) |
Move the pane left / down / up / right |
Ctrl+Alt+Shift+q (or −) |
Close the pane — it is only hidden; its session keeps running |
Ctrl+Alt+Shift+x (or the pane's ⏻) |
Reset the session: after asking, end its shell and anything running in it, and start a fresh one |
| Shift+click the pane's ⏻ | Force-reset a stuck session: after asking, kill everything started from it, then start a fresh one |
| Double-click the pane's title | Rename the pane |
| Drag the pane's header | Move it: onto the middle of another pane to swap, near an edge to dock |
The focused pane has a highlighted border once there is more than one. The shortcuts work from inside a terminal or a booth web tab (:3000 and the like). A web tab showing an outside site keeps its keys to itself, so click into a terminal pane first. Ctrl+Alt+arrows may be taken by your own desktop; h/j/k/l always work. On a Mac, VoiceOver claims Control+Option as its own modifier and takes these keys first while it is on.
The Keyboard Shortcut Hint in the toolbar always shows the main keys, the Ctrl+Alt ones and then the Ctrl+Alt+Shift ones, each modifier drawn as a key cap. On a Mac the caps are written short, ⌃⌥ and ⌃⌥⇧, in the font the console serves, so the glyphs do not depend on what you have installed; hovering the hint spells them out there, and CodingBooth Help gives both forms. While a mode is on it shows what the keys do there instead — a pane filling the console, or where the next pane will open after Ctrl+Alt+b / v — and a click leaves the mode. To resize a pane, drag a divider or use Ctrl+Alt+r. Otherwise a click opens the full table, which is also in the console itself: open the floating Booth panel and pick CodingBooth Help, which opens on its Console Shortcuts tab (the desktop variants' i3 template adds an i3 Shortcuts tab to the same dialog).
A layout the toolbar has no button for is written as a tree: h(…) puts its parts side by side, v(…) stacks them, and the numbers are sessions. @ gives a part's share in percent when the parts are not equal:
| Layout | Looks like |
|---|---|
h(1,2,3) |
Three columns |
h(v(2,3),1) |
Session 1 on the right, 2 and 3 stacked on the left |
v(h(2,3),1) |
Session 1 along the bottom, 2 and 3 above it |
h(1@60,v(2,3,4)@40) |
Session 1 on 60% of the width, three stacked on the right |
It appears in the URL (#mode=h(1,2,3)), is remembered in the browser like any layout, and works as "layout" in console.json — console-spec saves it there the same way it saves a preset name, sizes included ("layout": "h(2@55,1@45)"). Filling the console with Ctrl+Alt+f is temporary and never saved. When a shortcut produces the shape of one of the four layouts with a button, wherever its dividers sit, the console shows it as that layout and lights its button; every other layout — a buttonless preset's shape included — shows the Other button. quad is columns first, h(v(1,3),v(2,4)), so the rows-first v(h(1,2),h(3,4)) is Other.
A booth image older than this feature does not know tree layouts: it ignores one in console.json and starts with a single pane, as it would for any unknown name. The same goes for right-main, bottom-main and grid6, and for panes 5 and 6, on an image from before six panes.
Drag a pane by its header to move it with the mouse — anywhere on the header except its buttons and address bar. While you drag, a blue outline shows where it will land:
- The middle of another pane — the two trade places, each taking the other's spot and size.
- Near an edge of another pane (its outer quarter) — the pane takes that side of it, the two sharing what was its space. Dropping pane 2 at the bottom of pane 1 in two columns gives two rows.
Esc cancels the drag, and a click that barely moves is just a click. Terminals never reload when a pane moves, as with the keyboard, and a drop that lands on the shape of a toolbar layout lights its button. There is nothing to drag with one pane showing, while one fills the console (Ctrl+Alt+f), or on a narrow screen, where the panes are stacked.
Double-click a pane's title, or press Ctrl+Alt+n, to name it. Type the name and press Enter (or click away); Esc keeps the old one. The name replaces Session N in the header, stays beside the address bar in Web view, and shows in the console's hints; hovering it still says which session it is. Clearing the name goes back to the default — the pane's name in console.json, or Session N. A name is only a label: the tmux session behind the pane is still s1 … s6.
Your names are remembered in the browser, like the layout. With console-spec set, they are saved to console.json too, so a fresh browser opens with them.
By default, console.json only ever flows one way: the container reads it at boot, but your later customizing of the Console UI (switching a pane to Web view, opening tabs, resizing) is remembered in localStorage only — the file on disk never changes. --console-spec <mode> (or console-spec = "..." in .booth/config.toml) turns on saving the current layout and tabs back to the file itself, so the next fresh browser — a teammate, or you on another machine — starts from what you last had, without anyone editing JSON by hand.
Two modes:
| Mode | Saves to | Requires | Git |
|---|---|---|---|
shared |
.booth/console.json |
--writable-booth |
Committable — this is the same file described above |
cache |
.booth/.tmp/console.json |
Nothing extra — .tmp/ is always writable |
Never committed (already in .booth/.gitignore) |
Leaving console-spec unset keeps the read-only behavior documented above: an existing .booth/console.json is still honored as a starting layout, but nothing is ever written back.
shared needs --writable-booth because .booth/ is otherwise bind-mounted read-only; without it, a shared save fails cleanly (logged, not fatal) and the change still lives on in localStorage for that browser. cache never needs --writable-booth — .booth/.tmp/ is always mounted read-write, the same mount used for session/idle/lifecycle state — but because it isn't git-tracked, it only helps your next session, not a teammate's.
Saving is automatic and debounced: there's no explicit "save" button, it just happens shortly after you change layout, tabs or a pane's name. A named terminal pane is saved as its name alone ("3": { "name": "server" }). A preset is saved by name while its dividers are where the preset puts them, and as its whole tree once you have dragged them — "layout": "h(v(1@30,3),v(2,4))", which reads back as quad with those positions — so the file reproduces the screen exactly.
Caveat: takes effect on next restart, not live. index.html is rendered once, from console.json's contents at that moment, when the container boots — the same static page is then served for the container's entire lifetime. A save updates the file on disk right away, but the currently running container keeps serving the page it already rendered, so even a brand-new browser tab against that same running container won't see the change until the container restarts. This mirrors the read-only file's own load-once behavior — it isn't unique to saving.
This setting is only meaningful for the Console UI — other variants ignore it, so it's harmless to leave set in a shared config.toml that's used across variants.
Most per-project settings — idle timeout, run-time display, and the like — live as scalar keys in .booth/config.toml and get forwarded into the container as a BOOTH_* environment variable. The layout/tabs data itself follows a different path: a layout name plus an arbitrary, per-pane list of tab addresses is genuinely structured data, not a string, bool, or flat list, and config.toml's schema doesn't have a good way to express "an array of arrays" without real changes to the CLI itself.
Instead, console.json rides the same mechanism .booth/cache/ and .booth/shared/ already use: .booth/ is bind-mounted into every booth at /home/coder/code/.booth (read-only by default), so the container's own startup script can just read the file directly — no docker -e, no environment variable needed for the read path. console-spec (above) is the one small piece of this feature that is a scalar BOOTH_* setting, since "which mode, if any" is exactly the kind of flat value config.toml already handles well.
A dev server pre-opened next to the terminal:
{
"layout": "hsplit",
"panes": {
"2": { "web": true, "tabs": [":3000"] }
}
}Two tabs in one pane, in a four-way split:
{
"layout": "quad",
"panes": {
"1": { "web": true, "tabs": [":3000", ":3000/admin"] }
}
}