SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Self-host agent-console for coding agents

Run the agent-console hub on a VPS so a laptop and every agent box report Claude Code and Codex token use to one place, reached over an SSH tunnel.

What you are building

Self-host agent-console on a VPS and every machine you run coding agents on reports its token use into one box you own. One machine runs the hub. Every other machine runs a small reporter that reads its own Claude Code and Codex transcripts and pushes metadata to the hub, by default every ten seconds. A laptop and three agent VPSes then appear as one view of tokens, cache reads, model ids and estimated cost.

agent-console is MIT licensed, written in JavaScript, and needs Node.js 22 or newer with no build step. It is also very new. The first release, v0.1.0, is dated 2026-09-20, and v0.4.1 landed on 2026-09-27. Pin a release tag. Tracking the default branch on a project moving at that speed means the thing watching your spend changes under you without warning.

The topology is the interesting part, so most of this guide is about where each port listens and who is allowed to reach it.

Why self-host agent-console on a VPS

The failure this answers is an agent that runs unattended and spends far more in one night than you planned for the month. That spend is only visible afterwards if something was recording while it happened. A dashboard you start by hand in a terminal is not recording at 3am.

A VPS hub is awake when your laptop is asleep, which is exactly when an agent VPS is still working. It also collects machines that never see each other: a work desktop, a home laptop, and the disposable boxes described in running coding agents in throwaway VMs all report to the same place. Reading where the tokens in a single Claude Code session actually go tells you how to interpret the numbers once you have them. This guide is about keeping them.

Which port is public, and which one never is

agent-console opens two ports, and the naming is not what you would guess. Get this straight before you install anything, because every access decision later follows from it.

The console port, 6787, is the dashboard. It always listens on 127.0.0.1, and no flag moves it. Requests must arrive on a loopback connection carrying a loopback Host header, which is what stops a page in your browser from reaching it through DNS rebinding.

The reporting port, 6788, is the one other machines connect to. The --listen flag applies to this port only. It defaults to 127.0.0.1 as well, so a hub started with no flags accepts nothing from the network.

So the data arrives over 6788 from your machines, and you read it on 6787 from loopback. The hub VPS exposes one port, and it is not the dashboard.

Install Node.js 22 and a pinned release

Ubuntu 24.04 ships Node.js 18 in apt, which is too old to run this. Add the NodeSource repository for the 22 line instead.

curl -fsSL https://deb.nodesource.com/setup_22.x -o nodesource_setup.sh
sudo -E bash nodesource_setup.sh
sudo apt install -y nodejs
node -v

node -v must print v22 or higher. A lower version is the first thing to check when the console refuses to start.

Now download the pinned release and check it before you install it. The release carries a SHA256SUMS file next to the package.

cd /tmp
curl -fsSLO https://github.com/LockedinLabs-AI/agent-console/releases/download/v0.4.1/lockedinlabs-agent-console-0.4.1.tgz
curl -fsSLO https://github.com/LockedinLabs-AI/agent-console/releases/download/v0.4.1/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing
sudo npm install --global --ignore-scripts ./lockedinlabs-agent-console-0.4.1.tgz
command -v agent-console

sha256sum -c should print lockedinlabs-agent-console-0.4.1.tgz: OK. Anything else means a truncated download, so delete the file and fetch it again. --ignore-scripts is the project's own instruction: it stops npm from running lifecycle scripts out of the package at install time. Write down the path command -v agent-console prints, because the service file needs the absolute path.

Cloning the repository and running node bin/agent-console.mjs works too. If you do that, check out the tag rather than staying on main, for the same reason you pinned the tarball.

Both of these need outbound internet from the VPS to GitHub and to the Node.js repository. Run them yourself on a box that has it.

Run the hub under systemd

A hub in a terminal dies with the terminal. Give it a system user and its own state directory, then let systemd own it.

sudo useradd --system --create-home --home-dir /var/lib/agent-console agentconsole

Write /etc/systemd/system/agent-console-hub.service:

[Unit]
Description=Agent Console hub
Wants=network-online.target
After=network-online.target

[Service]
User=agentconsole
Environment=AGENT_CONSOLE_LISTEN=0.0.0.0
Environment=AGENT_CONSOLE_NO_LOCAL=yes
Environment=AGENT_CONSOLE_STATE_DIR=/var/lib/agent-console/hub
Environment=AGENT_CONSOLE_RETENTION_DAYS=30
ExecStart=/usr/bin/agent-console
Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

The project documents a systemd user unit for the reporter and ships nothing for the hub, so this unit is written for this guide. Change ExecStart to whatever command -v agent-console printed. Every Environment= line here has a flag equivalent, so AGENT_CONSOLE_LISTEN is --listen and AGENT_CONSOLE_RETENTION_DAYS is --retention-days, which accepts 1 to 90 and defaults to 8.

AGENT_CONSOLE_NO_LOCAL=yes matters on a hub VPS. That box runs no coding agents, so it has no transcripts of its own, and without this the hub keeps looking for a ~/.claude/projects directory that will never exist.

sudo systemctl daemon-reload
sudo systemctl enable --now agent-console-hub
sudo systemctl status agent-console-hub
sudo journalctl -u agent-console-hub -n 40

status should read active (running). The journal holds the line you actually need: on first start the console makes its own TLS certificate (transport layer security) and prints a single-use sign-in link. Copy that link. You cannot use it yet, because it points at loopback on the VPS.

How do you open the console on a headless VPS?

You make your browser's connection a loopback connection. An SSH local forward does that, because the browser really does connect to 127.0.0.1 on your own machine.

ssh -N -L 6787:127.0.0.1:6787 you@hub.example.com

Leave that running and paste the sign-in link from the journal into your browser. It points at http://127.0.0.1:6787, which is now the tunnel. The link works once. After it, your session rides a random id in an HttpOnly, SameSite=Strict cookie that lasts 30 days, so you do this once per browser and then just open http://127.0.0.1:6787 while the tunnel is up.

Why the console refuses to sit behind nginx

This is the first thing most people try, so it is worth stating plainly. Putting nginx in front of 127.0.0.1:6787 with basic authentication does not work, and no amount of configuration fixes it.

The console refuses requests that carry Forwarded, X-Forwarded-For or Via headers, and it requires a loopback Host header. A proxied request fails both tests, so it is rejected before your password prompt ever matters. The project's own interop documentation says the same thing about its Prometheus /metrics path: everything binds to the 127.0.0.1 listener, and remote access needs its own secure tunnel to that local port.

That leaves the tunnel. An SSH local forward costs nothing and reuses keys you already have. A tailnet is the better answer if you would rather not expose SSH at all, but you still forward the port over it, because a tailnet address is not a loopback address either.

Join a machine and keep the reporter running

Open the firewall on the hub, to the reporting port only.

sudo ufw allow 6788/tcp
sudo ufw status

Narrow it if your machines share a tailnet, with sudo ufw allow in on tailscale0 to any port 6788 proto tcp, so the port is invisible from the public internet.

Now the policy that catches people out. The hub refuses reports from callers outside private address space: RFC 1918, link-local, and IPv6 unique-local. A laptop on a home connection talking to your VPS's public IP is a public caller, so it is refused. Start the hub with --allow-cgnat and put both ends on a tailnet, which works because Tailscale hands out addresses in 100.64.0.0/10, carrier-grade NAT space that the hub treats as a separate, opt-in case. Or start it with --allow-public and accept that the reporting port faces the internet with nothing in front of it but device tokens and certificate pinning. Take the tailnet.

In the console, press "Add a machine", name the owner and the machine, and create a join link. The link carries the hub's address and the SHA-256 fingerprint of the certificate the hub made on first start, so the reporter will accept that certificate and no other. It works once and lives at most an hour. If the hub sits behind NAT, or you want join links to carry its tailnet address rather than the address it sees on itself, start it with --advertise <address>.

Install the same pinned release on the reporting machine, then join:

agent-console join '<join link>'

Keep the single quotes. The link contains characters the shell would otherwise read as its own.

Keep it running across logins with the user service the project documents, at ~/.config/systemd/user/agent-console-reporter.service:

[Unit]
Description=Agent Console reporter
After=network-online.target

[Service]
ExecStart=%h/.local/bin/agent-console report --interval 60
Restart=on-failure
RestartSec=30
RestartPreventExitStatus=2 3 4

[Install]
WantedBy=default.target
systemctl --user daemon-reload
systemctl --user enable --now agent-console-reporter
journalctl --user -u agent-console-reporter -f

Set ExecStart to the path command -v agent-console prints on that machine. --interval accepts 2 to 3600 seconds and defaults to 10; 60 is plenty for a cost view and lighter on a busy box. RestartPreventExitStatus=2 3 4 is doing real work: those exits mean a mistake in the command, removal by the console, or another reporter already running, and restarting would only repeat them. On a headless agent VPS run sudo loginctl enable-linger <user>, or the user service stops the moment you log out and starts reporting only when you log back in.

What actually leaves the reporting machine

Per usage event the reporter sends the tool name (claude-code or codex), the model id, the minute it happened, four token counts, whether it was a subagent, a continuation flag, and HMAC-SHA256 hashes of the session, its parent and the project folder. The session hashes are keyed with a salt the hub shares only with enrolled machines. The project states that prompts, replies, thinking, tool input and output, file names, file contents, git branches, command lines and credentials are never sent, and it ships a test that pushes transcripts full of planted canary strings through the real reporter and the real hub and inspects every transmitted byte.

Three flags widen that, and all of them default to off. --share-project-names sends folder names. --share-alerts sends alert kinds and counts. --share-tool-activity sends tool call counts by kind. Project folder names leak more than people expect, because repositories are often named after clients.

Treat linking a reporter as a decision about data all the same. The reporter process has read access to the full session transcripts on that machine, and those transcripts hold your prompts and the model's replies as plain text. What you are trusting is that this version of this program sends counts and nothing else. That is the real argument for pinning a release and reading the changelog before you move, rather than letting a nine-day-old project update itself under you. The same reasoning applies upstream, to what you let an agent read in the first place, because anything in the prompt is in the transcript.

Where the cost numbers come from

They are estimates, computed from a dated offline list-price table compiled into the release. The project says so directly, and adds that subscriptions, negotiated rates and other invoice adjustments are not observable from a transcript. So the dollar figure is right for spotting a run that cost ten times the usual, and wrong as an accounting record. On a Claude subscription rather than API billing, read it as a relative measure of load.

Cache reads are the number to watch. Cached input is billed far below uncached input, so a session whose cache read share collapses usually means a context that keeps getting invalidated rather than a model that got slower. Capping what an agent is allowed to spend on a VPS covers the limits worth setting once you can see which sessions are expensive, and the wider set of Claude Code spend trackers is worth a look if you want a per-repository ledger instead of a fleet view.

What agent-console does not do

It reads transcripts after the fact, and it is not a tracing tool: there are no spans and no prompt or response bodies. If you need to see the content of a step to work out why an agent did something, a self-hosted Langfuse instance is the tool for that job, and it costs you the privacy property agent-console is built around, because tracing means storing the prompts.

Pick by the question. "Which machine burned the budget last night" is agent-console. "Why did this agent take that step" is tracing. Running both is reasonable, as long as you know which one holds your text.

Failure modes worth knowing before you hit them

The reporter joins, and the hub stays empty. Reports arrive on 6788, so check that the hub was started with --listen 0.0.0.0 and that 6788 is open on the VPS firewall and on your provider's separate network firewall, which is a distinct control on most panels. A hub started without --listen keeps the reporting port on loopback, so the join never connects at all.

The join is refused from a home connection. That is the private-network policy, not a firewall. The caller's address is public, so the hub rejects it unless you started it with --allow-public, or moved both ends onto a tailnet and started it with --allow-cgnat.

The join link does not work a second time. Join codes are 128-bit, work once, and live at most an hour. The hub also rate limits join attempts to ten per address per ten minutes. Make a new link from the console.

The reporter service starts and immediately stops. Read systemctl --user status agent-console-reporter. Exit codes 2, 3 and 4 are deliberate stops rather than crashes, and the unit above tells systemd not to retry them. They mean a mistake in the command, removal of this machine by the console, or a second reporter already running on the same box.

The dashboard is empty right after install. The hub keeps a retention window, 8 days by default, and the first start reads only transcripts written inside that window. On a machine that has been quiet, there is nothing to show until an agent runs.

Backing up the hub

The state directory is the whole hub: daily record files, the admin key, device tokens and the certificate. Back it up while the service is stopped, keep it off the box, and you can rebuild on a fresh VPS without re-enrolling every machine.

sudo systemctl stop agent-console-hub
sudo tar czf agent-console-hub.tgz -C /var/lib/agent-console hub
sudo systemctl start agent-console-hub

If you rebuild without that archive, the new hub makes a new certificate and new tokens, so every reporter has to run agent-console leave and join again from a fresh link. That is survivable with two machines and tedious with ten. While you are handling agent boxes, the hardening in running Claude Code safely on a VPS applies to the hub too: it holds a record of who spent what, and that is worth protecting.

FAQ

Can I put the agent-console dashboard behind nginx with a password?

No. The console port refuses any request carrying Forwarded, X-Forwarded-For or Via headers, and it requires a loopback Host header, so a proxied request is rejected before authentication is considered. Use an SSH local forward instead: ssh -N -L 6787:127.0.0.1:6787 you@hub.example.com, then open http://127.0.0.1:6787. The project's own documentation gives the same answer for its metrics path, that remote access needs a secure tunnel to the local port.

Why is my laptop refused when it reports to the hub on a VPS?

The hub accepts reports only from private address space by default: RFC 1918, link-local, and IPv6 unique-local. A laptop on a home or office connection reaching your VPS's public IP is a public caller, so the hub refuses it. Put both machines on a tailnet and start the hub with --allow-cgnat, because Tailscale addresses live in 100.64.0.0/10, or start it with --allow-public and accept an internet-facing reporting port.

Does the reporter send my prompts to the hub?

No. Per event it sends the tool name, the model id, a minute timestamp, four token counts, a subagent flag, a continuation flag and HMAC-SHA256 hashes of the session, its parent and the project folder. Prompts, replies, file contents, file paths, command lines and credentials are not sent, and the project ships a canary test over the real reporter and hub to prove it. The reporter still reads full transcripts locally, so linking a machine means trusting that specific release, which is why you pin one.

How accurate is the estimated cost?

It is an estimate from a dated offline list-price table built into the release, and the project states that subscriptions, negotiated rates and invoice adjustments cannot be seen from a transcript. Use it to compare sessions and machines against each other, and to catch a run that cost far more than usual. Do not reconcile it against a bill.

Is v0.4.1 the version I should install?

As of September 2026 it is the newest release, dated 2026-09-27, with the first release only a week before it on 2026-09-20. Pin whichever tag is current when you install, and check the changelog before moving to a newer one, because a project releasing this often changes flags and defaults between versions. Installing from the release tarball with --ignore-scripts and checking SHA256SUMS gives you a build you can reproduce later.