- HTML 58.4%
- Nix 27.4%
- Python 12%
- JavaScript 2.2%
|
All checks were successful
CI / check (push) Successful in 11s
The channels share one lazily-spawned tmux server living in the first ttyd's cgroup; every ttyd ExecStart embeds sessionCommand (hard-coding claude-code / agent store paths that change most deploys), so activation restarted the units mid-session and tore the shared server down. Stop restarting running channels on rebuild, and only signal ttyd (not its cgroup) when one is restarted, so the server + sessions + bg procs survive. |
||
|---|---|---|
| .forgejo | ||
| modules | ||
| web | ||
| .gitignore | ||
| CLAUDE.md | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
Forge
A browser-accessible Claude Code workstation for NixOS.
Forge turns a headless NixOS box into a web app where each of your repos gets its own persistent terminal ("channel") running Claude Code — reachable from any browser, phone included. Close the tab, come back later, the session is still there.
What you get
- One channel per repo — each is a ttyd
web shell exec'ing into a persistent
tmuxsession that runs Claude Code (or any command you choose). Detach/reattach across browser disconnects for free. - A launcher served by nginx on port 80: a sidebar of channels, an Alt+K command palette (switch channels, split panes, open lazygit), a JuiceSSH-style keybar for touch devices (Esc/Tab/arrows/Ctrl-chords), and an upload dropzone (drop/paste a file → it lands on the box and its path is typed into the active session).
- Tuned for the browser terminal: extended keys (Shift+Enter, Ctrl-chords), mouse scroll, and OSC 52 clipboard all work through xterm.js.
- One route to expose: everything is same-origin behind nginx:80, so you put a single reverse-proxy route (+ your auth of choice) in front of it.
Requirements
- NixOS with flakes enabled.
- nixpkgs unstable — the default
sessionCommandusespkgs.claude-code, which lives in unstable. On stable nixpkgs, setsessionCommandto your own binary/wrapper. - A real login user on the host to own the sessions (Forge does not create it).
Usage
Add Forge as a flake input and enable the module on the host:
{
inputs.forge.url = "git+https://git.rdbytes.pt/pedromcaraujo/forge?ref=main";
# (or github:<you>/forge once mirrored)
outputs = { self, nixpkgs, forge, ... }: {
nixosConfigurations.my-workstation = nixpkgs.lib.nixosSystem {
modules = [
forge.nixosModules.forge
({ ... }: {
myServices.forge = {
enable = true;
user = "me"; # existing login user
order = [ "app" "infra" ]; # sidebar order + 1..9 hotkeys
channels = {
app = { cwd = "/home/me/code/app"; };
infra = { cwd = "/home/me/code/infra"; };
};
};
})
];
};
};
}
Then front http://<host>:80 with your reverse proxy + authentication. ttyd,
the upload sidecar, and the palette sidecar all bind loopback only.
Using the launcher
Once it's up, open the launcher in a browser. The active channel fills the screen; everything else is chrome around it.
Switching channels
- Click a channel in the sidebar (desktop) or the pill row (mobile).
- Command palette —
Alt+K(Option+Kon Mac), or tap the Forge brand on a phone. Type to filter,↑/↓to move,Enterto activate,Escto close. The palette also lists actions (see below). - The status dot on each channel shows session state: hollow = no session, dim = a fresh session, glowing green = a live conversation.
Ctrl/Cmd/middle-click a channel opens it in a new browser tab.
Light / dark theme — the ☀️/🌙 button in the sidebar header toggles a
high-contrast light theme (chrome and terminal) tuned for e-ink readers like
a Boox: black-on-white, no cursor blink, no glow/shadows. The choice is
per-device (saved in localStorage), so one browser can run light while another
stays dark. The dark default follows the deploy's theme option.
Palette actions (run against the current channel's tmux session)
- Split a second Claude pane right/below, or a plain shell split.
- Zoom (toggle fullscreen) the active pane.
- Kill the side pane (the lone main pane is protected).
- Open lazygit in a popup.
Sending files — drop a file anywhere on the page, paste an image, or tap the
dropzone to pick one. It uploads to the box under uploadDir/<channel>/ and its
path is typed into the active session, ready for Claude to read.
On a phone / tablet
- A keybar sits at the bottom:
Esc·Tab·Enter·↑ ↓ ← →·Home·End·PgUp·PgDn, plus☰(palette) and⟳(reconnect a stale session). These go straight to the session, so they work even where the soft keyboard's own keys don't. - A compose box lets you type a line into a normal input and send it with one tap — this sidesteps Android IME quirks that duplicate characters when typing straight into the terminal.
- The soft keyboard stays closed when you switch channels. Tap the terminal itself when you actually want to type; use the keybar/compose box otherwise.
⟳(or reselecting the channel) reconnects a session that went stale after the phone slept — the tmux session and its scrollback are still there.
Persistence — every channel runs inside tmux new-session -A, so closing the
tab or losing the connection never kills your work. The next visit re-attaches.
Add Forge to your home screen (it's an installable PWA) for an app-like, full-screen shell.
Options (myServices.forge)
| Option | Default | Description |
|---|---|---|
enable |
false |
Turn Forge on. |
user |
"pma" |
Login user that owns the ttyd/tmux sessions and palette sidecar. |
channels |
{} |
Attrset name -> { cwd; basePath?; }. One channel per entry. |
order |
[] |
Explicit channel order (sidebar, 1..9 hotkeys, default channel). Omitted channels append alphabetically. |
basePort |
7681 |
First loopback port; channel N binds basePort + N. Sidecars take the two ports above the last channel. |
sessionCommand |
${pkgs.claude-code}/bin/claude |
Command run inside each tmux session. |
sessionEnvFiles |
[] |
Files sourced (if readable) before sessionCommand — inject tokens without baking secrets into the module. |
theme |
Tokyo Night | xterm.js colour theme. |
branding |
"Forge" |
Product name in the launcher/PWA. |
uploadDir |
/var/lib/forge-uploads |
Where the upload sidecar writes files. |
maxUploadBytes |
26214400 |
Per-upload cap (also nginx client_max_body_size). |
nginx.default |
true |
Claim the default vhost + bind :80 + open the firewall. Disable to front Forge yourself. |
Injecting secrets
Forge never reads secrets directly. To give sessions API tokens, write them to a
shell file (e.g. via sops-nix) and point sessionEnvFiles at it:
myServices.forge.sessionEnvFiles = [ "/etc/profile.d/forge-tokens.sh" ];
Each channel sources those files before launching, and re-sources them inside the tmux session so freshly-rotated tokens reach already-running sessions.
Layout
modules/forge.nix options + wiring
modules/ttyd.nix one ttyd web shell + tmux session per channel
modules/launcher.nix nginx launcher, /channels.json, upload + palette sidecars
modules/shell.nix system tmux.conf tuned for the browser terminal
web/ launcher page, sidecars (upload.py, tmux_ctl.py), PWA assets
License
MIT.