Onboard a harness
A harness is any CLI coding agent — Claude Code, Codex, Copilot, or your own. RubberDuckHQ talks to all of them through one adapter contract. Onboarding a new agent is: implement that contract once, add one line to the registry. The core never imports a specific agent — it loads whichever the session declares.
Two ways a session runs
Every session is one of two kinds — the same watched / launched labels you see on the dashboard. A harness can support one or both; supporting more unlocks more.
- 1. Launched — Rubberduck starts the agent. You click New session; Rubberduck runs the agent's command for you, owns the process, and reads its state from the terminal output. Needs only the launch half of the contract, so it works for any CLI agent.
- 2. Watched — you start the agent yourself. You run the agent in your own terminal; its hooks stream events back to Rubberduck, which observes it (it doesn't own the process). Needs the agent to have a hook system you declare (a
HookSpec); this is what gives precise state, the Pulse feed, Needs-human, and history.
The contract
A harness is a class implementing this interface (Python today; the shape is what matters). Each method is small and single-purpose:
class Harness:
name: str # registry key, e.g. "codex"
hook_spec: HookSpec | None # for WATCHED support; None = launch-only
def __init__(self, command: str): ...
# ── needed to LAUNCH the agent ──
def launch_command(self, *, cwd, session_key, initial_prompt) -> list[str]
# the argv to start the agent
def restore_command(self, *, cwd, session_key) -> list[str]
# the argv to resume an existing session
def detect_state(self, recent_output: str) -> SessionState
# idle | busy | waiting | terminated, read from terminal output
# (used when there are no hook events driving state)
def tool_in(self, recent_output: str) -> str | None
# which tool is running, if detectable from output
# ── richer history (optional) ──
def locate_transcript(self, *, cwd, session_id) -> Path | None
def read_transcript(self, *, cwd, session_id) -> list[{role, text}]
# the conversation as uniform {role, text} records, newest-last
# (each agent reads its own native format: JSONL, SQLite, …)To support WATCHED: HookSpec
To let people watch a session they started themselves, the agent must have a hook system you declare with a HookSpec: the config file location (global + repo-local) and two pure functions that build (merge our hook entries in) and strip (remove them) on the parsed config — so install and uninstall stay symmetric and idempotent.
hook_spec = HookSpec(
global_rel = Path(".codex") / "hooks.json", # ~/.codex/hooks.json
repo_rel = Path(".codex") / "hooks.json", # <repo>/.codex/hooks.json
build = claude_style_build, # add our entries to the agent's config
strip = claude_style_strip, # remove them again
)Agents whose config is shaped like Claude's reuse the shared build/strip helpers — Codex does exactly this. A differently-shaped config (e.g. Copilot's) supplies its own pair.
The event contract
The installed hook posts the agent's lifecycle events to POST /events as one normalized JSON shape, whatever the agent's native field names. RubberDuckHQ accepts snake_case or camelCase and maps to:
{
"event_type": "SessionStart | UserPromptSubmit | PreToolUse |
PostToolUse | PermissionRequest | Notification |
Stop | SessionEnd",
"session_id": "<agent's own session id>",
"session_key": "<set by Rubberduck on launch, else null>",
"cwd": "/path/the/agent/runs/in",
"tool_name": "Bash | Edit | WebFetch | …",
"tool_input": { … }, # the command, url, file, etc.
"prompt": "<user prompt text>",
"runtime": "claude-code | codex | copilot",
"agent_pid": 12345 # so a watched session's death is detected
}From these, the dashboard derives session state, the Pulse feed, the Needs-human panel, and durable history — uniformly across agents.
Register it
One entry wires the agent into everything: the --agent choices, the New-session picker, runtime construction, hook install, and transcript reading for checkpoints.
REGISTRY = {
"claude-code": ClaudeCodeRuntime,
"codex": CodexRuntime,
"copilot": CopilotRuntime,
"your-agent": YourRuntime, # <- onboard here
"generic": GenericRuntime,
}Levels of support (degrade gracefully)
A harness gets exactly the support its capabilities allow — nothing is all-or-nothing:
generic runtime).Approvals, specifically
If the agent has a pre-exec hook that can block and return a decision, its permission prompts route to the dashboard: the hook registers the request (POST /approvals), long-polls GET /approvals/:id/decision, and returns the agent's allow/deny once you click — no keystroke faking, fail-open if RubberDuckHQ is down. Agents without such a hook (Codex) still surface the request and offer a one-click jump to their terminal to answer there.