asyncapi: 3.0.0
info:
  title: clawborrator hub_v1 WebSocket protocol
  version: 0.3.0
  description: |
    Three WebSocket surfaces:

      * **/channel** — clawborrator-mcp ↔ hub. The MCP child of a Claude
        Code instance opens this on register, ships chat/tail events and
        permission requests, services peer-routing, etc. Long-lived; one
        per CC session.

      * **/cli** — `claw session attach` (or any browser TUI) ↔ hub. The
        operator's eyes-and-write surface. Subscribes to a session,
        receives the broadcast firehose, sends prompts / op-messages /
        approvals.

      * **/supervisor** — clawborrator-supervisor (desktop daemon) ↔ hub.
        Long-lived RPC channel: daemon registers with `hello`, hub then
        sends `cmd` frames to operate managed sessions (create / kill /
        restart / screenshot) and the daemon replies with `ok`/`err`
        keyed by command id.

    Auth differs per surface:
      * /channel takes a channel token (`ck_live_…`) as a `register`
        message field, NOT in HTTP headers.
      * /cli takes a session token (`cw_sess_…`) as either an HttpOnly
        cookie (browser, set by /api/v1/auth/oauth/callback) OR a Bearer
        Authorization header (CLI, returned by /api/v1/auth/oauth/token)
        OR a `Sec-WebSocket-Protocol: bearer, <token>` subprotocol
        (browser fallback when cookies aren't viable).
      * /supervisor takes an `Authorization: Bearer cw_app_…` header
        (preferred — minted via the SPA OAuth + PKCE flow on first run)
        or `cw_sess_…` (also accepted).

    Companion docs:
      * Wire-level walkthrough with mermaid diagrams: `docs/CLI-WS-PROTOCOL.md`
      * Cross-session routing context impact:        `docs/PEER-ROUTING.md`
      * REST surface (/api/v1/*):                    `/docs` (OpenAPI)

  contact:
    name: clawborrator
    url: https://github.com/clawborrator/hub_v1
  license:
    name: MIT

servers:
  hosted:
    host: next.clawborrator.com
    protocol: wss
    description: hosted hub
  local:
    host: localhost:8787
    protocol: ws
    description: local dev

channels:
  cli:
    address: /cli
    title: Operator (CLI / browser) WS
    description: |
      Operator-facing read/write surface. Operators subscribe to a
      sessionId and receive every chat/tail event broadcast by /channel
      WS or /api/channel/event ingests for that session, plus permission
      relays, presence deltas, channel-online/offline transitions, and
      file events. Operators can write prompts, op-messages, approvals.

      **Agent interception** — when an outbound `prompt` frame's `text`
      starts with `@<owner-login>/<slug> ` (anchored at position 0), the
      hub treats it as an invocation of a published expert agent and
      routes it to the agent's session instead of the source session's
      Claude. The agent's `mcp__clawborrator__reply` lands back in the
      source session as a `chat/reply` event with `payload.source =
      'peer-reply'` and `payload.agentHandle = '<owner>/<slug>'`. No new
      WS message types are involved — this is purely server-side
      rewiring inside `services/prompts.ts → maybeInterceptAgentPrompt`.
      See REST `/api/v1/agents` for the agent management surface.
    messages:
      cli_out_subscribe: { $ref: '#/components/messages/cli_out_subscribe' }
      cli_out_unsubscribe: { $ref: '#/components/messages/cli_out_unsubscribe' }
      cli_out_prompt: { $ref: '#/components/messages/cli_out_prompt' }
      cli_out_op_message: { $ref: '#/components/messages/cli_out_op_message' }
      cli_out_approval: { $ref: '#/components/messages/cli_out_approval' }
      cli_out_route: { $ref: '#/components/messages/cli_out_route' }
      cli_in_subscribed: { $ref: '#/components/messages/cli_in_subscribed' }
      cli_in_event: { $ref: '#/components/messages/cli_in_event' }
      cli_in_op_message: { $ref: '#/components/messages/cli_in_op_message' }
      cli_in_permission_request: { $ref: '#/components/messages/cli_in_permission_request' }
      cli_in_permission_resolved: { $ref: '#/components/messages/cli_in_permission_resolved' }
      cli_in_presence: { $ref: '#/components/messages/cli_in_presence' }
      cli_in_channel_status: { $ref: '#/components/messages/cli_in_channel_status' }
      cli_in_file_event: { $ref: '#/components/messages/cli_in_file_event' }
      cli_in_ack: { $ref: '#/components/messages/cli_in_ack' }
      cli_in_error: { $ref: '#/components/messages/cli_in_error' }

  channel:
    address: /channel
    title: Channel (clawborrator-mcp) WS
    description: |
      Channel-side bidirectional. `register` is the first message after
      open and carries the channel token; the hub responds with
      `welcome` once auth + session resolution succeed. After that, the
      MCP ships chat_event / tail_event frames for everything CC does,
      forwards permission_request frames CC raises, and handles
      route_request / probe_request / list_peers_request from Claude's
      tool calls. Hub pushes back `prompt` (operator-driven or
      peer-routed), `permission_response`, `route_reply`, etc.
    messages:
      ch_out_register: { $ref: '#/components/messages/ch_out_register' }
      ch_out_chat_event: { $ref: '#/components/messages/ch_out_chat_event' }
      ch_out_tail_event: { $ref: '#/components/messages/ch_out_tail_event' }
      ch_out_permission_request: { $ref: '#/components/messages/ch_out_permission_request' }
      ch_out_route_request: { $ref: '#/components/messages/ch_out_route_request' }
      ch_out_probe_request: { $ref: '#/components/messages/ch_out_probe_request' }
      ch_out_list_peers_request: { $ref: '#/components/messages/ch_out_list_peers_request' }
      ch_out_pong: { $ref: '#/components/messages/ch_out_pong' }
      ch_in_welcome: { $ref: '#/components/messages/ch_in_welcome' }
      ch_in_prompt: { $ref: '#/components/messages/ch_in_prompt' }
      ch_in_permission_response: { $ref: '#/components/messages/ch_in_permission_response' }
      ch_in_route_response: { $ref: '#/components/messages/ch_in_route_response' }
      ch_in_route_reply: { $ref: '#/components/messages/ch_in_route_reply' }
      ch_in_probe_response: { $ref: '#/components/messages/ch_in_probe_response' }
      ch_in_peers_update: { $ref: '#/components/messages/ch_in_peers_update' }
      ch_in_list_peers_response: { $ref: '#/components/messages/ch_in_list_peers_response' }
      ch_in_bye: { $ref: '#/components/messages/ch_in_bye' }
      ch_in_ping: { $ref: '#/components/messages/ch_in_ping' }
      ch_in_error: { $ref: '#/components/messages/ch_in_error' }

  supervisor:
    address: /supervisor
    title: Supervisor (desktop daemon) WS
    description: |
      Long-lived control channel between the hub and a desktop daemon
      (clawborrator-supervisor). The daemon connects with a Bearer
      token and identifies its `machine_id` in a `hello` frame; the
      hub responds with `hello_ack`. After that, the daemon is a
      passive RPC endpoint — the hub sends `cmd` frames to operate
      managed sessions (create / kill / restart / screenshot) and
      the daemon replies with `ok` or `err` keyed by command id.

      Auth: `Authorization: Bearer cw_app_…` (preferred — minted via
      the SPA OAuth + PKCE flow on first run) or `cw_sess_…` (also
      accepted).
    messages:
      sv_out_hello: { $ref: '#/components/messages/sv_out_hello' }
      sv_out_ping:  { $ref: '#/components/messages/sv_out_ping' }
      sv_out_pong:  { $ref: '#/components/messages/sv_out_pong' }
      sv_out_ok:    { $ref: '#/components/messages/sv_out_ok' }
      sv_out_err:   { $ref: '#/components/messages/sv_out_err' }
      sv_out_evt:   { $ref: '#/components/messages/sv_out_evt' }
      sv_in_hello_ack: { $ref: '#/components/messages/sv_in_hello_ack' }
      sv_in_ping:      { $ref: '#/components/messages/sv_in_ping' }
      sv_in_pong:      { $ref: '#/components/messages/sv_in_pong' }
      sv_in_cmd:       { $ref: '#/components/messages/sv_in_cmd' }
      sv_in_error:     { $ref: '#/components/messages/sv_in_error' }

operations:
  cli_send:
    action: send
    channel: { $ref: '#/channels/cli' }
    title: Operator → hub
    summary: Frames the operator (claw or browser demo) sends after a successful WS upgrade.
    messages:
      - { $ref: '#/channels/cli/messages/cli_out_subscribe' }
      - { $ref: '#/channels/cli/messages/cli_out_unsubscribe' }
      - { $ref: '#/channels/cli/messages/cli_out_prompt' }
      - { $ref: '#/channels/cli/messages/cli_out_op_message' }
      - { $ref: '#/channels/cli/messages/cli_out_approval' }
      - { $ref: '#/channels/cli/messages/cli_out_route' }

  cli_receive:
    action: receive
    channel: { $ref: '#/channels/cli' }
    title: Hub → operator
    summary: Frames the hub pushes to /cli WS subscribers (events, presence, permission relays, file events, etc.).
    messages:
      - { $ref: '#/channels/cli/messages/cli_in_subscribed' }
      - { $ref: '#/channels/cli/messages/cli_in_event' }
      - { $ref: '#/channels/cli/messages/cli_in_op_message' }
      - { $ref: '#/channels/cli/messages/cli_in_permission_request' }
      - { $ref: '#/channels/cli/messages/cli_in_permission_resolved' }
      - { $ref: '#/channels/cli/messages/cli_in_presence' }
      - { $ref: '#/channels/cli/messages/cli_in_channel_status' }
      - { $ref: '#/channels/cli/messages/cli_in_file_event' }
      - { $ref: '#/channels/cli/messages/cli_in_ack' }
      - { $ref: '#/channels/cli/messages/cli_in_error' }

  channel_send:
    action: send
    channel: { $ref: '#/channels/channel' }
    title: Channel (clawborrator-mcp) → hub
    summary: Frames the MCP ships up — register/heartbeat, captured CC events, permission gates, peer-routing tool calls.
    messages:
      - { $ref: '#/channels/channel/messages/ch_out_register' }
      - { $ref: '#/channels/channel/messages/ch_out_chat_event' }
      - { $ref: '#/channels/channel/messages/ch_out_tail_event' }
      - { $ref: '#/channels/channel/messages/ch_out_permission_request' }
      - { $ref: '#/channels/channel/messages/ch_out_route_request' }
      - { $ref: '#/channels/channel/messages/ch_out_probe_request' }
      - { $ref: '#/channels/channel/messages/ch_out_list_peers_request' }
      - { $ref: '#/channels/channel/messages/ch_out_pong' }

  channel_receive:
    action: receive
    channel: { $ref: '#/channels/channel' }
    title: Hub → channel
    summary: Frames the hub pushes to a registered channel — operator-driven prompts, permission resolutions, peer-route replies, peer-list updates, lifecycle.
    messages:
      - { $ref: '#/channels/channel/messages/ch_in_welcome' }
      - { $ref: '#/channels/channel/messages/ch_in_prompt' }
      - { $ref: '#/channels/channel/messages/ch_in_permission_response' }
      - { $ref: '#/channels/channel/messages/ch_in_route_response' }
      - { $ref: '#/channels/channel/messages/ch_in_route_reply' }
      - { $ref: '#/channels/channel/messages/ch_in_probe_response' }
      - { $ref: '#/channels/channel/messages/ch_in_peers_update' }
      - { $ref: '#/channels/channel/messages/ch_in_list_peers_response' }
      - { $ref: '#/channels/channel/messages/ch_in_bye' }
      - { $ref: '#/channels/channel/messages/ch_in_ping' }
      - { $ref: '#/channels/channel/messages/ch_in_error' }

  supervisor_send:
    action: send
    channel: { $ref: '#/channels/supervisor' }
    title: Daemon → hub
    summary: Frames the desktop daemon emits — hello on connect, ping/pong, RPC responses, evt for daemon-initiated state pushes.
    messages:
      - { $ref: '#/channels/supervisor/messages/sv_out_hello' }
      - { $ref: '#/channels/supervisor/messages/sv_out_ping' }
      - { $ref: '#/channels/supervisor/messages/sv_out_pong' }
      - { $ref: '#/channels/supervisor/messages/sv_out_ok' }
      - { $ref: '#/channels/supervisor/messages/sv_out_err' }
      - { $ref: '#/channels/supervisor/messages/sv_out_evt' }

  supervisor_receive:
    action: receive
    channel: { $ref: '#/channels/supervisor' }
    title: Hub → daemon
    summary: Frames the hub sends — hello_ack after registration, ping/pong, cmd frames the daemon executes and responds to.
    messages:
      - { $ref: '#/channels/supervisor/messages/sv_in_hello_ack' }
      - { $ref: '#/channels/supervisor/messages/sv_in_ping' }
      - { $ref: '#/channels/supervisor/messages/sv_in_pong' }
      - { $ref: '#/channels/supervisor/messages/sv_in_cmd' }
      - { $ref: '#/channels/supervisor/messages/sv_in_error' }

components:
  messages:
    # ─── /cli outbound (operator → hub) ───────────────────────────
    cli_out_subscribe:
      summary: Subscribe to a session's broadcast firehose.
      payload:
        type: object
        required: [type, sessionId]
        properties:
          type:      { type: string, const: subscribe }
          sessionId: { type: string, format: uuid }
    cli_out_unsubscribe:
      summary: Stop receiving broadcasts for a session.
      payload:
        type: object
        required: [type, sessionId]
        properties:
          type:      { type: string, const: unsubscribe }
          sessionId: { type: string, format: uuid }
    cli_out_prompt:
      summary: Send a prompt to a session's live Claude.
      description: |
        `sourceSessionId` is set when the operator is attached to one
        session and redirecting `@peer text` to a different session
        (Flow A in PEER-ROUTING.md). The hub then sets up a permission
        relay so the source operator sees gates the target hits.

        **Agent interception**: if `text` starts with `@<owner>/<slug> `
        and a published agent matches that handle, the hub routes the
        prompt to the agent's session instead of `sessionId`. The
        querier's CC never sees the prompt; the reply lands in
        `sessionId` as a `chat/reply` event tagged `agentHandle`.
        Subject to per-agent rate limits + daily budgets — denials
        return a `cli_in_error` frame with code starting `agent_`.
      payload:
        type: object
        required: [type, sessionId, text]
        properties:
          type:            { type: string, const: prompt }
          sessionId:       { type: string, format: uuid, description: "target session — usually the attached one, or a peer for cross-session redirects" }
          text:            { type: string }
          sourceSessionId: { type: string, format: uuid, description: "set when redirecting; the hub uses it for permission-relay setup and to persist the dispatch in the source's events" }
    cli_out_op_message:
      summary: Operator-to-operator chat lane (NOT a chat event sent to Claude).
      payload:
        type: object
        required: [type, sessionId, text]
        properties:
          type:      { type: string, const: op_message }
          sessionId: { type: string, format: uuid }
          text:      { type: string }
          mentions:  { type: array, items: { type: string } }
    cli_out_approval:
      summary: Resolve a permission request (allow/deny).
      payload:
        type: object
        required: [type, sessionId, requestId, decision]
        properties:
          type:      { type: string, const: approval }
          sessionId: { type: string, format: uuid, description: "the GATE's session — for cross-session relays this differs from the operator's attached session" }
          requestId: { type: string }
          decision:  { type: string, enum: [allow, deny] }
          message:   { type: string }
    cli_out_route:
      summary: Reserved — not yet wired.
      description: Placeholder for a future direct-route tool that bypasses session attach.
      payload:
        type: object
        required: [type, peer, prompt, mode]
        properties:
          type:   { type: string, const: route }
          peer:   { type: string }
          prompt: { type: string }
          mode:   { type: string, enum: [ask, tell] }

    # ─── /cli inbound (hub → operator) ────────────────────────────
    cli_in_subscribed:
      summary: Hub confirms our subscribe; carries our role on the session.
      payload:
        type: object
        required: [type, sessionId, role]
        properties:
          type:      { type: string, const: subscribed }
          sessionId: { type: string, format: uuid }
          role:      { type: string, enum: [owner, viewer, prompter, approver] }
    cli_in_event:
      summary: A chat or tail event broadcast on this session.
      description: |
        The inner `event` carries kind=chat|tail and a free-form
        type+payload. Common chat types: prompt, assistant_text, reply,
        peer-timeout. Common tail types: PreToolUse, PostToolUse, Stop,
        Notification, UserPromptSubmit, SubagentStop. Payload shape
        varies by type — see PEER-ROUTING.md for the chat-flow shapes
        and CLI-WS-PROTOCOL.md for the broader event surface.
      payload:
        type: object
        required: [type, sessionId, event]
        properties:
          type:      { type: string, const: event }
          sessionId: { type: string, format: uuid }
          event:
            type: object
            required: [kind, type, payload, ts]
            properties:
              kind:    { type: string, enum: [chat, tail] }
              type:    { type: string, description: "free-form discriminator; e.g. prompt, assistant_text, reply, peer-timeout, PreToolUse, PostToolUse, Stop" }
              payload: { type: object, additionalProperties: true }
              ts:      { type: string, format: date-time }
    cli_in_op_message:
      summary: An op-message landed on this session.
      payload:
        type: object
        required: [type, sessionId, authorLogin, text, mentions, ts]
        properties:
          type:        { type: string, const: op_message }
          sessionId:   { type: string, format: uuid }
          authorLogin: { type: string }
          text:        { type: string }
          mentions:    { type: array, items: { type: string } }
          ts:          { type: string, format: date-time }
    cli_in_permission_request:
      summary: A tool-permission gate is pending on this session.
      description: |
        Fans out to (a) every operator subscribed to the session and
        (b) every operator with a permission-relay registered (cross-
        session redirects via Flow A or Flow B). When `sessionId`
        differs from the operator's currently-attached session, the
        request is a relayed cross-session gate — the approval frame
        sent in response MUST address the gate's sessionId, not the
        attached one.
      payload:
        type: object
        required: [type, sessionId, requestId, tool, ts]
        properties:
          type:         { type: string, const: permission_request }
          sessionId:    { type: string, format: uuid, description: "session where the tool is gated" }
          requestId:    { type: string }
          tool:         { type: string }
          inputPreview: { type: string }
          ts:           { type: string, format: date-time }
    cli_in_permission_resolved:
      summary: A pending permission_request got an allow/deny/expired decision.
      payload:
        type: object
        required: [type, sessionId, requestId, decision]
        properties:
          type:          { type: string, const: permission_resolved }
          sessionId:     { type: string, format: uuid }
          requestId:     { type: string }
          decision:      { type: string, enum: [allow, deny, expired] }
          resolverLogin: { type: string, nullable: true, description: "github login of the operator who resolved; null when expired without resolution" }
    cli_in_presence:
      summary: Set of operators currently attached, plus joined/left delta.
      payload:
        type: object
        required: [type, sessionId, attached]
        properties:
          type:      { type: string, const: presence }
          sessionId: { type: string, format: uuid }
          attached:  { type: array, items: { type: string }, description: "github logins of every /cli WS currently subscribed to this session" }
          joined:    { type: string, description: "set on the broadcast that observed a new operator subscribe" }
          left:      { type: string, description: "set on the broadcast that observed an operator unsubscribe / disconnect" }
    cli_in_channel_status:
      summary: The session's /channel WS connected or disconnected (CC online/offline).
      payload:
        type: object
        required: [type, sessionId, connected, ts]
        properties:
          type:      { type: string, const: channel_status }
          sessionId: { type: string, format: uuid }
          connected: { type: boolean }
          ts:        { type: string, format: date-time }
    cli_in_file_event:
      summary: A file was uploaded or deleted on this session.
      payload:
        type: object
        required: [type, sessionId, action, file]
        properties:
          type:      { type: string, const: file_event }
          sessionId: { type: string, format: uuid }
          action:    { type: string, enum: [uploaded, deleted] }
          file:      { $ref: '#/components/schemas/ApiFile' }
    cli_in_ack:
      summary: Hub acknowledged a write (typically a prompt send). May carry a chatId.
      payload:
        type: object
        required: [type, ok]
        properties:
          type:   { type: string, const: ack }
          ok:     { type: boolean, const: true }
          chatId: { type: string, description: "set after a prompt send; can be used for reply correlation" }
    cli_in_error:
      summary: Hub-side error on a frame the operator just sent.
      payload:
        type: object
        required: [type, ok, code, message]
        properties:
          type:    { type: string, const: error }
          ok:      { type: boolean, const: false }
          code:    { type: string, description: "stable code: auth_failed, forbidden, internal, …" }
          message: { type: string }

    # ─── /channel outbound (channel → hub) ────────────────────────
    ch_out_register:
      summary: First message on every /channel WS — auth + session intent.
      description: |
        sessionId may be null (hub mints a fresh UUID) or a previously-
        issued UUID (hub rebinds the existing row). Channel token rides
        in the `Authorization: Bearer ck_live_…` header (NOT in this
        message's body — convention is HTTP-side).
      payload:
        type: object
        required: [type, host, cwd, pid, channelVersion]
        properties:
          type:           { type: string, const: register }
          host:           { type: string }
          cwd:            { type: string }
          osUser:         { type: string, nullable: true }
          pid:            { type: integer }
          channelVersion: { type: string }
          sessionId:      { type: string, format: uuid, nullable: true }
    ch_out_chat_event:
      summary: A chat-lane event from CC (prompt, reply, assistant_text, …).
      payload:
        type: object
        required: [type, eventType, payload, ts]
        properties:
          type:      { type: string, const: chat_event }
          eventType: { type: string, enum: [prompt, reply] }
          payload:   { type: object, additionalProperties: true }
          ts:        { type: string, format: date-time }
    ch_out_tail_event:
      summary: A hook-fired tail event (PreToolUse, PostToolUse, Stop, …) or a clawborrator-emitted card (AskUserQuestion).
      payload:
        type: object
        required: [type, eventType, payload, ts]
        properties:
          type:      { type: string, const: tail_event }
          eventType: { type: string, enum: [PreToolUse, PostToolUse, Stop, Notification, UserPromptSubmit, AskUserQuestion] }
          payload:   { type: object, additionalProperties: true }
          ts:        { type: string, format: date-time }
    ch_out_permission_request:
      summary: CC needs permission to call a tool — relay to attached operators.
      payload:
        type: object
        required: [type, requestId, tool, inputPreview, ts]
        properties:
          type:         { type: string, const: permission_request }
          requestId:    { type: string }
          tool:         { type: string }
          inputPreview: { type: string }
          ts:           { type: string, format: date-time }
    ch_out_route_request:
      summary: Send a prompt to a peer (route_to_peer MCP tool).
      payload:
        type: object
        required: [type, correlationId, peer, prompt, mode]
        properties:
          type:          { type: string, const: route_request }
          correlationId: { type: string }
          peer:          { type: string, description: "routing name like @backend or @owner/backend" }
          prompt:        { type: string }
          mode:          { type: string, enum: [ask, tell] }
    ch_out_probe_request:
      summary: Fan out a question to many peers (probe_peers MCP tool).
      payload:
        type: object
        required: [type, correlationId, prompt]
        properties:
          type:          { type: string, const: probe_request }
          correlationId: { type: string }
          peers:         { type: array, items: { type: string }, nullable: true, description: "null = all online peers" }
          prompt:        { type: string }
    ch_out_list_peers_request:
      summary: Discover routable peers (list_peers MCP tool).
      payload:
        type: object
        required: [type, correlationId]
        properties:
          type:          { type: string, const: list_peers_request }
          correlationId: { type: string }
    ch_out_pong:
      summary: Reply to a hub-side ping.
      payload:
        type: object
        required: [type, ts]
        properties:
          type: { type: string, const: pong }
          ts:   { type: string, format: date-time }

    # ─── /channel inbound (hub → channel) ─────────────────────────
    ch_in_welcome:
      summary: Auth ok, session resolved — channel is registered and ready.
      payload:
        type: object
        required: [type, sessionId, routingName, channelTokenName]
        properties:
          type:             { type: string, const: welcome }
          sessionId:        { type: string, format: uuid }
          routingName:      { type: string, description: "@-prefixed, e.g. @backend" }
          channelTokenName: { type: string, description: "human label of the channel token used (audit only)" }
    ch_in_prompt:
      summary: Operator-driven or peer-routed prompt — surface to Claude.
      payload:
        type: object
        required: [type, chatId, text]
        properties:
          type:   { type: string, const: prompt }
          chatId: { type: string }
          text:   { type: string }
    ch_in_permission_response:
      summary: Operator resolved a pending permission_request.
      payload:
        type: object
        required: [type, requestId, decision]
        properties:
          type:      { type: string, const: permission_response }
          requestId: { type: string }
          decision:  { type: string, enum: [allow, deny, expired] }
          message:   { type: string, nullable: true }
    ch_in_route_response:
      summary: route_to_peer ask-mode reply (or tell-mode confirmation).
      payload:
        type: object
        required: [type, correlationId, peerLogin, reply]
        properties:
          type:          { type: string, const: route_response }
          correlationId: { type: string }
          peerLogin:     { type: string }
          reply:         { type: string }
    ch_in_route_reply:
      summary: A peer's reply landed for a previously-dispatched prompt (operator @-redirect or MCP tell-mode).
      payload:
        type: object
        required: [type, routeId, fromName, text, ts]
        properties:
          type:     { type: string, const: route_reply }
          routeId:  { type: string }
          fromName: { type: string }
          text:     { type: string }
          ts:       { type: string, format: date-time }
          origin:   { type: string, enum: [operator, mcp], description: "operator: dispatched via Flow A @-redirect; mcp: dispatched via route_to_peer tool" }
    ch_in_probe_response:
      summary: One peer's answer to a probe (others stream individually).
      payload:
        type: object
        required: [type, correlationId, peerLogin, answer]
        properties:
          type:          { type: string, const: probe_response }
          correlationId: { type: string }
          peerLogin:     { type: string, nullable: true, description: "null on the terminal `done` marker" }
          answer:        { type: string, nullable: true }
          done:          { type: boolean }
    ch_in_peers_update:
      summary: Live broadcast of online/offline peers (own + shared).
      payload:
        type: object
        required: [type, peers]
        properties:
          type:  { type: string, const: peers_update }
          peers:
            type: array
            items:
              type: object
              required: [login, name, online]
              properties:
                login:  { type: string }
                name:   { type: string }
                online: { type: boolean }
    ch_in_list_peers_response:
      summary: One-shot reply to list_peers_request.
      payload:
        type: object
        required: [type, correlationId, peers]
        properties:
          type:          { type: string, const: list_peers_response }
          correlationId: { type: string }
          peers:
            type: array
            items:
              type: object
              required: [login, name, online]
              properties:
                login:  { type: string }
                name:   { type: string }
                online: { type: boolean }
    ch_in_bye:
      summary: Hub is closing the channel — server restart, kick, etc.
      payload:
        type: object
        required: [type, reason, retry]
        properties:
          type:   { type: string, const: bye }
          reason: { type: string }
          retry:  { type: boolean, description: "true if reconnect is expected to succeed (e.g. transient restart); false on permanent reasons (revoked token)" }
    ch_in_ping:
      summary: Liveness probe — channel responds with `pong`.
      payload:
        type: object
        required: [type, ts]
        properties:
          type: { type: string, const: ping }
          ts:   { type: string, format: date-time }
    ch_in_error:
      summary: Hub-side error on a frame the channel just sent.
      payload:
        type: object
        required: [type, code, message]
        properties:
          type:    { type: string, const: error }
          code:    { type: string }
          message: { type: string }

    # ─── /supervisor outbound (daemon → hub) ──────────────────────
    sv_out_hello:
      summary: First frame after WS upgrade. Identifies the daemon to the hub.
      description: |
        Sent immediately after the WebSocket upgrade succeeds. The hub
        upserts a `desktops` row keyed by (userId-from-token, machine_id)
        and replies with `hello_ack`. If `hello` doesn't arrive within
        10s the hub closes with `code: no_hello`.
      payload:
        type: object
        required: [t, machine_id]
        properties:
          t:              { type: string, const: hello }
          machine_id:     { type: string, description: "stable per-install uuid generated by the daemon" }
          daemon_version: { type: string }
          hostname:       { type: string }
          capabilities:   { type: array, items: { type: string }, description: "advisory; lets the hub gracefully degrade for older daemons" }
    sv_out_ping:
      summary: Daemon-initiated liveness probe. Hub responds with `pong`.
      payload:
        type: object
        required: [t]
        properties:
          t: { type: string, const: ping }
    sv_out_pong:
      summary: Daemon's reply to a hub-initiated `ping`.
      payload:
        type: object
        required: [t]
        properties:
          t: { type: string, const: pong }
    sv_out_ok:
      summary: RPC success. Replies to a hub `cmd` frame keyed by `id`.
      payload:
        type: object
        required: [t, id]
        properties:
          t:    { type: string, const: ok }
          id:   { type: string, description: "echoes the cmd frame's id" }
          data: { description: "op-specific success payload (object, array, or scalar)" }
    sv_out_err:
      summary: RPC failure. Replies to a hub `cmd` frame keyed by `id`.
      payload:
        type: object
        required: [t, id, code, message]
        properties:
          t:       { type: string, const: err }
          id:      { type: string }
          code:    { type: string, description: "machine-readable error code, e.g. not_implemented, folder_denied, timeout" }
          message: { type: string }
    sv_out_evt:
      summary: Daemon-initiated push event (e.g. session.state, desktop.health).
      description: |
        **Step 2.** Reserved for daemon-side state pushes — a session
        process exited, daemon CPU/RAM heartbeat, etc. Not used by
        Step 1 (handshake-only).
      payload:
        type: object
        required: [t, topic]
        properties:
          t:     { type: string, const: evt }
          topic: { type: string, description: "e.g. session.state, desktop.health" }
          data:  { description: "topic-specific payload" }

    # ─── /supervisor inbound (hub → daemon) ───────────────────────
    sv_in_hello_ack:
      summary: Hub acknowledges the daemon's `hello` frame.
      payload:
        type: object
        required: [t]
        properties:
          t:          { type: string, const: hello_ack }
          user_login: { type: string, description: "the GitHub login the auth token resolved to" }
          message:    { type: string }
    sv_in_ping:
      summary: Hub-initiated liveness probe (every 30s). Daemon responds with `pong`.
      payload:
        type: object
        required: [t]
        properties:
          t: { type: string, const: ping }
    sv_in_pong:
      summary: Hub's reply to a daemon-initiated `ping`.
      payload:
        type: object
        required: [t]
        properties:
          t: { type: string, const: pong }
    sv_in_cmd:
      summary: Hub-initiated RPC. Daemon executes `op` and responds with `ok` or `err` keyed by `id`.
      description: |
        Drives every managed-session verb. The current op set:

          * `session.create`     — { folder, routingName?, extraFlags?, autoEnter? } → { sessionId }
          * `session.kill`       — { sessionId }                              → { ok: true }
          * `session.destroy`    — { sessionId }                              → { ok: true }
          * `session.restart`    — { sessionId }                              → { sessionId }   (always AUTO START)
          * `session.screenshot` — { sessionId }                              → { rows, cols, text, cursor? }
          * `session.input`      — { sessionId, bytes }                       → { ok: true, wrote }

        `autoEnter` defaults to true ("AUTO START": daemon types
        Enter ~5x at 1s to dismiss CC's default-yes startup
        prompts). Pass `false` for "MANUAL START" — operator
        drives prompts via the screenshot PIP / `claw session
        input`. `extraFlags` is `string[]`, one argv slot per
        entry, appended after the daemon's required CC flags.

        Errors: a `session_not_found` code on kill/restart/
        destroy/screenshot/input means the daemon's in-memory
        SessionManager has no entry for the given sessionId.
        Hub's REST handlers translate this into recreate
        fallbacks (restart) or no-op success (kill) as
        appropriate.

        Per-op timeout enforced hub-side (defaults: 10s, overridden to
        45s for create/restart, 5s for screenshot). On timeout the hub
        rejects the awaiting HTTP request with 504; on `err` reply 502;
        on disconnect mid-flight 503.
      payload:
        type: object
        required: [t, id, op]
        properties:
          t:    { type: string, const: cmd }
          id:   { type: string, description: "uuid; daemon must echo in its ok/err response" }
          op:   { type: string, description: "verb, e.g. session.create" }
          args: { description: "op-specific arguments (object)" }
    sv_in_error:
      summary: Connection-level error pushed by the hub (e.g. auth_failed, no_hello, duplicate_hello, bad_hello, pre_hello, invalid_json, internal). Usually followed by close.
      payload:
        type: object
        required: [t, code, message]
        properties:
          t:       { type: string, const: error }
          code:    { type: string }
          message: { type: string }

  schemas:
    ApiFile:
      type: object
      properties:
        id:            { type: integer }
        sessionId:     { type: string, format: uuid }
        uploaderLogin: { type: string }
        filename:      { type: string }
        mime:          { type: string }
        size:          { type: integer }
        sha256:        { type: string }
        scope:         { type: string, enum: [attachment, reply, corpus] }
        expose:        { type: boolean }
        uploadedAt:    { type: string, format: date-time }
        expiresAt:     { type: string, format: date-time }
        deletedAt:     { type: string, format: date-time, nullable: true }
