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)

  1. Joe opens https://skills.snappy.ai/invite/<code> in any browser.
  2. The page renders HTML with a single copyable command:
   SNAPPY_MASTER_KEY=<tenantKey> npx snappy-os
  1. Joe copies it (the page has a copy button) and pastes into his

terminal. Requires node >= 20.

  1. First run is dry — prints the plan, writes nothing. Joe re-runs

with SNAPPY_BOOTSTRAP_APPLY=1 prepended to actually wire.

  1. The bootstrapper:

Worker (program.md, state/, bin/).

  1. Joe verifies:
   snappy-os doctor    # 21/21 lints green
   snappy-os status    # local ↔ remote manifest diff
  1. First hook fire writes a row to state/log/evals.ndjson with

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:

  1. 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.

  1. 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.

  1. 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

SideFilePurpose
Robertstate/bin/invite-mint.tsmint script — POST /invite/mint
Robertstate/log/invites.ndjsonlocal audit log of every mint
Joebin/install.js (npx-staged)bootstrap entrypoint
Joe.bootstrap-report.jsonpost-bootstrap state summary
Joe~/.claude/settings.jsonwired by bin/wire-hooks.js
Workersrc/install.ts::handleInviteredeem page + mint endpoint
WorkerINVITES KV{code → {tenantKey, used, mintedAt}}
Workersrc/auth.ts::canAccessFileper-request tenant gate

Gotchas

If Joe closes the browser before copying, re-mint with a fresh code.

"permission", run npm login first.

key; SNAPPY_MASTER_KEY env var is the admin master key used to authenticate the mint POST. Different values, different roles.

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.