~/headspin $ ▍

HeadSpin Control_

A Claude Code plugin for driving real devices · HAR-verified · live-validated

v1.2.0 MIT no mocks, real API only

// what it is

Your device farm, driven from the terminal.

  • Authenticate to a HeadSpin environment via browser sign-in
  • Enumerate & lock devices across iOS, Android, Roku, Cast, Fire TV
  • Connect & control through REST, Appium, socket.io, and Janus
  • Explore & report — autonomous bug hunting, standardized reports
4 real surfaces
  • REST /v0
  • Appium wd/hub
  • socket.io control
  • Janus WebRTC

// footprint

One plugin, six moving parts.

14
Skills
10
Commands
2
Agents
3
Hooks
20
MCP Tools
34 live
Devices Validated
01

// section

Architecture

commands → skills → planes

// the five planes

Two token classes, five surfaces.

flowchart TD
  CMD["/headspin commands"] --> SK["14 Skills"]
  SK --> MCP["MCP server
20 REST tools"] SK --> CM["connection-manager"] SK --> SM["session-manager
lock / unlock"] MCP -->|"Bearer token"| REST[("REST /v0")] SM -->|"Bearer token"| REST SK -->|"token in path"| APP["Appium :70xx"] CM -->|"?access_token= JWT"| SIO["socket.io :23100+"] CM -->|"body token + pin"| JAN["Janus :150xx H264"] classDef live fill:#0d2818,stroke:#50fa7b,color:#c8f5d6; classDef gate fill:#2a2410,stroke:#f1c453,color:#f5edd0; classDef core fill:#12161e,stroke:#62d0ff,color:#c8d6e5; class REST,APP live; class SIO,JAN gate; class CMD,SK,MCP,CM,SM core;
Plugin components Live-verified (account token) Browser-credential-gated
entry points
  • :setup :login
  • :devices :connect
  • :control :capture
  • :explore :report
  • :sessions :waterfall

// components

Skills do the work; you type the verbs.

  • Auth & discovery — login, list-devices, session & connection managers
  • Connect & control — iOS (Appium xcuitest), Android (socket.io input.*), Roku
  • Capture & report — full record lifecycle, Waterfall reports, artifact download
  • Explore & report — device-explorer + bug-reporter agents
  • MCP + hooks — 20 stdlib REST tools, token-safety & auto-release guards
02

// section

The Auth Model

5 real carriers · 1 fabricated

// credential carriers

Who authenticates what.

PlaneCredentialCarrierStatus
REST api-dev /v0API tokenAuthorization: BearerLive
Appium wd/hubAPI token32-hex token in URL pathLive
socket.io Android / Cast / Fire TVIdentity JWT?access_token=Gated
iOS control WS :5002Identity JWT?jwt=Gated
Janus H264 mediaSession secretbody token + watch pinGated
orgkey:tokenFabricated
The spec asserted orgkey:token. The capture proved zero such headers across 350 HAR entries + 141 docs.
— HAR forensics · auth correction
03

// section

Validation

real system · no mocks · latest full pass 2026-07-05

// mcp surface · 1 of 2 · live 2026-07-05

20 / 20 tools — the capture lifecycle, run for real.

ToolLive result Galaxy S10 · Toronto · session e8024cb0
hs_login_details200 · org/env probePass
hs_list_devices34 devices · 33 online · 5 platformsPass
hs_adb_lockstatus:0 · R38N70234FA reservedPass
hs_adb_shellgetpropSM-G973W · swipes/taps rc=0Pass
hs_start_capturesession active · video onPass
hs_stop_capture"Video uploaded to …e8024cb0….mp4"Pass
hs_session_download6,899,682-byte MP4 · 512×1184 h264Pass
hs_adb_unlockstatus:0 · device releasedPass

// mcp surface · 2 of 2 · live 2026-07-05

…then read the report of what just happened.

ToolLive result same session + real iPhone 11
hs_analysis_statusdone · report pipeline finishedPass
hs_session_issuesWaterfall card: "Audio Too Quiet" −30.8 LUFSPass
hs_session_timestampsstart/end/complete epochs · 79.9 s spanPass
hs_session_video_metadata23.105 fps · 80,111 ms · audio 1chPass
hs_session_timeseries_info + download16 series · screen_change → 67,767-byte CSVPass
hs_session_tls_exceptions · hs_list_sessions{} clean · session listed, ended, no errorPass
hs_idevice_info · hs_installer_listiPhone12,1 · iOS 14.4.2 · 6 appsPass
hs_lock_device · hs_unlock_devicestatus:0 both ways — iOS REST lock proven livePass

// worked example · exactly as run 2026-07-05

Record a real device in six calls.

/headspin:capture — under the hood
hs_adb_lock       {"device_id": "R38N70234FA", "timeout": 30}
→ {"status": 0, "message": "R38N70234FA@dev-ca-tor-0-proxy-2-lin… locked."}
hs_start_capture  {"device_address": "R38N70234FA@dev-ca-tor-0-proxy-2-lin.headspin.io"}
→ {"session_id": "e8024cb0-788e-11f1-8491-da3445a29211", "state": "active"}
hs_adb_shell      {"device_id": "R38N70234FA", "command": "am start -a android.intent.action.VIEW -d https://www.youtube.com"}
hs_adb_shell      {"device_id": "R38N70234FA", "command": "input swipe 500 1500 500 500 300"}
hs_stop_capture   {"session_id": "e8024cb0-…"}
→ {"msg": "Video uploaded to https://api-dev.headspin.io:443/v0/sessions/e8024cb0-….mp4"}
hs_adb_unlock     {"device_id": "R38N70234FA"}   # always release

Result: an 80-second, 6.9 MB h264 recording of the drive — pulled to disk with hs_session_download.

// worked example · report retrieval

Then ask what the platform saw.

/headspin:sessions + /headspin:waterfall
hs_analysis_status           {"session_id": "e8024cb0-…"}
→ {"status": "done", "message": "Analysis completed for the session."}
hs_session_issues            {"session_id": "e8024cb0-…"}
→ {"Audio Too Quiet": {"Integrated Loudness (LUFS)": ["-30.8"]}}
hs_session_timeseries_info   {"session_id": "e8024cb0-…"}
→ 16 series: impact, network_in/out, download_rate, blurriness, screen_change, …
hs_session_timeseries_download {"session_id": "e8024cb0-…", "key": "screen_change"}
→ {"saved_to": "…/screen_change.csv", "bytes": 67767}
hs_session_download          {"session_id": "e8024cb0-…", "ext": "mp4"}
→ {"saved_to": "…/session-e8024cb0.mp4", "bytes": 6899682, "content_type": "video/mp4"}

Every line above is a captured live response — the issue card even flagged the muted YouTube tab.

before · the bug

A bare lock_id read as "held".

13 / 33 devices

…sit at rest with a non-null lock_id and no owner. Keying reservation off lock_id falsely marks every idle device busy.

after · the fix

The hold is owner_email / session_id.

Proven by a decisive adopt → release cycle: account /v0/devices/lock set the owner; /v0/devices/unlock cleared it — lock_id never moved.

Skill + MCP docstring corrected. Device left free.

// control planes, exercised live

Reachable — and honestly gated.

  • socket.io :33100 — WS upgrade HTTP 101, then app-layer "Failed to decode jwt access_token."
  • Janus :15042 — reachable; create403 wrong/missing secret
  • Both reject the account JWT correctly — they want the browser identity credential, not a bug
verdict
  • network: reachable
  • TLS: valid
  • transport: authed
  • app cred: browser-gated

// the one thing to understand

Two JWT classes — the API token mints only one.

lease JWT ≠ identity JWT

POST /v0/jwt/permissions mints a lease JWT (sub · aud · exp). The control planes validate an identity JWT (name · email · plain_email) — a browser-login artifact, re-minted on lock with the lock UUID as the email.

See "Failed to decode jwt access_token."? Wrong credential class. Fix: re-run /headspin:login to capture the browser identity JWT. A real, verified boundary.

04

// section

Get Started

install → login → drive

// first run

Four steps to a driven device.

01
Install
Add the dev marketplace, install the plugin, restart Claude Code.
/plugin install
02
Log in
Browser sign-in; API token to keychain + identity JWT for the control planes.
/headspin:login
03
List
See the live roster — the validation run returned 34 real devices.
/headspin:devices
04
Drive
Lock, control, and auto-release on session end — no stranded devices.
/headspin:connect

// shipped · public · MIT

Live-validated,
no mocks.

▸ github.com/krzemienski/headspin-control

v1.2.0 MIT plugin validate ✔ 20/20 MCP tools live full capture lifecycle proven