---
name: agent-console
description: Open a customer's Hermes runtime on Fly in Hermes Desktop. Use when the operator wants to inspect, debug, or chat with a customer's agent — "open Persona in Hermes", "connect to a customer's agent", "see what the agent did in Slack", "list my customer agents", "close the console". Admin-only; requires the Sellable Fly org token. Read-only on the customer runtime; never sends.
---

# Agent Console — open a customer's Fly runtime in Hermes Desktop

The Fly-era counterpart to `hermes-vps-desktop-setup`. That skill reaches the
Hostinger VPS over Tailscale at a stable tailnet hostname. Fly is a different
shape: **one machine per customer**, no public service, and nothing listening on
the org private network — so the console tunnels in and is ephemeral.

<role>
You are a guest on a customer's machine. Their Slack gateway keeps running the
whole time. Nothing you do here sends a message or changes their campaigns.
</role>

## Admin-only

Requires the **Sellable Fly org token** (`FLY_API_TOKEN`). Whoever holds it can
open a console; nobody else can. That is the access control — there is no
per-user permission layer yet, so the audit trail says "the org token did it",
not who.

If the token is missing, say so and stop. Do not work around it or suggest other
routes into the machine.

## How it works

```
laptop → fly proxy (WireGuard) → hallpass :22 → ssh -L → machine loopback :9119
```

`hermes serve` starts on the machine's loopback only while attached and stops on
detach. Loopback matters: any non-loopback bind engages Hermes' auth gate, and
these images register no auth providers (`auth_providers: []`), so the static
session-token mode Desktop needs would be unavailable.

## The flow

Always run it as: **list → ask → open.** Never pick a customer for the operator.

**1. Load the options.**

```bash
agent-console --list
```

```
Customers:
  Test 2            running   slb-agent-685f4016e59662409898e487
  Persona           running   slb-agent-828555afd3a57f03efc986e9
  Test Campaigns    running   slb-agent-af7bae795e2680fc8eaa7536
  sellable.dev      running   slb-agent-df21059a2381e0aebd7e6d03
```

**2. Ask which one.** Present the workspace names and let the operator choose.
If they already named one ("open Persona"), skip the question.

**3. Open it.**

```bash
agent-console --customer persona
```

Hermes Desktop relaunches pointed at that customer, landing on its runtime
profile with its Slack threads.

**4a. All customers at once (browser).** Desktop shows one customer per
session, but browser dashboards have no such limit — each customer is its own
tunnel, so they open as independent tabs:

```bash
agent-console --all --dashboard
```

Verified across all four: `html=200`, `api_auth=200`, with Persona showing 7
sessions (3 Slack) and sellable.dev 2 (2 Slack). Hand the operator the URLs.

**4. Offer the browser UI.** Ask whether they also want a browser link — useful
when Desktop is uncooperative, or for a quick look without relaunching it:

```bash
agent-console --customer persona --dashboard
```

That prints a URL to hand over:

```
Browser UI for Persona:
  http://127.0.0.1:19556/?token=…
```

The token is in the URL, so treat it like a credential: hand it to the operator,
don't paste it anywhere shared, and it dies when the console closes.

To open the browser dashboard and Hermes Desktop against the same verified
customer backend, use:

```bash
agent-console --customer persona --both
```

The console waits for three consecutive authenticated `/api/status` responses
before opening either UI. A root response only proves that Uvicorn bound its
socket; it previously produced false readiness while Desktop's first real API
call still timed out. The readiness loop allows up to 15 minutes because a busy
shared-CPU runtime can take minutes to finish importing Hermes and scanning its
profile. Do not replace this gate with an arbitrary sleep or a root-only probe.

**5. Tear down** when they're done:

```bash
agent-console --stop
```

`--customer` holds the tunnel in the foreground; Ctrl-C closes the console and
stops the server on the machine. Leave it running while the operator works.

## What to tell the operator

- Which customer is open, and on what local port.
- That **their `connection.json` is untouched** — Desktop is launched with
  `HERMES_DESKTOP_REMOTE_URL` / `HERMES_DESKTOP_REMOTE_TOKEN`, so quitting and
  reopening Desktop normally returns them to the Hostinger tailnet gateway.
- That Ctrl-C ends the session.

## sellable.dev has a second profile

The sellable.dev machine also carries a `sellable-admin` profile with the Sellable
Admin MCP (51 tools), installed out-of-band on 2026-07-26. Its gateway shows
`stopped` — that is correct for a profile-only admin agent and does not mean it is
broken. Nothing in the control plane restores it, so if it disappears after a
machine replacement, rebuild it from
[SELLABLE-ADMIN-ON-FLY-CUSTOMER-MACHINE.md](../../../docs/operations/SELLABLE-ADMIN-ON-FLY-CUSTOMER-MACHINE.md).
sellable.dev is our own workspace; do not replicate this on a real customer's
machine.

## Rules

1. **Never start a stopped machine.** `--list` shows state; stopped agents are
   skipped. Starting one brings that customer's agent online — an explicit
   decision, not a side effect of looking.
2. **Never send, approve, or schedule** from inside a customer's runtime.
3. **Always tear down.** If a run is interrupted, follow with `--stop`.
4. **One customer per session.** Desktop's profile picker is populated by
   whichever backend it is connected to, so a session shows exactly one
   customer's agent. Rerun with a different `--customer` to switch.
5. **Never guess the customer.** List, then ask. Opening the wrong customer's
   agent is a privacy event, not a typo.

## Troubleshooting

**Desktop stuck on "CONNECTING"** — the WebSocket is failing while REST answers
fine. Almost always a stale `hermes serve` holding `:9119` with an older token,
so Desktop gets `403` on `/api/ws`. Run `--stop`, then rerun. The script stops
before starting for exactly this reason; a leftover from a manual session can
still cause it.

```bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:<port>/api/status   # expect 200
```

**Desktop fails with `ECONNREFUSED` after a Fly refresh** — Fly replaced or
restarted the Machine, so the ephemeral `hermes serve` process disappeared even
though the local proxy and SSH listener can still exist. Keep the foreground
`agent-console` process running. Its supervisor rebuilds the tunnel and, when
the authenticated status route is still absent, restarts only the console
backend with the same token/profile. It deliberately does not restart or
replace the customer's Slack gateway.

**Capabilities says “Skills failed to load” after the gateway is ready** — the
Capabilities screen makes separate `/api/skills` and `/api/tools/toolsets`
requests. Hermes Desktop builds containing NousResearch/hermes-agent PR #81180
give those discovery calls the same 60-second budget as other startup metadata;
older builds fail at the generic 15-second IPC timeout even when the backend
finishes successfully a few seconds later. Update/rebuild Desktop before
changing the Fly runtime.

**Cold start takes minutes** — first distinguish CPU throttling from memory
pressure. On a shared CPU, `vmstat 1 5` may show a high run queue, zero idle, and
90%+ `st` (steal) while RAM still has roughly 1 GB available. A concurrent Slack
turn can amplify this. Let the bounded readiness loop finish; do not create a
restart loop. Resize only after an otherwise-idle, warm retest still cannot load
status/capabilities reliably. The sellable.dev verification on 2026-08-07 later
returned to 94–98% idle CPU with about 1.0 GB available, so no resize was needed.

**"No sessions yet"** — that agent genuinely has no history. Not every runtime
has been used. Confirm with the operator before assuming a bug.

**`fly ssh issue` hangs** — a credential already exists and it is prompting. The
script deletes the old one first; if you ran flyctl by hand, remove
`~/.sellable-admin/agent-console/fly_ssh_key*` and retry.

## Known limits

**Use `--customer`. `--all` is not finished.**

`--all` exists and gets further than expected: with `mode: local`, a local
profile directory per customer named for its runtime profile id, and
`{value}`-wrapped tokens, Desktop **does list all four customers in the profile
rail**, and selecting one does route to that customer's backend ("Waking up
agent-…"). But it then hangs on that waking state and never loads the session
list. Best theory: in local mode Desktop still treats the profile as local for
*gateway startup* even though API reads route remotely, so it waits on a
gateway that will never come up locally.

What is already known, so nobody re-derives it:

- The picker enumerates the PRIMARY backend's profiles. Global remote mode
  therefore shows one host's profiles — which is exactly why `--customer` works.
- A local profile directory must exist for a name to appear in local mode, and
  it must be named for the **remote** profile id or the backend answers
  "Profile 'x' does not exist".
- Tokens in `connection.json` must be `{ value: "..." }`. A bare string is
  decoded through Electron safeStorage and comes back empty.
- Clone new local profiles from a working one; an empty profile triggers a
  full-screen onboarding modal that covers the UI.
- Deleting a profile that was made sticky leaves `~/.hermes/active_profile`
  dangling, which breaks every `hermes profile` command including `profile use`.
  The tool repairs this automatically.

- Desktop is v0.18.0 locally against v0.19.0 runtimes; fine so far.
