Invite walkthrough
How a new user goes from "I heard about snappy-os" to "my tenant shows up in Robert's YOUR TENANT panel." Every step, both sides of the handshake.
The story in one line
Robert mints a single-use invite → sends the URL → Joe opens it in a browser → Joe pastes the npx snappy-os line into a terminal → Joe's machine bootstraps + registers → Joe's tenant becomes visible in the global tenants count, and if Joe is added to Robert's tenant, in Robert's YOUR TENANT panel.
Side A — Robert (admin mints the invite)
cd ~/projects/snappy-os
# Mint an invite for Joe. --tenant is the SNAPPY_MASTER_KEY Joe will use.
# Minting a NEW tenant: generate a fresh tenant key first (via
# state/bin/sync/tenant-id.sh's companion tool, or accept a key Joe
# already has).
SNAPPY_MASTER_KEY="$(grep ^SNAPPY_MASTER_KEY= .env.cache | cut -d= -f2- | tr -d '"')" \
npx tsx state/bin/invite-mint.ts \
--tenant="<joes-tenant-key>" \
--label="joe@example.com — frontend contractor"
Output (stdout, one line of JSON):
{"invite_code":"ABC123...","invite_url":"https://skills.snappy.ai/invite/ABC123...","tenant_id":"abc123…wxyz","expires_at":"2026-04-25T19:47:00.000Z","label":"..."}
Send Joe the invite_url. That's the whole share payload — one link.
What Robert keeps: a row in state/log/invites.ndjson with the mint timestamp, label, and Worker-returned status.
What the Worker keeps: a row in the INVITES KV namespace keyed by invite_code, value {tenantKey, mintedBy, mintedAt, used: false}, TTL 7 days.
Side B — Joe (redeems the invite)
- Joe opens
https://skills.snappy.ai/invite/<code>in any browser. - The page renders HTML with a single copyable command:
SNAPPY_MASTER_KEY=<tenantKey> npx snappy-os
- Joe copies it (the page has a copy button) and pastes into his
terminal. Requires node >= 20.
- First run is dry — prints the plan, writes nothing. Joe re-runs
with SNAPPY_BOOTSTRAP_APPLY=1 prepended to actually wire.
- The bootstrapper:
- Detects runtimes on PATH.
- Creates
~/projects/snappy-os/and pulls canonical from the
Worker (program.md, state/, bin/).
- Wires
Stop+SessionStarthooks for every runtime on PATH. - Syncs
CLAUDE.md→AGENTS.md/GEMINI.md/.cursorrules/etc. - Installs the 6-hourly
snappy-os doctorcron self-test. - Runs smoke tests (Worker reachable, skills visible per runtime).
- Joe verifies:
snappy-os doctor # 21/21 lints green
snappy-os status # local ↔ remote manifest diff
- First hook fire writes a row to
state/log/evals.ndjsonwith
machine_id → that's the first moment Joe is visible as a tenant.
How Robert knows it worked
Three observable signals, in order of arrival:
- Invite KV marked used (within seconds of Joe opening the URL):
re-opening the same link shows "This invite was already redeemed." You can verify the mint log line has "worker":"ok" and compare.
- Tenant count increments (next time Robert opens TUI + press
4):
the [GLOBALLY · tenants] line goes from N to N+1. Worker /_status returns the updated count.
- Joe's machine appears in
YOUR TENANT(only if Robert added
Joe's tenant_id to his own tenant's grants — i.e. they're on the same team): the [YOUR TENANT · machines] panel shows Joe's hostname with ping 4m ago once Joe's first hook fires.
If signal #1 fires but #2 doesn't within 10 minutes, Worker /_status is stale or Joe's bootstrap didn't complete. Check ~/projects/snappy-os/.bootstrap-report.json on Joe's machine.
If #2 fires but #3 doesn't, tenants are isolated by design — that's correct behavior for an unrelated tenant. To share, Robert adds Joe's tenant_id to his own tenant's grants via the Worker admin path.
Key files, both sides
| Side | File | Purpose |
|---|---|---|
| Robert | state/bin/invite-mint.ts | mint script — POST /invite/mint |
| Robert | state/log/invites.ndjson | local audit log of every mint |
| Joe | bin/install.js (npx-staged) | bootstrap entrypoint |
| Joe | .bootstrap-report.json | post-bootstrap state summary |
| Joe | ~/.claude/settings.json | wired by bin/wire-hooks.js |
| Worker | src/install.ts::handleInvite | redeem page + mint endpoint |
| Worker | INVITES KV | {code → {tenantKey, used, mintedAt}} |
| Worker | src/auth.ts::canAccessFile | per-request tenant gate |
Gotchas
- Invite is single-use. Reload redeem page = "already redeemed."
If Joe closes the browser before copying, re-mint with a fresh code.
- TTL is 7 days. After that the KV row auto-expires. Mint fresh.
- Expired npm auth token blocks publish. If
npm publish404s with
"permission", run npm login first.
- Tenant key != admin master key.
--tenant=takes Joe's tenant
key; SNAPPY_MASTER_KEY env var is the admin master key used to authenticate the mint POST. Different values, different roles.
- Dry-run false positives on the published 0.1.0 package. If Joe
reports ERROR: cli.js missing in dry mode, they're on 0.1.0 — push them to npx snappy-os@latest once 0.2.0 is published. Apply mode still works on 0.1.0 because the checks fire in order.
Why this shape
One link, one command. No account, no signup form, no credential-pasting dance. The invite IS the credential handoff, and it's single-use so a leaked URL only burns once.
Joe never sees DO Spaces creds. Worker is the only ingress to DO. Joe's machine only has SNAPPY_MASTER_KEY, which the Worker trades for per-tenant file access.
Framework lives in git, state lives per-tenant. Joe pulls framework through the Worker (canonical). Joe's evals/frictions stay on Joe's machine + git log. Robert's don't leak to Joe; Joe's don't leak to Robert unless they share a tenant.