Headspin login
Authenticate the HeadSpin Control plugin to a HeadSpin environment, persist the bearer token in the OS keychain via ${user_config.api_token}, and surface the resolved environment (UI host, API host, per-platform control hosts/ports) for downstream skills. REST calls use Authorization Bearer; the websocket control/stream surfaces use ?access_token=/?jwt= query params, not Bearer. Invoke when the user runs /headspin:login, when a downstream skill reports "401 Unauthorized", or when the user asks to switch HeadSpin environments.From its SKILL.md
npx -y skills add krzemienski/headspin-control --skill headspin-loginAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
8.2 KB, as published. Nobody here has run it
headspin-login
When to use
- User runs
/headspin:login(first time or switching environments). - A downstream skill (
headspin-list-devices,headspin-connect-*) returns 401 or 403 and you need to refresh the token. - The user pastes a new API token and asks to apply it.
Inputs
The plugin manifest (.claude-plugin/plugin.json) declares the inputs via userConfig. The skill reads them at runtime through ${user_config.<KEY>} substitution:
| Key | Source | Purpose |
|---|---|---|
api_host | userConfig.api_host | REST API base (e.g. https://api-dev.headspin.io). |
ui_host | userConfig.ui_host | Web UI base (e.g. https://ui-dev.headspin.io). |
api_token | userConfig.api_token (sensitive) | Bearer token, stored in the OS keychain by Claude Code. |
tunnel_host | userConfig.tunnel_host | Default region host for per-device websockets. |
tunnel_port | userConfig.tunnel_port | Default region port for per-device websockets. |
Never read the token literal from a HAR file, a .env, or a prompt. The plugin's sensitive: true flag sends it to the keychain, not settings.json.
Workflow
-
Read config via env — every downstream command consumes these as
CLAUDE_PLUGIN_OPTION_<KEY>(see plugins-reference.md, "Environment variables" section). To confirm the values are set, run:echo "API: $CLAUDE_PLUGIN_OPTION_API_HOST UI: $CLAUDE_PLUGIN_OPTION_UI_HOST Tunnel: $CLAUDE_PLUGIN_OPTION_TUNNEL_HOST:$CLAUDE_PLUGIN_OPTION_TUNNEL_PORT"If any required value is empty, stop and tell the user to run
/pluginand enable the plugin with the missing config. -
Probe the environment (unauthenticated) — call
GET ${CLAUDE_PLUGIN_OPTION_API_HOST}/v0/logindetails. This is an unauthenticated org/env config probe (it takes optional?org_id=&hostname=query params and returns{"email": true, "organization_name": "…"}); a200confirms the API host is reachable and the org config, but it does NOT prove the API token is valid — the HAR captures no auth header on this request. Do not treat a200here as "token accepted".Validate the token (Bearer-authed) — to actually exercise the
Authorization: Bearer <api_token>header, call a REST endpoint that requires it. The one Bearer-authed GET proven in the 2026-07-02 raw capture is the per-device iOS info route:curl -sS -o /dev/null -w "%{http_code}" \ -H "Authorization: Bearer ${CLAUDE_PLUGIN_OPTION_API_TOKEN}" \ "${CLAUDE_PLUGIN_OPTION_API_HOST}/v0/idevice/{udid}@{proxy-host}.headspin.io/info?json"A
200means the token is accepted; a401means the token is revoked or the API host is wrong — surface the exact status and body so the user can correct it. If no iOSdevice_addressis known yet, skip this and let the firstheadspin-list-devicescall surface a401instead. (The RESTauthorizationheader carrier is proven by CORS preflight,raw-forensics/auth-inventory.md§1a; there is noorgkey:tokenheader and no/v0/devices/keysroute in the capture.) -
Resolve the actual tunnel host — the 2026-07-02 capture shows the per-device WebSocket host is not the UI host. The UI host is
ui-dev.headspin.io; the socket.io control //d/screen host isdev-ca-tor-0.headspin.io, and iOS control lives on a different region host (dev-in-blr-0.headspin.io:5002). The exact host + port for a given device must be resolved at connect time from the device record'sdisplay.url(Android/Cast/Fire TV) or the iOS control endpoint — seeheadspin-connect-ios/headspin-connect-android. Do not assume${CLAUDE_PLUGIN_OPTION_TUNNEL_HOST}is correct for every device. -
Persist the env — for this session only, export the resolved values to a temp file the other skills can source:
mkdir -p /tmp/headspin-control cat > /tmp/headspin-control/env.sh <<EOF export HEADSPIN_API_HOST="$CLAUDE_PLUGIN_OPTION_API_HOST" export HEADSPIN_UI_HOST="$CLAUDE_PLUGIN_OPTION_UI_HOST" export HEADSPIN_TUNNEL_HOST="$CLAUDE_PLUGIN_OPTION_TUNNEL_HOST" export HEADSPIN_TUNNEL_PORT="$CLAUDE_PLUGIN_OPTION_TUNNEL_PORT" # The bearer token is read inline from $CLAUDE_PLUGIN_OPTION_API_TOKEN; # never written to disk. EOF chmod 600 /tmp/headspin-control/env.shThe token itself is never persisted to disk. Every downstream skill reads it from the env var, not from the temp file.
-
Surface the resolved environment — print a single-line summary the user can copy:
Logged in to HeadSpin dev. API: api-dev.headspin.io UI: ui-dev.headspin.io Android control host: dev-ca-tor-0.headspin.io (ports 23100/27100/33100/34100) iOS control host: dev-in-blr-0.headspin.io:5002Do not compute a device count here from a
/v0/devicescall — that REST route is not exercised in the capture and is not a real inventory carrier (API-CONTRACT.mdDL-6). Device inventory has two real carriers, surfaced byheadspin-list-devices: iOS via RESTidevice/info, and Android/Cast/Fire TV via the socket.iodevicelistevent on the control ports. Point the user at/headspin:devicesfor the roster.
Evidence
- REST auth carrier:
e2e-evidence/headspin-forge-260702/raw-forensics/auth-inventory.md§1a — REST/v0/…is guarded by anAuthorization: Bearer <JWT>header, proven by the CORS preflight (access-control-request-headers: authorization→ serveraccess-control-allow-headers: Authorization,Content-Type). Noorgkey:token, no cookie auth. logindetailsis unauthenticated:e2e-evidence/headspin-forge-260702/har-forensics/API-CONTRACT.md§1 "logindetails — what it actually is" (GET /v0/logindetails?org_id=&hostname=→ 200 org-config probe; no auth header observed).- Bearer-authed REST GET (token validation):
raw-forensics/auth-inventory.md§3 (GET /v0/idevice/{udid}@{host}/info?json→ 200 withAuthorization: Bearer). - Websocket auth shape:
har-forensics/API-CONTRACT.md§1 AUTH-1/AUTH-2 (socket.io control +/d/screen stream carry the JWT as?access_token=query param — not a Bearer header); iOS control WS carries it as?jwt=(raw-forensics/auth-inventory.md§1b). /v0/devices*is NOT a real inventory route:API-CONTRACT.mdDL-6.- Token storage:
plugins-reference.md:535-572(sensitive: trueroutes to the OS keychain). - Variable substitution contract:
plugins-reference.md:637-670(${CLAUDE_PLUGIN_ROOT}, ${CLAUDE_PLUGIN_DATA}, ${user_config.*}).
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
CLAUDE_PLUGIN_OPTION_API_TOKEN empty | Plugin not enabled or token not set in /plugin UI | Re-run /plugin -> Configure headspin-control, paste the token. |
401 Unauthorized from /v0/idevice/…/info?json (or any Bearer REST call) | Token revoked or wrong host | Check the user account at the UI host; if the token is valid, confirm api_host matches the host the token was issued against. |
403 Forbidden from a Bearer REST call | Token is valid but lacks the required permission for that route | Org admin needs to grant the permission, or the user needs to be promoted. |
/v0/logindetails returns 200 but downstream REST calls 401 | logindetails is unauthenticated — a 200 there never proved the token | Validate the token against a Bearer-authed route (step 2), not against logindetails. |
Tunnel host is not ${CLAUDE_PLUGIN_OPTION_TUNNEL_HOST} | Device is in a different region (iOS control is dev-in-blr-0:5002; Android control is dev-ca-tor-0:{ctrl}) | Resolve the host/port at connect time from the device record (display.url) or the iOS control endpoint; do not overwrite the plugin default. |