Rails: lets you answer Claude's questions from Rails' chat as well as here

A self-hosted, multi-device agent multiplexer. Attach a project folder, see every Claude Code, Codex and pi session that already exists for it, and run the agents' own terminal UIs side by side in panels you can split, resize and rearrange, from your laptop or phone.
Projects are added with your operating system's own folder picker (Finder on macOS, zenity/kdialog on Linux), which opens on the machine running Rails.
Requires Node 24+, tmux 3.2+ (every panel runs in it) and the agents' CLIs on the server's PATH. make dev, make start and make install check for tmux and install it with Homebrew, apt or dnf when it's missing (make system-deps does only that step). On Linux, node-pty compiles during npm install, so you also need python3, make and a C++ compiler.
With Nix you can skip all of that: nix develop gives a shell with the pinned Node, tmux and (on Linux) build tools from flake.lock; then use make as usual. The agent CLIs still come from your own PATH. If flakes aren't enabled in your Nix config, run nix --extra-experimental-features 'nix-command flakes' develop.
make dev # installs deps if needed; server on :4317, UI on http://localhost:5173
# open the "open: …?token=…" URL printed in the terminal
make # list all targets
Production: make start, then open http://<host>:4317/?token=… (make token prints the token). Use make dev-lan, or set RAILS_HOST=0.0.0.0, to reach it from other devices (for example over Tailscale). The token is stored in ~/.rails/config.json.
Status:
claude, pi, codex or a shell, backed by tmux so they survive server restarts and are shared across devicesDocs: architecture · glossary · decisions · provider formats · roadmap
Working on Rails with a coding agent? Start with AGENTS.md.
hooks/register.js 55 lines1// Rails' mod for Claude Code (ADR 0028). Rails loads it with `--plugin-dir` in the panels it starts (a copy in
2// RAILS_HOME, since Claude Code writes type files into a mod's folder). While Claude's question dialog
3// (AskUserQuestion) is open, it waits for an answer from Rails too: the user's own answer in the panel and one sent
4// from a Sprocket's chat race, and the first wins. An answer from Rails closes the dialog and reaches Claude as the
5// user's answer. Nothing else changes: without Rails (no RAILS_BRIDGE) the mod does nothing.
6
7export function register(on) {
8 on("tool.call", { tool: "AskUserQuestion" }, async ($, e, next) => {
9 const base = await $.env.get("RAILS_BRIDGE");
10 const token = await $.env.get("RAILS_PANEL_TOKEN");
11 const questions = Array.isArray(e.questions) ? e.questions : [];
12 // Only a single question with a known id: several are answered in the panel, as before.
13 const id = e.tool_use_id;
14 if (!base || !token || questions.length !== 1 || typeof id !== "string" || !id) return next(e);
15 const headers = { authorization: `Bearer ${token}`, "content-type": "application/json" };
16 const post = (path, body) => $.http.fetch(`${base}/claude/${path}`, { method: "POST", headers, body: JSON.stringify(body) }).catch(() => {});
17
18 const q = questions[0];
19 const options = (Array.isArray(q.options) ? q.options : []).map((o) => (typeof o?.label === "string" ? o.label : "")).filter(Boolean);
20 let settled = false;
21 // Tell Rails the dialog can be answered from it, by this tool call's id.
22 await post("dialog", { id, question: q.question, options });
23
24 const panel = next(e).then(
25 (result) => ({ from: "panel", result }),
26 (error) => ({ from: "error", error }),
27 );
28 const rails = (async () => {
29 // Rails holds each poll up to 25 s (a fetch may take 30), so this asks again until something settles.
30 while (!settled) {
31 let messages = [];
32 try {
33 const r = await $.http.fetch(`${base}/claude/inbox`, { headers });
34 if (r.ok) messages = JSON.parse(r.text || "{}").messages ?? [];
35 else await $.clock.sleep(2000);
36 } catch {
37 await $.clock.sleep(2000); // Rails is down or restarting: the panel can still answer
38 }
39 for (const m of messages) {
40 const mine = !settled && m?.answer?.dialog === id && typeof m.answer.text === "string" && m.answer.text.trim() !== "";
41 void post("delivered", { id: m?.id, ok: mine, ...(mine ? {} : { error: "The dialog has closed." }) });
42 if (mine) return { from: "rails", text: m.answer.text.trim() };
43 }
44 }
45 return { from: "none" };
46 })();
47
48 const won = await Promise.race([panel, rails]);
49 settled = true;
50 if (won.from === "rails") return { result: { questions, answers: { [q.question]: won.text } } };
51 if (won.from === "error") throw won.error;
52 return won.result;
53 });
54}
55