@forked-online/pod-runner
v0.9.0
Published
Self-hosted runner for forked.online pods — registers with the platform, clones a workspace's repos onto a persistent volume, injects env vars, keeps a tmux session, and heartbeats. Honors the platform's manual on/off.
Maintainers
Readme
@forked-online/pod-runner
Self-hosted runner for a forked.online Pod (Pods phase 2, WI-0066).
A Pod is a persistent, per-workspace controller environment for your agents (Hermes / Claude Code / OpenCode). You run this daemon on your own box; it:
- Registers with the platform using the pod's
runner_token. - Pulls the environment manifest (
GET /api/v1/pod/runner/manifest). - Clones/updates every workspace repo onto a persistent volume at the
manifest's on-disk paths (idempotent: clone when absent,
pull --ff-onlywhen present). Uses a brokered GitHub token when a GitHub App is installed. - Writes the env-var store to
<working_root>/.pod.env. - Keeps a tmux session (
pod) alive so agent shells persist across runs. - Heartbeats and honors the operator's manual on/off (
desired_state).
The platform never exposes env values or the brokered token to agents — only
the runner (authenticated by runner_token) receives them.
A freedom pod
The pod is a full, root Debian sandbox — not a scoped Node box. A curated baseline is baked into the image (git, tmux, build tools, mise, the agent CLIs, Hermes); beyond that you install whatever you want, however you want:
- Languages via
mise(Ruby, Node, Rust, Python, Go, Java, …) — installed on demand from a repo's.tool-versions, and they persist on the volume. - Tools via
cargo/go install/npm -g/pip --user— these are relocated onto/workspacetoo, so they survive restarts and rebuilds. - System packages via
apt install— you're root, so it just works in the session. (Those land in the image's root FS, so they reset on an image rebuild; prefer the user-space installers above for anything you want permanent.)
The rule of thumb: the /workspace volume is your machine. HOME, your
dotfiles, shell history, SSH keys, toolchains, and per-tool installs all live
there and persist; the image just provides the warm baseline.
Production quickstart (forked.online)
- Deploy the app and run
bin/rails db:migrate. - In the app: sidebar → Pod → Create pod → bind your GitHub provider →
add env vars (e.g.
GITHUB_TOKENif you're not on a GitHub App) → Save → Turn on → copy the Runner token. - Make sure the workspace's projects have repos linked (the manifest clones
them). For in-pod agent runs, install the agent CLI (
claude/opencode/hermes) on this host. - On the runner host (with a persistent volume mounted at the working root):
POD_RUNNER_TOKEN=pod_xxx FORKED_BASE_URL=https://forked.online npx @forked-online/pod-runnerIt registers → clones repos → writes .pod.env → starts a pod tmux session →
heartbeats. The /pod page shows the heartbeat + recent in-pod runs. Then on any
work item, hit ▶ Run in pod to launch the chosen agent in its own worktree.
Deploy on Dokploy (or any host, anywhere)
The runner only ever makes outbound connections to the platform — heartbeat,
manifest, dispatches, and (since WI-0279) the browser terminal, which tunnels out
over the same channel. So it runs on any box, anywhere — a separate VPS behind
NAT/Docker is fine, with no inbound ports and no POD_RUNNER_HOST on the
app side.
- New Dokploy Application → build from this repo, build context
pod-runner/(uses the includedDockerfile: Debian + git + tmux + node-pty + mise + the agent CLIs). - Env on the runner service — just two:
POD_RUNNER_TOKEN=pod_…(Settings → Pod → Runner token)FORKED_BASE_URL=https://forked.online
- Mount a persistent volume at
/workspace(the image's defaultPOD_WORK_DIR). Repos, tmux, yourHOME, and everything you install live there.
Then: pod shows a heartbeat → sidebar → Terminal attaches.
Self-hosting Hermes inside the pod (WI-0100)
Enable Pod → Hermes → Host Hermes and the runner will, on each reconcile:
- Install the Nous Hermes agent — baked into the Docker image, so a rebuild already has the binary; the runtime only (re)installs it if it's missing (e.g. running from bare source).
- Point
HERMES_HOMEat<working_root>/.hermeson the persistent volume, so Hermes' memory, skills, sessions, and config survive runner restarts. - Render its config —
model+agent.reasoning_effortviahermes config set, and intoHERMES_HOME/.env: the LLM provider keys plus the API-server platform (API_SERVER_ENABLED=true,API_SERVER_HOST,API_SERVER_PORT,API_SERVER_KEY). Only keys the runner manages are touched; yourSOUL.md/skills are left alone. - Supervise the gateway (
hermes gateway run), which serves the agent HTTP API (/v1/runs,/v1/runs/:id/events,/api/sessions,/v1/models) only becauseAPI_SERVER_ENABLED=true. Restarts it if it exits; reports status via heartbeat.
The API server binds 127.0.0.1 by default on port 8642. Exposing it publicly —
and the TLS for that URL — is your responsibility; set POD_HERMES_BIND=0.0.0.0 and
put it behind your reverse proxy (target port 8642). Then copy the URL + token
from the Hermes tab into a new agent Collaborator (Collaborators → Add → Hermes
agent) — from there it's a normal remote Hermes: dispatch, stream, and steer all work
unchanged. The token travels in the clear over plain HTTP, so use HTTPS.
Provider keys (e.g. ANTHROPIC_API_KEY) come from the Pod → Environment editor — the
runner copies the allow-listed subset into Hermes' .env. Nothing Hermes-related (token
or keys) is ever exposed to agents via the manifest.
Auto-wiring the forked.online MCP into Hermes: add HERMES_FORKED_API_KEY=<the agent
collaborator's fkd_… key> in Pod → Environment. The runner writes the forked-online
MCP server into Hermes' config.yaml (merging, so the rest is preserved) so the agent can
read/write work items with zero setup. Use the collaborator's own key (from
Collaborators → your agent → Agent), not the workspace key — its actions are then attributed
to that agent and scoped to its projects. (API_SERVER_KEY = inbound auth; this fkd_…
key = Hermes → forked MCP.)
Setting Hermes up by hand? If you configure Hermes yourself in a pod terminal instead
of the UI, enable the API-server platform with the token from the Hermes tab as
API_SERVER_KEY:
cat >> "$HERMES_HOME/.env" <<EOF
API_SERVER_ENABLED=true
API_SERVER_HOST=0.0.0.0
API_SERVER_PORT=8642
API_SERVER_KEY=<token from the Pod → Hermes tab>
EOF
hermes gateway restartUse the token shown on the Hermes tab — that's the one to paste into the Collaborator. (The runner applies these same settings automatically when you use the UI.)
Serve command / port are configurable from the Hermes tab; defaults are
hermes gateway runon port8642with the API-server platform enabled (API_SERVER_ENABLED/API_SERVER_KEY). A Python + Node agent runs alongside the rest of the pod — size the host accordingly.
Working in the pod (polyglot dev + Rails TDD)
The image ships mise (on-demand toolchains), the native build libs, psql, and the
Claude Code / OpenCode CLIs. Per-project toolchains are installed on demand from the repo's
.ruby-version / .tool-versions.
Put your dev/test creds in the Pod → Environment variables editor (they're injected into
every terminal shell + agent): e.g. DB_HOST / DB_USER / DB_PASSWORD (or DATABASE_URL)
pointing at Supabase, plus ANTHROPIC_API_KEY for Claude, etc. The brokered GitHub token is
exported as GITHUB_TOKEN/GH_TOKEN automatically.
In a pod terminal:
cd forked-online
mise install # installs Ruby 3.4.5 / Node / … from the repo's version files
bundle install
RAILS_ENV=test bin/rails db:test:prepare && bin/rails testDB caveat: tests run against the Supabase connection you supply, and
db:test:preparedrops/recreates the test database — use a separate Supabase project/database for tests, not the one holding production data. The no-SUPERUSER convention (create test data directly, no fixtures) still applies on Supabase.
Run from source (any host)
npm install && npm run build
export FORKED_BASE_URL=https://forked.online # optional
export POD_RUNNER_TOKEN=pod_xxx # Settings → Pod → Runner token
export POD_WORK_DIR=/workspace # optional; else the manifest's
node dist/index.jsOr with Docker (mount a volume at the working root so repos + tmux persist):
docker run -d --name forked-pod \
-e POD_RUNNER_TOKEN=pod_xxx \
-v forked-pod-data:/workspace \
node:20 sh -c "npx @forked-online/pod-runner"Env
| var | default | meaning |
|-----|---------|---------|
| FORKED_BASE_URL | https://forked.online | platform base url |
| POD_RUNNER_TOKEN | — (required) | the pod's runner token |
| POD_WORK_DIR | manifest working_root | where repos are cloned |
| POLL_INTERVAL_MS | 30000 | reconcile + heartbeat interval |
| POD_HERMES_BIND | 127.0.0.1 | bind addr for the self-hosted Hermes server |
Status
Phase 2 ships the daemon + the platform's runner-auth endpoints (register /
manifest / heartbeat, authenticated by the pod runner_token, with secrets and
a brokered GitHub token served only to the runner). Phase 3 (WI-0067) adds
launching the chosen agent inside the pod against an on-disk repo; phase 4
(WI-0068) adds per-WI git worktrees + short-lived token hardening.
The platform side is covered by
test/controllers/api/v1/pod_runner_controller_test.rbandtest/models/pod_test.rb. The daemon's clone/tmux behavior must be validated on a real host (git + tmux + a persistent volume).
