Imported from sebastientaggart/Deckhand (
AGENTS.md). Install upstream withnpx skills add sebastientaggart/Deckhand. Copyright stays with the author.
Deckhand – Development Guidelines
Deckhand turns one Stream Deck button into a real action for AI coding workflows: jump to the Claude session that needs input, show plan usage on a button, fire a project-startup macro. The service runs locally on 127.0.0.1, talks to OpenDeck today and the official Elgato Stream Deck next, and is built for the specific problem of "I have multiple Claude / Cursor sessions and want tactile control over them" — not as a generic automation platform.
If you're touching the code, the principles below capture how the project is shaped today.
Core Principles
-
AI coding agents are the point, not an example. The "agent" abstraction exists because Claude Code and Cursor sessions are the first-party use case. Everything else (signals, custom plugins, raw state) is supporting cast.
-
Thin client, smart core. The Stream Deck (or OpenDeck) is a button surface for display and presses. Orchestration, state, decisions, and lifecycle live in the local Deckhand service. Clients should never re-implement business logic.
-
Bidirectional by default. The core emits events over WebSocket; clients subscribe rather than poll. State changes stream to enable indicator buttons that update without round-trips.
-
Local-first. The service runs on the developer's machine. Prefer local APIs, local files, and AppleScript over cloud round-trips. No remote execution, no cloud orchestration, no opt-in telemetry in core.
-
Composable but specific. Buttons trigger named actions (
agents.focus_next_pending,agent.start,ui.open_url). Signals ingest external events. State keys drive indicator buttons (usage.claude_code.session,agents.pending_input_count). When in doubt about whether to ship something generic or specific, ship specific — the project's value is in solving the AI-coding-agent case well, not in being a marketplace.
Architecture Snapshot
- Service: FastAPI HTTP + WebSocket on
127.0.0.1:18765by default. On startup Core writes the bound URL to~/.config/deckhand/runtime.tomlso OpenDeck and the CLI can find a non-default port. - Event bus: in-memory pub/sub with a versioned event envelope (
type,source,payload,ts,version). In-process listeners and WebSocket subscribers receive the same stream. - State store: in-memory key/value with optional TTL; emits
state.changed. Optional JSON persistence across restarts. - Actions: named async handlers (
ActionRegistry). Built-ins cover agent lifecycle (agent.start/cancel/input), UI hints (ui.open_url,ui.focus_cursor_agent), and the pending-input focuser (agents.focus_next_pending). - Signals: named webhook receivers (
SignalRegistry). Used by hook-driven integrations (Claude Code / Cursor hooks post here). - Agents:
AgentBasesubclasses that reflect external session state (Claude Code, Cursor). Created on demand from hook events; cleared onSessionEnd. Not framework objects. - Focusers: per-agent async callables that bring an external window/tab to the foreground. Today: iTerm via AppleScript. Cursor and browser are tracked separately (#24, #25).
- Plugins: local Python modules loaded via
deckhand.plugins.loader. Each plugin'sregister()gets aPluginRegistryand can register actions, signals, event-bus listeners, and shutdown hooks. Background tasks (pollers, watchers) MUST register a shutdown hook viaregistry.on_shutdown(coro)so the FastAPI lifespan tears them down cleanly. - Bindings: button-to-action mappings live in OpenDeck profiles, not in Core config.
Event Envelope
type string (e.g. "state.changed", "agent.status_changed", "ui.open_url")
source {kind, id} attribution
payload dict
ts unix timestamp
version schema version ("1.0")
Use build_event(...) / build_error_event(...) from deckhand.orchestrator.events. Never construct envelopes by hand.
Client Expectations
- Clients open URLs / native apps themselves; the core emits
ui.open_urland similar but does not shell out for the client. - Discovery:
GET /actions,GET /signals,GET /agents,GET /catalog/state_keys. Data Widget state-key options come from[catalog.state_keys]inconfig.toml(local file and/or Core). The Property Inspector sorts entries bydropdown_label(menu name, not the key face). Optionalformatandbutton_titleon a catalog row are applied when the key is selected;button_titleis the first line drawn on the key. Clients find Core's listen URL from~/.config/deckhand/runtime.toml(written on startup) before falling back to[client]/[service]/deckhand.env. - Live updates: subscribe to
/eventsWebSocket and update indicators fromstate.changed.
Scope (deliberate non-goals)
- macOS-first. The iTerm focuser is AppleScript-based. Linux and Windows ports are interesting but unscoped.
- OpenDeck-first. Elgato Stream Deck plugin port is planned; until it lands, OpenDeck is the only client.
- Concrete usage adapters, not a federation layer. Today: Claude Code (in-process OAuth), Antigravity (in-process
agyKeychain OAuth → Cloud Code Gemini session/week), and Cursor (local IDE JWT → Spending dashboard pools). Do not add another quota provider until there is a concrete need and a thin adapter to test against. Do not vendor caut/CodexBar or reintroduce local JSONL burn analytics in core — point power users at companion CLIs for historical cost dashboards. - No multi-user, no cloud sync, no remote agents. Service is single-user, single-machine.
Constraints
- Optional state persistence via JSON file. Optional API key auth (auto-generated write key if none configured). No RBAC beyond
read/writescopes. - No Stream Deck SDK plugin in core; client implementations stay thin.
- Avoid agent-specific logic in shared infrastructure beyond what's already established. If you find yourself adding a
if agent_type == "claude_code"branch to a generic registry, that's a smell — push it into the agent class.