# Remote clients — work runs on each user's machine, the hub only records company spend Status: **B1–B3 built 2026-11-04 for the Claude pool** (§ Built); B4 and Codex not started. First decided 2026-10-09, re-scoped 2026-11-04 (this version). Server mode with S3 stays and runs side by side: a user with a weak machine keeps working on the hub, a user with their own machine runs AgentHub local and enrolls it. ## Why Everyone logging into one machine (server mode, S3) does not scale past ~10 concurrent users: every pane, build and test runs on the hub. Here the compute moves to each user's machine; the hub keeps identity, the shared accounts, and the record of what the COMPANY's accounts were used for. ## Decisions (user, 2026-11-04 — replaces the 10-09 list) 1. Each user may install **AgentHub local** on their own machine and enroll it with the hub once. **S3 stays** alongside it. 2. **The account decides, not the workspace.** Only a session that runs on a company account — the hub's pool or a shared AI Proxy, i.e. one that goes through the hub's gateway — is reported. A session on the user's OWN login or own proxy sends **nothing**: no conversation, no tokens, no heartbeat, not even the workspace's name. Neither does anything run outside AgentHub. 3. What a reported session sends: its **conversation and token usage**, never the repository. The hub **never reads the client's source tree, never runs anything on the client, never drives it** (no terminal, no start/stop, no file access). The client pushes; the hub never connects to a client. 4. Cadence: **one registration** when a company-account session starts (session id + device, no content — what the gateway checks), then conversation and tokens **once an hour** and once more when the session ends. No per-minute heartbeat (user, 11-04). A queue on disk carries what an offline machine could not send; it is sent back in the office. The conversation is uploaded **whole** (tool results and code included — user, 11-04: no stripping); only strings that look like keys, tokens or passwords are redacted before sending. The hub keeps a conversation **30 days**; token and cost rows are kept. 5. The company account's credential **never leaves the hub**. A client holds a device token only; its Claude calls go through the hub's gateway, which adds the real credential. Revoking the device token ends access at once. 6. Use of a device token outside AgentHub is **detected**; `remote_gate` decides `warn` (default: forward, mark the session unmonitored, alert) or `block` (403). 7. The hub is reached over the company LAN only (`10.1.13.7`); a device token **expires after 90 days** (`agenthub remote login` again). At home a company-account session simply cannot reach the hub — AgentHub local says so plainly — while the user's own login works as usual. Working from home later means a VPN, never exposing the hub. **No HTTPS** (user, 11-04): plain HTTP on the company LAN, accepting that someone already inside the network could sniff conversations, hub passwords and device tokens. Adding it later is a reverse proxy in front of the hub (docs/SERVER-MODE.md § Put HTTPS in front of it), no code change. 8. Company vs own is decided by the **account picked at session start**: an enrolled machine's account menu gains "Company account", whose pane talks to the hub's gateway; the default login and the user's own AI Proxies are their own. It is recorded on the run (`dashboard_runs.proxy`), shown as a "Company" badge, and a resume keeps it. A company account is usable only through the gateway, so neither side can be mislabelled. 9. Each user can see, on their local AgentHub, what was sent for each session and when, and on the hub who (admin/analyst) opened their sessions (`audit_log`). 10. On the hub a client's sessions are shown grouped **device › folder** — no lane rows are created for them. ## Shape ``` user machine — AgentHub LOCAL hub 10.1.13.7 — SERVER mode ┌──────────────────────────────────────┐ ┌──────────────────────────────────┐ │ pane on a COMPANY account: │─/v1/… ─▶│ gateway: device token + session │ │ ANTHROPIC_BASE_URL = hub gateway │ │ check → pool credential added │ │ ANTHROPIC_AUTH_TOKEN = ahdev_… │ │ → Anthropic; usage counted now │ │ │─start ─▶│ session registered (id only) │ │ │─hourly▶│ conversation + tokens │ │ pane on the user's OWN login │ (none) │ │ └──────────────────────────────────────┘ └──────────────────────────────────┘ ``` ## What "conversation, not source" can and cannot mean The hub stores no file of the repository. But a conversation is made of what the agent read and wrote: a transcript's tool results hold the files Claude `Read`, its `Edit`/`Write` inputs hold code, and every request through the gateway carries the whole context (code included) on its way to Anthropic. So "conversation only" still contains source excerpts. Decided (user, 11-04): they are NOT stripped — the transcript goes whole, only secret-shaped strings are redacted. ## Gateway checks (verified 2026-10-09, Claude Code 2.1.295) Every `/v1/messages` call carries `x-claude-code-session-id: `, and `--session-id ` sets it — `spawnRun` already starts Claude panes that way. The gateway checks each request: - bearer = a device token that is not revoked, of a user who is not disabled — else **always 401**; - `x-claude-code-session-id` = a session that SAME device registered at its start. The registration, not the hourly upload, is what this check reads. Relay spike (2026-10-09, script `~/.cache/agenthub-spike/relay.js`): a client with no login and a fake `ANTHROPIC_AUTH_TOKEN` worked through a relay that swapped in a team subscription's OAuth token (+ `anthropic-beta: oauth-2025-04-20`): answers, `--resume`, local tools, prompt-cache reads, rate-limit headers. Rules it gave: - set `ENABLE_PROMPT_CACHING_1H=1` in the pane (a base URL drops to 5-minute cache entries otherwise); - keep a session on ONE pool account (the cache is per account); - give the gateway through `--settings=` (a repo's `.claude/settings*.json` `env` overrides the pane env), as AI Proxy panes already do. Not checked: `/usage`, claude.ai MCP connectors, Remote Control — likely lost through a relay. What is and is not guaranteed: - **Guaranteed:** no hub account / disabled user / revoked device → no company credential, because it never leaves the hub — as long as the admin alone holds each shared seat's login. - **Not guaranteed:** that the AgentHub app was used. A device token copied out and used with a plain `claude` is caught by the registration check, unless the user also fakes a registration. Hard enforcement needs managed machines. - Quota and budget on company accounts stay **real-time** despite the hourly upload: the gateway counts `usage` from each response it relays. ## What already exists (2026-11-04) | Need | In the code | Missing | |---|---|---| | Hooks from another machine | `POST /api/hooks/ingest-batch` + `push-token.js` (`push:`) | one hub-wide token, no user/device; transcript dropped | | Credential swap | `proxy-gateway.js` (pane token → real key, SSE streamed) | loopback + pane token only | | Shared accounts | pools, pool groups, pins, own choice, `refreshLogins` | a path for a remote caller | | Spend per user | `pool-usage.js`, `user-quota.js`, `session-account.js`, Administration user detail | ownership of a session with no lane | | Transcript import | `importFromDirectory`, `codex-usage.js` | an upload path | ## Phases Each ships alone and changes nothing for a local-only install. **B1 — devices and ownership.** Table `devices` (id, user_id, name, token_hash, created_at, last_seen_at, revoked_at); `agenthub remote login http://10.1.13.7:4820` (hub password, throttled like `/api/auth/login`) stores an `ahdev_` token 0600 in the runtime dir; an admin lists and revokes devices on the user's page (step-up + `audit_log`). Column `sessions.owner_id`, written from the device's user; `sessionWhere` honours it, so scoping needs no lane. Verify: mint/hash/revoke tests; revoked → 401; a developer sees only their own device sessions. **B2 — gateway for the Claude pool over LAN.** `proxy-gateway.js` gains the device-token path: heartbeat check, `pickAccount` + session-sticky account, `usage` counted per response. The local `spawnRun`, on an enrolled machine and only when the user picks a company account, points the pane at the hub through `--settings=` and sets `ENABLE_PROMPT_CACHING_1H=1`. Spike first on one real pool account. Verify: a pane answers "pong"; a curl with a device token and an unknown session id is logged (`warn`) or 403 (`block`). **B3 — registration and hourly upload.** For company-account sessions only: one registration at start; conversation (Claude transcript, Codex rollout) and tokens hourly and at session end, incremental by byte offset, disk queue, never blocking a hook. The hub imports with `importFromDirectory` so cost matches local sessions; shown device › folder on the user's page. **B4 — shared skills/plugins.** No AgentHub code: company marketplace (git) + a `managed-settings.json` template for user machines. Codex comes after B2 works for Claude (ChatGPT-login auth differs from an API key; its session-id header is unchecked). ## Built (2026-11-04) Hub side — `server/hub/`, loaded only in server mode (`server/index.js` requires it inside `isServerMode()`; `server/__tests__/hub-boundary.test.js` fails if anything outside the folder requires it): | File | Does | |---|---| | `devices.js` | `ahdev_<32 bytes base64url>` tokens, only the SHA-256 kept; 90 days (`AGENTHUB_DEVICE_TOKEN_DAYS`); revoke | | `remote-sessions.js` | registration (UUID, absolute cwd, `claude` only); one session = one device — another user's device gets `409 EFOREIGN`, the SAME user's new device (enrolled again) takes it over (`moveSession` moves its copy) | | `remote-gateway.js` | `/api/remote/gw/v1/*`: device token → registered session (waits `AGENTHUB_REMOTE_REGISTER_GRACE_MS`, 5 s, for one in flight) → `claude-pool.forRemote` (sticky per session) → upstream `AGENTHUB_REMOTE_UPSTREAM`; usage read from a decompressed COPY of the answer (Anthropic sends it gzip/br) | | `remote-ingest.js` | upload chunks at the hub's own offset (`409 EOFFSET` + `expected`), copy under `/remote/u/d//`, `importFromDirectory` once per round (chunks carry `more: true`, the round's closing request imports; a round cut short is imported after `AGENTHUB_REMOTE_FLUSH_MS`, 2 min), then `sessions.source = device:`, `owner_id`; `purgeOld` after `AGENTHUB_REMOTE_RETAIN_DAYS` (30) keeps token rows | | `index.js` | `/api/remote/device/*` (device token: login, me, logout, sessions, end, files, upload), `/api/remote/*` (signed in: devices, revoke with password + audit, sessions, "who opened mine"), hourly purge | Shared: `sessions.owner_id` (scope.js, session-account.js, users.js honour it); `claude-pool.forRemote` and live remote sessions in the pool ranking; `auth.checkLogin`. Client side (local mode): `server/lib/remote-client.js` + `/api/remote-client` + `agenthub remote login|status|upload|logout`, Settings → AI tools → Company hub. The reserved AI Proxy name `agenthub.company` ("Company account", Claude only) writes `/proxy-settings/agenthub.company.json` with the gateway URL, the device token and `ENABLE_PROMPT_CACHING_1H=1`. A company session is registered at spawn, again on a new session id (SessionStart / UserPromptSubmit hooks: `/clear`, a fork), uploaded hourly (`DASHBOARD_REMOTE_UPLOAD_MS`) and soon after SessionEnd; every JSON line goes through `privacy.redactValue` first. Measured (2026-11-04): local mode loads none of `server/hub/` (216 server modules vs 214 before: `remote-client.js` + its route); startup and RSS unchanged within noise. E2E on one real pool account: answers, sticky account, `/clear`, resume, hub totals = transcript totals, no device token or key in the hub copy, revoke → 401, hub down → `503 EHUBDOWN` at start. Known limits: - Claude Code reads `ANTHROPIC_AUTH_TOKEN` once: a pane open across a re-login (expired or revoked token) keeps failing with 401 until it is resumed. - An unmonitored session is flagged on the hub (`unmonitored`, a warning in the log) and, once it has stayed unregistered for `AGENTHUB_REMOTE_UNMONITORED_ALERT_MS` (60 s), fires every enabled **Company account outside AgentHub** alert rule (`remote_unmonitored`). A registration that was only late clears the flag first. - `AGENTHUB_REMOTE_GATE=block` is covered by unit tests only. - A shared AI Proxy as a company account, and Codex, are not built. ## Open questions - Should an admin be able to mark a workspace "company account required"? (Today's answer: no — a user's own login in any workspace is their business.) ## Open risks - A hub outage stops every company-account session; the machine can still use its own login. - Every byte of a company-account session's context passes through the hub in transit; tell users.