Forge — a browser-accessible Claude Code workstation (NixOS module)
  • HTML 58.4%
  • Nix 27.4%
  • Python 12%
  • JavaScript 2.2%
Find a file
Pedro Araújo 18d264323e
All checks were successful
CI / check (push) Successful in 11s
ttyd: survive deploys — restartIfChanged=false + KillMode=process
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.
2026-07-21 11:08:40 +01:00
.forgejo ci: add Forgejo Actions workflow (nix flake check + launcher parse) (#9) 2026-07-17 11:47:23 +01:00
modules ttyd: survive deploys — restartIfChanged=false + KillMode=process 2026-07-21 11:08:40 +01:00
web forge: add Ctrl+B as a secondary tmux prefix for macOS (#11) 2026-07-20 09:55:30 +01:00
.gitignore Forge: browser Claude Code workstation as a reusable NixOS flake 2026-07-09 14:51:09 +01:00
CLAUDE.md docs: add CLAUDE.md — track work as Forgejo issues 2026-07-15 09:30:28 +01:00
flake.lock Forge: browser Claude Code workstation as a reusable NixOS flake 2026-07-09 14:51:09 +01:00
flake.nix Forge: browser Claude Code workstation as a reusable NixOS flake 2026-07-09 14:51:09 +01:00
LICENSE Forge: browser Claude Code workstation as a reusable NixOS flake 2026-07-09 14:51:09 +01:00
README.md forge: in-app light / e-ink theme toggle (#8) 2026-07-17 10:56:32 +01:00

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.

channels in a sidebar, a terminal in an iframe

What you get

  • One channel per repo — each is a ttyd web shell exec'ing into a persistent tmux session 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 sessionCommand uses pkgs.claude-code, which lives in unstable. On stable nixpkgs, set sessionCommand to 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 paletteAlt+K (Option+K on Mac), or tap the Forge brand on a phone. Type to filter, / to move, Enter to activate, Esc to 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.