Drive real devices on a HeadSpin farm from inside Claude Code — authenticate, enumerate and lock devices,
connect and control iOS / Android / Roku, run autonomous exploration, and file standardized bug reports.
Every call hits the real HeadSpin API through REST, Appium,
socket.io, and Janus surfaces. No mocks anywhere.
commands → skills → planes
Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit · ⛶ opens full size.
skills are model-invoked · commands are entry points
headspin-login — browser sign-in, keychain tokenheadspin-list-devices — REST roster + socket.io devicelistheadspin-session-manager — lock / unlock / leaseheadspin-connection-manager — WS lifecycleheadspin-connect-ios · -android · -rokuheadspin-control-ios · -roku:5002 touch pathsinput.* eventsheadspin-capture — lock → record → drive → stopheadspin-reports — issue card, status, time seriesheadspin-waterfall — HAR / MP4 / device-log downloadheadspin-explore-bugs — autonomous driveheadspin-bug-report — standardized reportdevice-explorerbug-reporter/headspin:setup · :login/headspin:devices · :connect/headspin:control · :capture/headspin:explore · :report/headspin:sessions · :waterfallStdlib-only stdio JSON-RPC 2.0. Twenty REST tools — device roster, lock/shell, full capture lifecycle, Waterfall reports, time series, artifact downloads — every one a real HTTPS call, no stubbed responses. Errors surface the live HTTP status + body.
Three guards in hooks/hooks.json: token-safety before device commands, session release on Stop / SessionEnd, and interaction logging.
5 carriers · orgkey:token is fabricated
| Plane | Credential | Carrier | Source | Status |
|---|---|---|---|---|
REST api-dev.headspin.io/v0 |
API token | Authorization: Bearer |
User Settings → API Tokens | Live-verified |
| Appium W3C wd/hub session | API token | 32-hex token in URL path :70xx/v0/<token>/wd/hub |
same API token | Live-verified |
| socket.io control Android / Cast / Fire TV | Identity JWT claims name·email·plain_email |
?access_token=<JWT> |
browser login re-minted on lock → <lockUUID>@lock.hspin.io |
Browser-gated |
iOS control WS :5002 |
Identity JWT | ?jwt=<JWT> |
browser login | Browser-gated |
| Janus WebRTC H264 media | 16-char session secret | body token + watch pin |
per-session, UI-issued | Browser-gated |
orgkey:token header |
— | — | Asserted in the original spec | Fabricated · 0 in 350 HAR + 141 docs |
20 / 20 exercised over real JSON-RPC stdio · latest full pass 2026-07-05
| Tool | REST call | Live result | |
|---|---|---|---|
hs_login_details | GET /v0/logindetails | 200 · org/env probe (unauthenticated) | Pass |
hs_list_devices | GET /v0/devices | 34 devices · 33 online · android/ios/roku/safari/tizentv | Pass |
hs_idevice_info | GET /v0/idevice/{addr}/info | lockdownd dump · iPhone12,1 · iOS 14.4.2 | Pass |
hs_installer_list | GET .../installer/list | 6 apps · io.headspin.webdriveragent | Pass |
hs_lock_device / hs_unlock_device | POST .../lock|unlock | status:0 both directions on a real iPhone 11 — route proven live | Pass |
hs_adb_lock / hs_adb_unlock | POST /v0/adb/{id}/lock|unlock | status:0 · Galaxy S10 reserved & released | Pass |
hs_adb_shell | POST /v0/adb/{id}/shell | getprop → SM-G973W · swipes, taps, am start all rc=0 | Pass |
hs_start_capture / hs_stop_capture | POST|PATCH /v0/sessions | session e8024cb0 recorded 80 s · "Video uploaded" confirmed | Pass |
hs_list_sessions | GET /v0/sessions | session listed first · state=ended · error_code=null | Pass |
hs_analysis_status | GET .../analysis/status | done · "Analysis completed for the session." | Pass |
hs_session_issues | GET .../analysis/issues | Waterfall card: "Audio Too Quiet" −30.8 LUFS | Pass |
hs_session_timestamps | GET .../timestamps | capture start/end/complete epochs · 79.9 s span | Pass |
hs_session_video_metadata | GET .../video/metadata | 512×1184 h264 · 23.105 fps · 80,111 ms · audio 1ch | Pass |
hs_session_timeseries_info / _download | GET .../timeseries/… | 16 series · screen_change CSV 67,767 bytes to disk | Pass |
hs_session_download | GET /v0/sessions/{sid}.{ext} | 6,899,682-byte MP4 to disk · HAR 404 surfaced verbatim (no network capture — correct) | Pass |
hs_session_tls_exceptions | GET .../tlsexceptions | {} · no TLS-pinning capture gaps | Pass |
hs_session_timeseries_info → up to 31 signals · _download → CSV
Every capture session records a set of device time series — "what occurred on the device" as
measurable signals over the session timeline. hs_session_timeseries_info discovers what
was captured; hs_session_timeseries_download streams any one series to disk as CSV
(it returns {saved_to, bytes}, never the raw rows). The set scales with what the
capture recorded: a live video session returned 31 series; a network-only session
returned 6.
video_quality_mos · screen_changeblockiness · blurriness · brightnesscolorfulness · contrast · downsampling_indexpage_content · screen_rotationdownload_rate · throughput · net_cpuconcurrency · connectionsnetwork_in_bytes (+ _rvi / _total / _packets)network_out_bytes (+ _rvi / _total / _packets)signal_wifi_rssibattery_currentbattery_energy_drain (+ _percent)memory_used · memory_used_percentimpact · impact_kde| Live download — real byte counts | Series | Bytes | |
|---|---|---|---|
| session e8024cb0 · Galaxy S10 · 80 s YouTube drive | screen_change | 67,767 | CSV to disk |
| session 968f82b2 · video-quality | video_quality_mos | 8,800 | CSV to disk |
| session 968f82b2 · memory | memory_used | 583 | CSV to disk |
| session 968f82b2 · network total | network_in_bytes_total | 97 | CSV to disk |
full passes 2026-07-02 / 07-03 / 07-05 · real system, no mocks
All 20 tools driven end-to-end against live api-dev. The 2026-07-05 pass ran a complete capture lifecycle on a real Galaxy S10 — lock → record → drive YouTube → stop → 6.9 MB MP4 + issue card + 16 time series pulled to disk — and proved the iOS REST lock/unlock route live on an iPhone 11.
lock → reserved → unlock → free, confirmed on the roster. Found + fixed a defect: a bare lock_id is an ambient idle marker (13/33 devices carry one at rest); the real hold is owner_email / session_id.
socket.io + Janus are network-reachable and TLS-valid; socket.io even completes the WS upgrade (HTTP 101). Both correctly reject the account JWT — they require the browser-login identity credential.
claude plugin validate passes; the plugin installs and enables clean (v1.2.0, user scope). All 14 skills, 10 commands, 2 agents, hooks, and the 20-tool MCP server auto-discover.
two credentials, not one
POST /v0/jwt/permissions mints a lease JWT (claims sub · aud · exp). The
control/streaming planes validate a different identity JWT (claims name · email · plain_email),
obtained during the /headspin:login browser sign-in and re-minted on lock with the lock UUID as the email.
If a control WebSocket ever answers "Failed to decode jwt access_token.", the wrong credential class was
used. Fix: re-run /headspin:login so the browser-minted identity JWT is captured. This is a real, verified
boundary — proven live, not a plugin bug.
full guides in docs/
The plugin ships its own dev marketplace. Point Claude Code at the directory, install, and restart.
/plugin marketplace add /path/to/headspin-control /plugin install headspin-control@headspin-dev
/headspin:login opens your environment's UI so you can sign in and copy your API token (and capture the browser identity JWT for the control planes). The token is stored in the OS keychain.
List the roster — if you see your device count, the plugin is wired to your environment.
/headspin:devices # → real roster; validation run returned 34 devices
/headspin:connect locks a device and opens its control session; /headspin:control drives it; the Stop / SessionEnd hooks auto-release your lock so you never strand a device.