Browser-based multi-VM interface for Claude Code. SpAIglass is a stateless relay that routes browser WebSocket connections to Claude Code running on your own machines.
If parsing this HTML is awkward, fetch https://spaiglass.xyz/api/setup instead — exact same content, structured JSON, no CSS/markup noise. Both endpoints stay in sync (they're rendered from one source).
Use whichever format is easier for your tools. The numbered steps, decision trees, and final instructions are identical.
Every setup you perform MUST end with the full integrity check in the "Verify the setup actually works" step below. Do not declare success until all six gates are green. A setup where the installer exited 0 and the connector shows online can still be silently broken — the failure modes the gates catch are real ones we have shipped to users:
agents/*.md, so session_start rejects every message while the UI looks connectedIntegrity-check failures are not optional to investigate. If you skip them, the user will come back with "it doesn't work" and neither of us will know where the break is. Run all six checks, paste the jq output if anything looks off, and fix before reporting done.
Universal fix pattern: delete bad record + re-add correctly. Every configurable surface supports it:
DELETE /api/connectors/:id then POST /api/connectorsDELETE /api/roles/:name?path=X then POST /api/roles?path=XPOST /api/projects/unregister then POST /api/projects/register (idempotent)SpAIglass runs anywhere the Anthropic Claude Code CLI runs. Mix and match in the same fleet.
systemd --user service with linger so it survives logout.~/Library/LaunchAgents.Install the official Anthropic Claude Code CLI first (claude.ai), then run the spaiglass installer for your platform.
SpAIglass now works on two concepts — Server and Directory. Everything below assumes this model. If you are an agent reading this to configure a new user, internalize the terms before touching any endpoints.
name (URL slug) and a displayName (human label). The fleet API calls these rows "connectors"; the UI calls them "Servers". Same thing. One agent key can own many servers.GET /api/projects (Claude Code's own project registry at ~/.claude.json). One server can have many directories — a user is no longer forced into one-connector-per-project. The UI term is always "Directory", never "Folder" — do not rename it in user-facing copy..claude/agents/<role>.md) defines persistent Claude persona + plugins + tool allowlist for a directory. If a directory has no role, chat still works — you just get the default Claude Code behavior. Add roles when the user wants persistent persona or plugin pinning; skip them for casual exploration./vm/<login>.<server>/ → Server+Directory picker. /vm/<login>.<server>/<directory-basename>/ → chat for that directory (role-less; primary flow). /vm/<login>.<server>/<directory>-<role>/ → chat with a specific role (legacy but still supported for links/bookmarks). The human never needs to know any of these — they use dropdowns.~/projects/* directories. Nothing in the DB needs migrating.Before registering anything, find out whether this user already has a connector set up. This step prevents the #1 fleet-maintenance bug: agents re-registering a VM the user already owns, leaving duplicate or stale connectors behind.
1. Do you already have an agent key? If your human user has handed you an sg_... key, use it. If not, skip to Step 2 to mint one — you need it to call GET /api/connectors.
2. List existing connectors. Call GET https://spaiglass.xyz/api/connectors with your agent key. If the VM you are being asked to configure already appears in the list, do NOT create a new one — reuse it. If only the human-friendly name is wrong, use PATCH /api/connectors/:id with {"name": "new-slug"} to rename in place (preserves id + token, VM keeps running).
3. Ask the human when in doubt. If the list has a connector that might be the target VM (same prefix, similar name), ask the human before touching it — do not guess.
GET https://spaiglass.xyz/api/connectors
Requires `Authorization: Bearer sg_YOUR_KEY` header. Response is a flat array of `{id, name, displayName, role, online, lastSeen, ...}`. This is the source of truth for the fleet dropdown — anything in it is real, anything not in it does not exist.
You need an sg_... agent key before you can register a VM. Pick whichever option fits how your human user is set up:
Option A — You already have your user's GitHub PAT. Exchange it for an agent key via the API below. This is the fastest path when you (the agent) already have credentials. A classic PAT needs no scopes (the relay only calls GET https://api.github.com/user). A fine-grained PAT needs Account permissions → Read profile data — no repo scopes.
Option B — Ask the human to retrieve a token. Tell the user: "Go to https://spaiglass.xyz and sign in with GitHub. If you have no VMs registered yet, a one-shot token will appear in a copy-box — paste it back to me." That token is a real sg_... agent key minted for their session. Either option produces the same kind of key; use Option B whenever you do not already have a PAT and do not want to ask for one.
POST https://spaiglass.xyz/api/auth/token-exchange
{ "github_pat": "github_pat_YOUR_TOKEN", "key_name": "my-agent" }
Token prefix: fine-grained PATs start with `github_pat_` (the default when you create one in GitHub today); classic PATs start with `ghp_`. Either one is accepted — send the string you have, whichever prefix it has. Save the returned agent key — it is shown only once. Treat it like a password. All subsequent steps set the header `Authorization: Bearer sg_YOUR_KEY`.
Register a new VM connector. The name is a short label for your reference (used as the URL slug the human will see later). You can register one VM, or one for each project you are setting up — the agent key is reusable across VMs.
Name rules (enforced on create AND rename — same contract): must start alphanumeric, can contain letters, digits, dots, hyphens, underscores; max 100 chars; cannot be a reserved relay route (e.g. api, vm, setup, auth, install, releases, dashboard). Whitespace-only or empty names are rejected. A 409 means you (the same user) already have a connector with that name — check Step 1's list and reuse it, or pick a different name.
Want a different name later? Use PATCH /api/connectors/:id with {"name": "new-slug"}. That preserves the id and token so the VM-side connector keeps working without reconfig — see the Fleet Management API section below.
POST https://spaiglass.xyz/api/connectors
{ "name": "my-vm" }
Requires `Authorization: Bearer sg_YOUR_KEY` header. The response returns `{ id, name, token, ... }` — save all three, `token` is shown ONCE and you'll feed all three to the installer in the next step. `id` and `token` are both UUIDs (no prefix); `name` is the slug you just chose. Worked example mapping this response to Step 5 flags: Response: { "id": "4f5e...", "name": "my-vm", "token": "8a9b..." } Step 5: --id=4f5e... --name=my-vm --token=8a9b... Copy each field verbatim. Do not invent values. Do not reuse the agent key (sg_...) as the connector token — they are different credentials. You do NOT need to construct or remember a VM URL; sign-in handles routing for the human automatically.
SpAIglass spawns the official Anthropic Claude Code CLI to run sessions. It must be installed AND authenticated before the spaiglass installer runs.
Auth model — read this first. Both auth patterns below use your existing Claude subscription via OAuth. Neither one generates or uses an ANTHROPIC_API_KEY; nothing here switches you to API-key billing. The terms "headless" and "setup-token" sound like API-key flows but they are not — they are just OAuth tokens stored on disk so non-interactive subprocesses can read them. SpAIglass spawns claude as a subprocess; it inherits whichever credentials file you produce here.
Pick the right pattern for the host:
Pattern A — Desktop with a browser (Windows, macOS, Linux desktop). Run claude login once. The CLI opens your default browser, you complete OAuth, the credentials persist to ~/.claude/.credentials.json (Linux/macOS) or %USERPROFILE%\.claude\.credentials.json (Windows). Use this on any machine where you can reach the browser yourself.
Pattern B — Headless VM (no desktop, SSH-only). Plain claude login hangs waiting for a TTY/browser that is not there. Run claude setup-token on the VM instead — it prints a URL, you open it in a browser on any other machine, complete OAuth, paste the code back into the VM terminal. Same OAuth subscription, just a one-round-trip flow that doesn't need a browser on the VM itself.
Pattern C — Cloning credentials (fleet rollouts). If you already authenticated on another machine, copy ~/.claude/.credentials.json to the new host (same path, mode 600 on Linux/macOS). Same OAuth subscription, no second login.
Verify with claude --version AND a trivial round-trip (echo hi | claude -p 'say ok') before continuing. A claude binary that responds to --version but 401s on -p will cause the spaiglass installer to look fine while every chat session dies on first message.
Requirements: Node.js >= 20, Claude Code CLI installed (~/.local/bin/claude on Linux/macOS, %USERPROFILE%\.local\bin\claude.exe on Windows), Claude Code CLI authenticated via OAuth (subscription) — verify with: echo hi | claude -p 'say ok'
# ── Install ── # Linux / macOS: curl -fsSL https://claude.ai/install.sh | bash # Windows (PowerShell): irm https://claude.ai/install.ps1 | iex # ── Authenticate (pick ONE; both are OAuth subscription, NOT API key) ── # Pattern A — Desktop with a browser (Windows, macOS, Linux desktop): claude login # Opens your default browser, complete OAuth, done. # Pattern B — Headless VM (SSH-only, no GUI): claude setup-token # Prints a URL. Open on any machine with a browser, complete OAuth, # paste the returned code back into the VM terminal. # ── Verify auth actually works (not just that the binary exists) ── claude --version echo hi | claude -p 'say ok' # should print an assistant reply
SpAIglass looks for the binary at ~/.local/bin/claude on Linux/macOS and %USERPROFILE%\.local\bin\claude.exe on Windows. If `claude -p` fails with 401/403, re-run `claude login` (desktop) or `claude setup-token` (headless) — tokens occasionally expire before paste. Do NOT set ANTHROPIC_API_KEY; SpAIglass uses your OAuth subscription. Do NOT run plain `claude login` on a headless VM expecting it to auth — it needs a TTY and a local browser. Use `claude setup-token` there instead.
The installer downloads a self-contained binary tarball (~30 MB) from the relay, extracts it under ~/spaiglass, writes the .env, and registers a per-user service that launches the backend + relay connector at boot/logon. Idempotent. Re-running upgrades in place and preserves the existing .env — if setup failed partway through, you (the agent) can re-run the exact same one-liner with the original --token/--id/--name, or without any flags to refresh from the saved .env.
Linux gating decision — user lingering (requires sudo). On Linux, the systemd --user service only survives logout if lingering is enabled for the user, and enabling lingering is a root-only operation. Before running the installer, check whether passwordless sudo is available: sudo -n loginctl enable-linger $USER. If that succeeds silently, you are done — proceed to install. If it fails (prompts for a password or exits non-zero), stop and ask the human user to run it manually — without linger the service will die on logout and the VM will appear to "go offline" mysteriously. The installer itself hard-fails on Linux when linger is not set, so there is no way to sleepwalk past this. macOS and Windows have no linger requirement.
# Linux preflight (ask the user to run if this errors): sudo -n loginctl enable-linger $USER # Field mapping — use the EXACT values from Step 3's response: # --id=(UUID) # --name= (the slug you chose) # --token= (UUID, shown once) # Linux / macOS install: curl -fsSL https://spaiglass.xyz/install.sh | bash -s -- \ --token=YOUR_TOKEN --id=YOUR_ID --name=YOUR_VM_NAME # Windows (PowerShell — run as your normal user, no admin needed): & ([scriptblock]::Create((iwr https://spaiglass.xyz/install.ps1 -useb))) ` -Token YOUR_TOKEN -Id YOUR_ID -Name YOUR_VM_NAME
Installs a systemd --user unit on Linux, a launchd LaunchAgent on macOS, and a per-user Scheduled Task on Windows. All three start automatically and restart on crash. No inbound ports are opened — the connector dials out over WSS to the relay. If the installer bails on the linger check, fix linger and re-run the same one-liner. Supported hosts: Linux x64/arm64, macOS x64/arm64, and Windows x64 — five native binaries, no WSL2 required. The PowerShell installer pulls the matching windows-x64 build automatically. Files land under %USERPROFILE%\spaiglass\ on Windows (env file: %USERPROFILE%\spaiglass\.env).
Roles are optional in the Server+Directory model. A directory without a role works fine — chat uses default Claude Code behavior for that cwd. Add a role when the user wants a persistent persona, a pinned plugin set, or a restricted tool allowlist for that directory. If the user has not asked for one, skip this step — do not hand-craft a role file unprompted.
When you do add a role: SpAIglass uses Claude Code's native .claude/agents/ directory — the same convention the CLI uses with claude --agent <name>. Each .md file becomes a selectable role for that directory in the SpAIglass chat view.
Fastest path — use the register API. One POST to the VM's local backend creates the directory, role file, Claude config entry, and project metadata directory in one shot. See Adding Projects & Roles for the endpoint reference.
Manual path (same result). If you prefer shell commands, the block below works. After creating the file, restart the spaiglass service (systemctl --user restart spaiglass) so the backend re-scans.
Start from the baseline. Do not block on hand-crafting a role. There is a canonical template at /roletemplate (raw: /roletemplate.md). Drop it in, finish setup, and then discuss with the human how to improve the role.md during the first real session.
# ── RECOMMENDED: one-shot register via API ──
curl -s -X POST http://127.0.0.1:8080/api/projects/register \
-H 'Content-Type: application/json' \
-d '{"name": "MyProject", "role": "developer"}'
# With custom role content:
curl -s -X POST http://127.0.0.1:8080/api/projects/register \
-H 'Content-Type: application/json' \
-d '{"name": "MyProject", "role": "developer", "roleContent": "You are the developer for MyProject..."}'
# ── Manual path (if you prefer shell commands) ──
PROJECT=myproject
ROLE=developer
mkdir -p ~/projects/$PROJECT/.claude/agents
curl -fsSL https://spaiglass.xyz/roletemplate.md \
| sed "s//$PROJECT/g" \
> ~/projects/$PROJECT/.claude/agents/$ROLE.md
systemctl --user restart spaiglass # re-scan projects
Example: https://spaiglass.xyz/vm/octocat.dev-server/myproject-developer/
Role files use YAML frontmatter (between --- delimiters) to configure plugins, tools, MCP servers, and model settings. The markdown body below the frontmatter is injected into Claude's system prompt.
| Field | Type | Description |
|---|---|---|
| plugins | object | Enable/disable plugins for this role: "plugin-name@marketplace": true/false. Parsed from role frontmatter and surfaced in the role editor UI. |
| mcpServers | object | MCP tool servers to register for this role's sessions. Same format as Claude Code's mcpServers config. |
| tools | string[] | Allowlist of tools this role can use (e.g., Read, Write, Bash, mcp__github__*). |
| disallowedTools | string[] | Tools to block for this role, even in bypass mode. |
| model | string | Claude model override (e.g., claude-opus-4-6, claude-sonnet-4-6). |
| permissionMode | string | Permission mode override (bypassPermissions is the default in SpAIglass). |
| maxTurns | number | Max conversation turns before the session stops. |
| effort | string | Thinking effort level (low, medium, high). |
---
plugins:
superpowers@claude-plugins-official: true
code-review@claude-plugins-official: true
frontend-design@claude-plugins-official: false
mcpServers:
github:
command: npx
args:
- -y
- "@anthropic-ai/mcp-server-github"
tools:
- Read
- Write
- Edit
- Bash
- mcp__github__*
model: claude-opus-4-6
---
# MyProject — Backend Developer
You are the lead backend engineer...
SpAIglass parses the enabledPlugins map from each role's frontmatter and surfaces it in the role editor UI. Sessions for all roles currently share the host's ~/.claude/ config — per-role plugin settings aren't physically isolated yet, so if two roles enable conflicting plugins, the last-written settings win. Keep plugin sets aligned across roles on the same project for now.
| Section | Why it matters |
|---|---|
| Identity (put first) | Who is Claude in this role? "You are the lead backend engineer for ProjectX." One strong sentence at the very top. Models attend most to the beginning and end of instructions — put identity and hard rules at those positions. |
| Project location | Where is the code? ~/projects/myproject/ — Claude needs this to find files without asking. |
| Architecture / tech stack | What's the stack? What are the key directories? Use tables — a table of 10 directories with one-line descriptions beats two paragraphs of prose. Only list things Claude can't figure out by reading the code. |
| How things connect | How do the pieces connect? How do messages flow? How is it deployed? Write this like a day-one briefing for a new developer, not a reference manual. |
| Verification commands | How does Claude check its own work? Provide the exact commands: build, test, lint, deploy-check. This is the single highest-leverage section — without it, you become the only feedback loop. |
| Authority & access | What can Claude do? sudo, git push, SSH to other machines, credentials, databases. If Claude doesn't know it has access, it won't use it. List credential file paths explicitly. |
| Conventions | Commit message style, branch strategy, test expectations, naming conventions. Only include rules that differ from defaults — don't tell Claude to "write clean code." |
| Compaction instructions | What must be preserved when Claude's context window compresses during long sessions? "Always preserve: modified file list, pending deploys, current task, verification commands." Without this, long sessions lose critical state. |
| Hard rules (put last) | What must Claude NEVER do? Use absolute language (NEVER, MUST NOT) and explain WHY for each rule. "Never force-push to main — other sessions depend on linear history." Rules with rationale are followed more reliably than bare commands. |
---
plugins:
superpowers@claude-plugins-official: true
code-review@claude-plugins-official: true
model: claude-opus-4-6
---
IMPORTANT: You are the lead backend engineer for MyProject, a SaaS API platform. You are a senior engineer with root access. Execute, don't narrate.
## Who you are
- You own the backend: API, database, deployment pipeline
- The human is technical and direct — report results, not intentions
- When something breaks, diagnose the root cause. Don't retry blindly
## Project
~/projects/myproject/ — GitHub: github.com/acme/myproject (main)
## Architecture
| Layer | Stack |
|-------|-------|
| API | Node.js 20, Express, TypeScript |
| Database | PostgreSQL 16 on db.internal:5432 |
| Cache | Redis on cache.internal:6379 |
| Deployment | Docker Compose via `./scripts/deploy.sh` |
## Key directories
| Path | What's there |
|------|-------------|
| src/routes/ | API route handlers |
| src/models/ | Database models (Drizzle ORM) |
| src/middleware/ | Auth, rate limiting, logging |
| tests/ | Vitest test suite |
| scripts/ | Deploy, migrate, seed scripts |
## Verification — check your work
| What | Command |
|------|---------|
| Types compile | `npx tsc --noEmit` |
| Tests pass | `npm test` |
| Lint clean | `npm run lint` |
| DB migrations | `npm run migrate:status` |
ALWAYS run the relevant checks before declaring done.
## Access & credentials
- `~/credentials/db.json` — PostgreSQL connection string
- `~/credentials/github.json` — PAT (git push works via credential helper)
- SSH to db.internal and cache.internal via ~/.ssh/config
- Passwordless sudo on this machine
## Conventions
- Commit messages: imperative mood ("Add user endpoint", not "Added")
- PRs target main, squash-merge only
- Migrations in src/migrations/ — never edit a shipped migration
## When context compacts
Preserve: list of modified files, pending deploys, current task, test results.
## IMPORTANT — Hard rules
- NEVER commit anything from ~/credentials/. Why: live tokens would be exposed in git history.
- NEVER force-push to main. Why: CI and other developers depend on linear history.
- NEVER drop a production table without explicit instruction. Why: data loss is irreversible.
SpAIglass also checks the legacy agents/ directory for backward compatibility. If the same filename exists in both .claude/agents/ and agents/, the .claude/agents/ version takes precedence. The canonical baseline template is at /roletemplate — use it whenever a setup agent would otherwise block on "what should the role file say?".
The Directory dropdown in the SpAIglass chat header is a live view of Claude Code's ~/.claude.json projects map on this VM — SpAIglass does not maintain its own separate list. To change what the human sees in the dropdown, mutate ~/.claude.json via the three VM-local endpoints below. The user will typically ask you in chat (e.g. "hide the workspace directory", "add ~/code/foo", "rename that one to Acme API") — make the matching API call and confirm. The next page reload on the user's side picks up the change; no restart needed.
Hide a directory — removes the entry from ~/.claude.json but keeps its encoded session history on disk, so re-registering later restores prior chats.
Add a directory — the same register endpoint used at setup time. Role is optional in the Server+Directory model; pass {"name": "..."} alone to just create the directory entry.
Rename (cosmetic label only) — the directory's real path never changes; this just overrides the display name in the dropdown.
These calls are VM-local (127.0.0.1:8080) and require no auth — only the VM-side agent can reach them.
# ── Hide a directory from the dropdown ──
curl -s -X POST http://127.0.0.1:8080/api/projects/unregister \
-H 'Content-Type: application/json' \
-d '{"path": "/home/user/workspace"}'
# → {"ok":true,"removed":true,"path":"/home/user/workspace"}
# Idempotent: removed:false if the path was not registered.
# ── Add a directory to the dropdown ──
curl -s -X POST http://127.0.0.1:8080/api/projects/register \
-H 'Content-Type: application/json' \
-d '{"name": "foo"}' # ~/projects/foo, no role
# Add with an explicit absolute path (anywhere on disk):
curl -s -X POST http://127.0.0.1:8080/api/projects/register \
-H 'Content-Type: application/json' \
-d '{"name": "foo", "path": "/home/user/code/foo"}'
# ── Rename the dropdown label (cosmetic) ──
# `project` is the directory basename (e.g. 'foo' for /home/user/code/foo),
# NOT the full path. Pass displayName:null to clear the override.
curl -s -X PUT http://127.0.0.1:8080/api/settings/project-display-name \
-H 'Content-Type: application/json' \
-d '{"project": "foo", "displayName": "Acme API"}'
# ── See what is currently in the dropdown ──
curl -s http://127.0.0.1:8080/api/projects | jq
These mutate ~/.claude.json directly. Hide preserves session transcripts under ~/.claude/projects/
Do NOT trust "installer exited 0" as proof of working. The service can fail to start, the connector token can be wrong, linger can silently revert — all of which leave the installer happy but the VM absent from the fleet.
1. Relay health (public). GET https://spaiglass.xyz/api/health — returns {"status":"ok", "spaiglassVersion":"..."}. No auth required. Confirms the relay is reachable from wherever you are calling from.
2. Connector online (authenticated). GET https://spaiglass.xyz/api/connectors and look for your id with online: true. If online: false 30 seconds after the installer finished, the service did not attach — see troubleshooting.
3. Project visible (on the VM). GET http://127.0.0.1:8080/api/projects on the VM itself should return the project you just registered. If it does not, either the backend is not running on port 8080 or the project was written to the wrong directory.
4. Directory has a resolvable role. GET http://127.0.0.1:8080/api/roles?path=<projectPath> must return a non-empty roles array. The backend session store is keyed by (projectPath, roleFile) — a directory with zero role files cannot start a session even though chat URLs resolve and the connector looks online. The browser's role-less URL flow (/vm/<server>/<directory>/) auto-picks developer.md if present, else the first available file; if the list is empty, chat silently dies at session_start. If this check returns { "roles": [] }, either (a) call POST /api/projects/register with a role name to create one, or (b) drop /roletemplate.md into <projectPath>/.claude/agents/developer.md and re-run the check.
5. Installed CONNECTOR_ID matches the one you just registered. Read CONNECTOR_ID from ~/spaiglass/.env on the VM and compare it to the id returned by Step 3's POST /api/connectors. They must be the same UUID. If they differ, the installer was run with flags from a previous registration, leaving the connector you just created as an orphan (visible in GET /api/connectors, permanently online: false, never seen by the relay) while the service is actually authenticated as a different one. Fix by calling DELETE /api/connectors/:id on the orphan — do not leave it in the fleet, it shows up in every user's dropdown as a broken server.
6. No other orphaned connectors for this host. List owned connectors via GET /api/connectors and inspect any entry with online: false AND a lastSeen either null or older than 24h. If more than one owned connector is authenticated-but-silent and they plausibly map to the same VM (same display name stem, same human’s machine), they are leftovers from a prior failed setup. One machine should equal one connector — duplicate registrations fracture the recent-URL list in the UI and make the dropdown surface dead entries. Delete the orphans with DELETE /api/connectors/:id; the live service keeps working because its token points at the row you are keeping.
Only after all six pass should you report "setup complete" to the human.
# From anywhere (no auth):
curl -fsSL https://spaiglass.xyz/api/health | jq
# With your agent key (confirm connector is online):
curl -fsSL -H 'Authorization: Bearer sg_YOUR_KEY' \
https://spaiglass.xyz/api/connectors \
| jq '.[] | select(.id=="YOUR_CONNECTOR_ID") | {name, online, spaiglassVersion}'
# On the VM (confirm project was registered):
curl -fsSL http://127.0.0.1:8080/api/projects | jq
# On the VM (confirm the directory has at least one role file —
# chat silently dies at session_start if this list is empty):
curl -fsSL 'http://127.0.0.1:8080/api/roles?path=/home/USER/projects/myproject' \
| jq '.roles | length' # must be >= 1
# Fix pattern if gate 4 fails — delete bad role + re-add. On the VM:
# curl -X DELETE 'http://127.0.0.1:8080/api/roles/?path='
# curl -X POST 'http://127.0.0.1:8080/api/roles?path=' \
# -H 'Content-Type: application/json' \
# -d '{"name":"developer","description":"..."}'
# On the VM (confirm the installer actually wired up the connector
# you just registered — mismatch = orphan row in the relay DB):
grep '^CONNECTOR_ID=' ~/spaiglass/.env
# The UUID printed here MUST equal the `id` from Step 3's response.
# Orphan sweep — any owned connector that never came online, or
# last saw the relay >24h ago, that maps to this same host is dead
# weight in the dropdown. Delete each one with:
# curl -X DELETE -H 'Authorization: Bearer sg_YOUR_KEY' \
# https://spaiglass.xyz/api/connectors/
curl -fsSL -H 'Authorization: Bearer sg_YOUR_KEY' \
https://spaiglass.xyz/api/connectors \
| jq '.[] | select(.online==false and (.lastSeen==null or (now - (.lastSeen/1000) > 86400))) | {id, name, displayName, lastSeen}'
If any of the six checks fail, stop and consult the troubleshooting section — do not ship an unverified setup. Check #4 catches the "everything looks green but first message never gets a reply" failure mode (online connector, registered project, but no role file so session_start rejects). Check #5 catches the "running service authenticates as connector X while the agent thought it set up connector Y" split — the .env is authoritative for which row is live, anything else with the agent's user_id is dead weight. Check #6 catches older orphans from prior setup attempts — one machine equals one connector, and duplicates surface in the fleet dropdown as permanently-offline servers users keep clicking.
Do not skip this step. architecture/architecture.json is the single most valuable artifact you will produce during setup. It is an operational snapshot of the project — when Claude (or a human returning after months away) opens a session, the Arch button renders this file and gives them full mental context without reading code. A project without one is a project every new session re-discovers from scratch.
Pick one of the two paths below based on how much the user has ready right now:
Path A — Quick start (≈5 minutes). Use the minimal template below. Fills components, connections, and infrastructure with placeholder values the user can refine later. Good when the user wants to move on to chat and promises to improve the file "soon". Set the expectation: this unblocks the Arch button but produces a breadcrumb, not an operational document. Schedule a follow-up to graduate it to Path B within the week.
Path B — Comprehensive (recommended; ≈30-60 minutes). Fetch the full manual first at https://spaiglass.xyz/api/architecture-manual (raw markdown; easy to parse) and read it end-to-end before writing any field. The manual lays out the eight non-negotiable rules — snapshot over design doc, measured status with statusSource, complete site map including orphans, redacted secrets preserving shapes, etc. Then generate the manifest by observing the running system (code at HEAD, running processes, the DB, URLs that actually respond), not by reading README. This is the default path; only fall back to Path A if the user explicitly opts for the quick start.
mkdir -p ~/projects/myproject/architecture # ── Path B: Fetch the manual (READ END-TO-END before writing) ── curl -fsSL https://spaiglass.xyz/api/architecture-manual -o /tmp/architecture-manual.md # Manual is ~5 pages of core rules + reference appendix. Do not skim. # After reading, generate architecture.json from OBSERVATION: # git rev-parse HEAD # code as checked out # systemctl --user list-units # processes actually running # psql -c '\d' / mongosh --eval ... # DB as it currently exists # curl -sSf# routes that actually respond # du -sh / ls # filesystem as it currently exists # Write the manifest as a SNAPSHOT, not a design doc. # ── Path A: Quick-start minimal template (placeholder only) ── # Use only if the user explicitly opted out of the comprehensive path.
Example:
{
"project": {
"name": "MyProject",
"summary": "Brief description of what this project does"
},
"components": [
{
"id": "api",
"name": "API Server",
"type": "service",
"runsOn": [
"vm1"
],
"status": "active",
"statusSource": {
"command": "systemctl --user is-active myproject-api",
"output": "active",
"observedAt": "2026-04-20T00:00:00Z"
}
},
{
"id": "db",
"name": "Database",
"type": "datastore",
"runsOn": [
"vm1"
]
}
],
"connections": [
{
"from": "api",
"to": "db",
"purpose": "queries"
}
],
"infrastructure": [
{
"id": "vm1",
"name": "Production VM",
"type": "vm"
}
],
"architectureRules": [
"All traffic must go through the API gateway"
]
}
Save at ~/projects/myproject/architecture/architecture.json. The Arch button in the chat UI renders this file; without it, Arch links here. Path A is a placeholder — it unblocks the Arch button but does not substitute for Path B. The manual explains why shallow manifests are worse than no manifest.
Report success ONLY after the full six-gate integrity check passed (see step "Verify the setup actually works"). All six gates must be green: relay health, online: true for the connector, project visible in /api/projects, at least one role file via /api/roles?path=…, CONNECTOR_ID in ~/spaiglass/.env matches the id you registered in Step 3, and no orphan connectors left behind for this host. Running the integrity check is not optional — it is the contract that separates a finished setup from a broken one that only looks finished. If any gate fails, do not declare success; fix it (delete orphans with DELETE /api/connectors/:id, add a role file, re-run the installer, etc.) and re-run the check until every gate is green. Once verified, tell the human: "Setup complete. Go to https://spaiglass.xyz and sign in with GitHub — you'll land directly in your chat." They do not need a URL, a VM name, or a role name; sign-in routes them to the right place automatically. Do not construct or share /vm/<login>.<vm>/ URLs — that detail is now internal. If something failed partway through, say so plainly and tell the user what you need from them (typically: `sudo loginctl enable-linger $USER`, then re-run the install one-liner).
After initial setup, the connector fleet is managed entirely through the relay API. If the API cannot express an operation you need, update the API — never edit the relay database directly. Every endpoint below requires `Authorization: Bearer sg_YOUR_KEY` and operates on connectors owned by the caller.
| Method / Path | Purpose |
|---|---|
| GET /api/connectors | List all connectors owned by the caller, plus any shared with them. Returns `{ id, name, displayName, role, online, lastSeen, createdAt, spaiglassVersion }` per connector. Use this as the source of truth for what's in the fleet — it is what the fleet dropdown reads. |
| POST /api/connectors | Register a new connector. Body: `{ "name": "my-vm" }`. Returns `{ id, name, token, ... }`; the raw token is shown once — store it, then supply it to the VM installer. Preferred over any manual DB entry. |
| PATCH /api/connectors/:id | Update a connector. Body accepts `displayName` (free-form label) and/or `name` (slug — changes /vm/ |
| DELETE /api/connectors/:id | Remove a connector. Disconnects the live tunnel if the VM is online and deletes the connector record. The VM-side spaiglass service will fail to reauth until you POST a replacement and update its .env with the new token/id. |
| GET /api/connectors/:id/config | Download a .env scaffold for the connector (the raw token is NOT embedded — tokens are hashed at rest; you must keep your own copy from the create call). |
| GET / POST / PATCH / DELETE /api/connectors/:id/collaborators[/:userId] | Share a connector with another signed-in spaiglass user at role `editor` or `viewer`. Owner-only except for GET, which any collaborator can call to see who else has access. |
| PUT /api/connectors/:id/labels | Set or clear a custom human-readable label for a role file on this connector. Owner only. Use this instead of renaming role files on disk when a human wants a friendlier name in the UI. |
SpAIglass is a browser UI for Claude Code — it does not launch Claude for the user, and it never asks the user about "absolute paths" or "relay vs VM". When the user says "rename my server to Foo" or "add ~/code/bar to my directory list", you (the install agent on this VM) make the API call on their behalf. Confirm the plain-English settings you are about to change before firing, then do it. Never instruct the user to reinstall to make a change.
| Name | What it controls | Editable by |
|---|---|---|
| Server Display Name | Top-left server name on the chat page, Server dropdown entries, Server segment of the 'last used' buttons, Agent Picker on mobile. Cosmetic — the real connector slug in the URL does not change. | User via Settings wheel OR agent via relay API. |
| Project Directory Display Name | Top-left project label on the chat page, Directory dropdown entries (shown as ' |
User via Settings wheel OR agent via VM-local API. |
| Project Directory Tab Name | Browser tab title (and therefore the text saved when the user bookmarks the page). Falls back to Project Directory Display Name, then to the directory basename. Nothing in-app uses this string. | User via Settings wheel OR agent via VM-local API. |
| Working Directory (real) | The absolute path on the VM's filesystem, used as Claude Code's cwd for the session. Appears top-left of the chat page alongside the Display Name, and on every Directory dropdown entry. This never changes via a rename — only via unregister + re-register. | Agent only, via register/unregister. No UI path. |
| Connector Slug (real) | The segment in the URL /vm/ |
Agent only, via installer; never via UI. |
Confirm first: Read back: "I'll change the Server Display Name to X. The URL and bookmarks stay the same. OK to proceed?"
| Method | PUT |
| URL | https://spaiglass.xyz/vm/ |
| Body | { "displayName": "X" } |
| Auth | Owner-only — must be called from a session that owns the connector. The VM-side agent can curl this with the user's relay cookie or agent key. |
Consequences: Cosmetic. No session history lost. No re-sign-in needed. Dropdown updates on next page refresh.
Clear: Pass { "displayName": null } to revert to the raw slug.
Confirm first: Read back: "I'll change the Project Directory Display Name for
| Method | PUT |
| URL | http://127.0.0.1:8080/api/settings/project-display-name |
| Body | { "project": " |
| Auth | VM-local — no auth. Only callable from inside the VM. |
Consequences: Cosmetic. No session history lost. Dropdown + top-left label update on refresh.
Clear: Pass displayName:null to revert to the basename.
Confirm first: Read back: "I'll change the Project Directory Tab Name for
| Method | PUT |
| URL | http://127.0.0.1:8080/api/settings/project-directory-tab-name |
| Body | { "project": " |
| Auth | VM-local — no auth. |
Consequences: Cosmetic. Tab title updates on next navigation / refresh.
Clear: Pass tabName:null to fall back to the Display Name (and then to the basename).
Confirm first: Read back: "I'll add
| Method | POST |
| URL | http://127.0.0.1:8080/api/projects/register |
| Body | { "name": " |
| Auth | VM-local — no auth. |
Consequences: No session history touched. Dropdown updates on next page refresh. If a hidden entry for the same path existed before, prior session transcripts under ~/.claude/projects/
Confirm first: Read back: "I'll remove
| Method | POST |
| URL | http://127.0.0.1:8080/api/projects/unregister |
| Body | { "path": " |
| Auth | VM-local — no auth. |
Consequences: Dropdown entry disappears on next page refresh. Session transcripts under ~/.claude/projects/
Confirm first: Read-only. No confirmation needed.
| Method | GET |
| URL | http://127.0.0.1:8080/api/projects |
| Body | (none) |
| Auth | VM-local — no auth. |
Consequences: Returns { projects: [{ path, encodedName }] }. The path is the real filesystem directory; the dropdown shows it with any Display Name override applied on top.
Confirm first: Read-only. No confirmation needed.
| Method | GET |
| URL | http://127.0.0.1:8080/api/settings/project-display-names |
| Body | (none) |
| Auth | VM-local — no auth. |
Consequences: Returns { displayNames: { '
Confirm first: Read-only. No confirmation needed.
| Method | GET |
| URL | http://127.0.0.1:8080/api/settings/project-directory-tab-names |
| Body | (none) |
| Auth | VM-local — no auth. |
Consequences: Returns { tabNames: { '
Confirm first: Read-only, but needs an agent key (sg_...) — even to read THIS server's own Server Display Name, because there is no VM-local endpoint for it. If you do not already have one on disk, ask the user: "To read your Server Display Name (this server or any other) I need an agent key. You can mint one at https://spaiglass.xyz/dashboard (Agent Keys → New). Paste it here and I won't store it beyond this session." If they decline, you can still read Project Directory info (reads above) and tell the user the Server Display Name requires an agent key to fetch.
| Method | GET |
| URL | https://spaiglass.xyz/api/connectors |
| Body | (none) |
| Auth | Authorization: Bearer sg_ |
Consequences: Returns an array of connectors the user owns: [{ id, name (slug), displayName, customDisplayName, online, spaiglassVersion, ... }]. Each id can be passed to DELETE /api/connectors/
Confirm first: Read-only. An agent key (sg_...) is required to enumerate servers and to reach OTHER servers’ directories. If you do not already have one, mint one via Step 2 of this guide (POST /api/auth/token-exchange) — the same key works for every read below. Without a key you can only report the three VM-local reads for THIS server.
| Method | GET (multi) — do this in two passes |
| URL | Pass 1 — enumerate the fleet (one call):
https://spaiglass.xyz/api/connectors
→ gives you every connector’s slug (`name`) and Server Display Name (`displayName`).
Pass 2 — for EACH connector slug from Pass 1, call ONE combined endpoint that proxies through that VM’s connector tunnel and returns its directories with Display Name AND Tab Name already merged:
https://spaiglass.xyz/vm/ |
| Body | (none) |
| Auth | Both passes: Authorization: Bearer sg_ |
Consequences: Output format to send back to the user — one block per server:
Server:
Confirm first: Read-only audit. No confirmation needed to RUN. If you plan to act on any issue, stop and confirm with the human first — doctor reports issues, it does not auto-fix them.
| Method | GET |
| URL | Two scopes:
• Just THIS VM: http://127.0.0.1:8080/api/doctor (no auth, loopback)
• Whole fleet: https://spaiglass.xyz/vm/ |
| Body | (none) |
| Auth | VM-local: no auth. Fleet-wide: Authorization: Bearer sg_ |
Consequences: Response shape (VM-local): { ok, checkedAt, counts, issues:[{ id, code, severity, message, details, fixable, fixHint }] }. Fleet-wide wraps that as { servers:[{ server:{slug, displayName, online, role}, issues, counts }] }. Checks in v1: directory.missing (registered path gone from disk), directory.duplicate-case (two entries differ only in case), directory.home-root ($HOME registered as a project — usually accidental), displayName.orphan / tabName.orphan (override for a basename that isn’t registered anymore). severity is info | warn | error. Offline servers are returned with issues=[] and skipped='offline'. Report issues grouped by server, in severity order, and for each one read the `message` verbatim plus the `fixHint` — then ASK the human before touching anything.
Confirm first: The user adds servers by running the installer on the new machine. Do NOT try to add a server from this VM. Tell them: "Adding a server is a one-shot installer you run on the new machine itself. I can't do it from this VM. Open https://spaiglass.xyz/setup on the new machine and follow the installer step — or I can give you the exact one-liner for your OS."
| Method | N/A |
| URL | (installer only — runs on the target machine) |
| Body | (see the 'Install the connector' step in this guide) |
| Auth | Agent key + connector token generated during install. |
Consequences: Creates a new connector row in the relay DB. Does not touch this VM.
Confirm first: This is destructive on the relay side. Read back exactly: "Removing this server unlists it from your Server dropdown and ends this connector's ability to attach. Nothing on the VM itself is deleted. If you later reinstall with the same name you'll get a fresh connector — old session history stays on disk here regardless. Proceed?"
| Method | DELETE |
| URL | https://spaiglass.xyz/api/connectors/ |
| Body | (none) |
| Auth | Owner-authenticated via relay session / agent key. |
Consequences: Relay row removed; this VM can no longer attach as that connector. Local files, ~/.claude.json, and session transcripts are untouched. Users will need to sign in again only if their session was pinned to this connector.
If you are an agent and a setup step failed, match the symptom here before guessing. These are the real failure modes we have seen — fix the root cause, do not paper over it with retries or SQL.
Ask the human for a fresh PAT (or switch to Option B — send them to PUBLIC_URL/ and have them hand you the one-shot token). Do not retry with the same PAT expecting a different result.
Confirm the header is exactly `Authorization: Bearer sg_...` (no quotes, no leading 'Bearer:'). Run GET /api/auth/me with the same key — if that also 401s, the key is dead; mint a new one via token-exchange.
Use the existing connector from GET /api/connectors (the response body includes its id). If the name is wrong, PATCH it — do not create a second one.
Rename with only [A-Za-z0-9._-], starting alphanumeric, ≤100 chars. Examples that work: 'production-vm', 'dev.alice', 'Staging_2'.
Enable linger (`sudo loginctl enable-linger $USER`), verify the token in .env matches the one from POST /api/connectors, then `systemctl --user restart spaiglass` and re-check GET /api/connectors. If the token really is lost, DELETE the connector and POST a new one — do not edit the DB.
Check `systemctl --user status spaiglass`, start/restart it, and confirm PORT in ~/spaiglass/.env. The local backend is what hosts the project-register endpoint — it is NOT served by the relay.
This is the expected empty-state page — it carries a one-shot sg_ token for you to use in Option A. Complete Steps 2-4 to register their first VM; next sign-in will route them to chat.
Every project registered through this setup produces one file: architecture/architecture.json. That single file is the operational snapshot of what the project is doing — a human coming back after months away should be able to open it and rebuild full mental context without touching the code.
This is not a breadcrumb diagram. Shallow architecture files are worse than none: they create the illusion of documentation while hiding the risks they should be surfacing. A manifest that doesn’t list orphaned routes won’t help you find the 2024 signup form still live at /old-signup. A manifest that doesn’t list database schemas and their row counts won’t help you notice the tenant that stopped getting writes three weeks ago. A manifest that doesn’t list unauthenticated endpoints can’t be used as a security baseline.
Full instructions — and they are long by design — are at:
→ https://spaiglass.xyz/architecture-manual
Read the whole thing before you write a single field. The manual covers: the sourcing rules (never use docs as source-of-truth), the JSON schema, the site-map requirements (every route, every form, every orphan), the database-layer requirements (schemas, tables, grants, row counts), the security surface (unauthenticated endpoints, exceptions with rationale, open CVEs), and the ASCII-art rendering conventions the SpAIglass viewer uses.
The manual includes a self-check list at the end. An architecture.json that fails any of the checks is not done.
⚠ Agent directive: do not produce a partial manifest. If you cannot verify a field (a process is unreachable, a database rejects your connection, a repo is unreadable), write "unknown" with a statusSource explaining why. An honest unknown is the correct output when the ground truth can’t be observed.
Agents and scripts can fetch /api/setup for the same content as JSON.