openapi: 3.0.3
info:
  title: Lycus API
  version: 1.0.0
  description: |
    Backend contract for Lycus — the hub that pairs OpenClaw Macs to
    workspaces and routes chat to them over WebSocket. **Rails owns durable state;
    Action Cable is only a delivery pipe.**

    Endpoint groups:

      1. **Plugin-facing HTTP** (`/api/openclaw/channel/*`) — pair, reply,
         indicator, event ACK, event pull. Authenticated with the plugin's
         machine token (`bearerAuth`).
      2. **Plugin-facing WebSocket** (`/cable`) — Action Cable stream that
         delivers queued events from `openclaw_channel_events` to an online
         machine.
      3. **Workspace-scoped HTTP** (`/api/conversations*`, `/api/pairing_tickets*`,
         `/api/messages*`) — consumed by the browser SPA *and* by external
         scripts. Authenticate with either the signed session cookie
         (`cookieAuth`) or a workspace API key (`workspaceApiKey`:
         `Authorization: Bearer lyk_…`). Workspace API keys carry their own
         role (admin/member) and per-group scope, so the same RBAC that
         governs users applies to automations.
      4. **Streaming** (`/cable`) — same endpoint, cookie-authenticated for
         browsers, receives the server-side ConversationChannel broadcasts.

    The previous outbound HTTP to a plugin-hosted `/lycus/webhook` is
    **removed**; no `gatewayPublicUrl` / ngrok is required anymore.

servers:
  - url: http://localhost:3000
    description: Local development
  - url: https://claw-management.example.com
    description: Production (Elastic Beanstalk behind an ALB)

tags:
  - name: Auth
    description: |
      Passwordless OTP login. Users sign in with an email or phone number;
      the backend delivers a 6-digit code via SES (email) or Twilio (SMS),
      the user posts it back, and receives a signed `claw_session_token`
      cookie. IDs are prefixed UUIDs — see the IdPrefix schema.
  - name: Workspace
    description: |
      Workspaces own machines, conversations, and pairing tickets. Users
      become workspace members via a Membership row (owner/admin/member).
      Every API request is scoped to the current user's active workspace
      (cookie `claw_workspace_slug`).
  - name: Plugin HTTP
    description: Endpoints the OpenClaw Lycus plugin calls over HTTPS.
  - name: Plugin WebSocket
    description: Action Cable contract the plugin streams over.
  - name: Pairing
    description: Web-initiated pairing flow (browser creates a ticket, plugin claims it).
  - name: Chat UI
    description: Endpoints consumed by the browser chat interface.
  - name: Health
    description: Liveness probe.

paths:
  /health:
    get:
      tags: [Health]
      summary: Liveness probe
      operationId: health
      responses:
        "200":
          description: Service is up
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }

  /api/openclaw/channel/pair:
    post:
      tags: [Plugin HTTP]
      summary: Pair / re-pair an OpenClaw machine
      operationId: pairMachine
      description: |
        Response includes a `socketUrl`. The plugin must open a WebSocket to
        that URL with `?machine_id=<machineId>` and the same bearer token.
      security: [ { bearerAuth: [] } ]
      parameters:
        - $ref: "#/components/parameters/ChannelHeader"
        - $ref: "#/components/parameters/AccountHeader"
        - $ref: "#/components/parameters/MachineHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/PairRequest" }
            examples:
              default: { $ref: "#/components/examples/PairRequestExample" }
      responses:
        "200":
          description: Machine paired
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PairResponse" }
              examples:
                default: { $ref: "#/components/examples/PairResponseExample" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }

  /api/openclaw/channel/messages:
    post:
      tags: [Plugin HTTP]
      summary: Store an assistant reply
      operationId: storeAssistantReply
      description: |
        Reply is persisted to `openclaw_channel_replies` and broadcast to the
        chat UI. Idempotent by `replyId` (the plugin should send one); if
        absent, the server derives a stable id from
        `sha256(machineId|conversationId|replyToId|text)`.

        If `replyToId` matches a pending event's `messageId` we also mark
        that event as ACKed.
      security: [ { bearerAuth: [] } ]
      parameters:
        - $ref: "#/components/parameters/AccountHeader"
        - $ref: "#/components/parameters/MachineHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AssistantReply" }
            examples:
              default: { $ref: "#/components/examples/AssistantReplyExample" }
      responses:
        "200":
          description: Stored
          content:
            application/json:
              schema:
                type: object
                required: [messageId]
                properties:
                  messageId: { type: string, description: "Backend reply id" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/openclaw/channel/indicators:
    post:
      tags: [Plugin HTTP]
      summary: Typing / typing_stopped / error indicator
      operationId: sendIndicator
      security: [ { bearerAuth: [] } ]
      parameters:
        - $ref: "#/components/parameters/AccountHeader"
        - $ref: "#/components/parameters/MachineHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/Indicator" }
      responses:
        "200":
          description: Accepted (never persisted)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/openclaw/channel/events/ack:
    post:
      tags: [Plugin HTTP]
      summary: ACK a delivered event
      operationId: ackEvent
      description: |
        Flips `openclaw_channel_events.status` to `acked`. Idempotent — a
        second ACK for the same `eventId` is a no-op. Prefer this over the
        inline `ack` action on the WebSocket channel if you want delivery
        guarantees that survive a dropped socket.
      security: [ { bearerAuth: [] } ]
      parameters:
        - $ref: "#/components/parameters/AccountHeader"
        - $ref: "#/components/parameters/MachineHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [eventId]
              properties:
                eventId: { type: string, example: "evt_8f2c1d0b-..." }
                status: { type: string, enum: [received, processed], default: received }
      responses:
        "200":
          description: ACK recorded
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Ok" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/openclaw/channel/events/pull:
    post:
      tags: [Plugin HTTP]
      summary: Pull pending events (reconnect replay)
      operationId: pullEvents
      description: |
        Returns up to `limit` events (max 200, default 50) newer than
        `afterCursor`. Each returned event is marked `delivered`. The plugin
        should ACK each event after processing.
      security: [ { bearerAuth: [] } ]
      parameters:
        - $ref: "#/components/parameters/AccountHeader"
        - $ref: "#/components/parameters/MachineHeader"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                afterCursor:
                  type: string
                  nullable: true
                  description: eventId from the previous pull (exclusive).
                limit:
                  type: integer
                  default: 50
                  minimum: 1
                  maximum: 200
      responses:
        "200":
          description: Event batch
          content:
            application/json:
              schema:
                type: object
                required: [ok, events]
                properties:
                  ok: { type: boolean }
                  cursor:
                    type: string
                    nullable: true
                    description: eventId of the last returned event, or null when empty.
                  events:
                    type: array
                    items: { $ref: "#/components/schemas/OutboundEvent" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /api/pairing_tickets:
    post:
      tags: [Pairing]
      summary: Create a pairing ticket (browser-initiated)
      operationId: createPairingTicket
      security: [ { workspaceApiKey: [] }, { cookieAuth: [] } ]
      description: |
        Returns a one-time `token` the user copies into `openclaw.json` as
        `machineToken`. On first pair, the token becomes the machine's
        credential (bcrypt-digested into `openclaw_machines.token_digest`)
        and the resulting machine is bound to the browser's WebUser.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                label: { type: string, example: "My MacBook Pro" }
                account_id: { type: string, default: default }
      responses:
        "201":
          description: Ticket created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PairingTicket" }

  /api/pairing_tickets/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Pairing]
      summary: Poll pairing status
      operationId: getPairingTicket
      security: [ { workspaceApiKey: [] }, { cookieAuth: [] } ]
      responses:
        "200":
          description: Current ticket state
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PairingTicket" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/conversations:
    get:
      tags: [Chat UI]
      summary: List conversations for the current WebUser
      operationId: listConversations
      security: [ { workspaceApiKey: [] }, { cookieAuth: [] } ]
      responses:
        "200":
          description: Up to 100 conversations, newest first.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: "#/components/schemas/Conversation" }
    post:
      tags: [Chat UI]
      summary: Create a new conversation
      operationId: createConversation
      security: [ { workspaceApiKey: [] }, { cookieAuth: [] } ]
      requestBody:
        required: false
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateConversationRequest" }
      responses:
        "201":
          description: Conversation created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Conversation" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /api/conversations/{id}:
    parameters:
      - { name: id, in: path, required: true, schema: { type: integer } }
    get:
      tags: [Chat UI]
      summary: Fetch a conversation + its timeline
      operationId: getConversation
      security: [ { workspaceApiKey: [] }, { cookieAuth: [] } ]
      responses:
        "200":
          description: Conversation with merged timeline of events+replies.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/Conversation"
                  - type: object
                    properties:
                      timeline:
                        type: array
                        items: { $ref: "#/components/schemas/TimelineEntry" }
        "404": { $ref: "#/components/responses/NotFound" }

  /api/conversations/{conversation_id}/messages:
    parameters:
      - { name: conversation_id, in: path, required: true, schema: { type: integer } }
    post:
      tags: [Chat UI]
      summary: Send a user message
      operationId: sendUserMessage
      security: [ { workspaceApiKey: [] }, { cookieAuth: [] } ]
      description: |
        Creates an `openclaw_channel_events` row with `status=pending`,
        broadcasts a `user` bubble to the chat UI, and enqueues
        `Openclaw::DeliverEventJob`. The socket may or may not be connected
        — the event waits in the queue either way.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text: { type: string, minLength: 1 }
      responses:
        "201":
          description: Event queued
          content:
            application/json:
              schema:
                type: object
                required: [event_id, message_id, status]
                properties:
                  event_id: { type: string, example: "evt_..." }
                  message_id: { type: string, example: "msg_..." }
                  status: { type: string, enum: [pending, delivered, acked, failed, cancelled] }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }

  /cable:
    get:
      tags: [Plugin WebSocket]
      summary: WebSocket upgrade (Action Cable)
      description: |
        This is not a real HTTP GET — it's the Action Cable mount point.
        Documented here so generated clients know the URL and handshake.

        **Machines** connect with
        `wss://.../cable?machine_id=<id>` and `Authorization: Bearer <token>`
        (or `&token=<token>` if headers aren't available).

        **Browsers** connect without `machine_id`; Rails authenticates the
        WebUser from the signed `web_user_token` cookie.

        After `welcome`, a machine sends:

        ```json
        { "command": "subscribe", "identifier": "{\"channel\":\"OpenclawMachineChannel\"}" }
        ```

        After `confirm_subscription`, Rails fires the
        `Openclaw::ReplayPendingEventsJob` and starts broadcasting.

        Payloads pushed to machines match
        [`OutboundEvent`](#/components/schemas/OutboundEvent).

        Supported client-initiated actions on `OpenclawMachineChannel`:
        `ping`, `ack`.
      responses:
        "101":
          description: Switching Protocols (upgrade to WebSocket)
        "401":
          description: Bearer token missing/invalid or cookie rejected.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        **Plugin machine token** — used by the OpenClaw plugin to call
        `/api/openclaw/channel/*`. On pair, Rails stores `bcrypt(token)` in
        `openclaw_machines.token_digest` — the raw token is never at rest on
        the server. Same token is used for all subsequent HTTP calls and the
        WebSocket connection.
    workspaceApiKey:
      type: http
      scheme: bearer
      bearerFormat: "lyk_<opaque>"
      description: |
        **Workspace API key** — issued from the workspace *API keys* page.
        Send as `Authorization: Bearer lyk_…` on any `/api/*` endpoint (chat
        UI, pairing tickets, messages). Each key carries a workspace role
        (`admin` or `member`); member keys are further scoped per-group via
        `api_key_group_accesses`, mirroring user memberships. Only the
        sha256 digest is stored; the raw token is shown once at creation.
    cookieAuth:
      type: apiKey
      in: cookie
      name: claw_session_token
      description: |
        Signed session cookie set by the browser login flow. Used by the
        in-app chat UI. Third-party clients should use `workspaceApiKey`
        instead.

  parameters:
    ChannelHeader:
      name: X-OpenClaw-Channel
      in: header
      required: false
      schema: { type: string, enum: [lycus] }
    AccountHeader:
      name: X-OpenClaw-Account-Id
      in: header
      required: false
      schema: { type: string, default: default }
    MachineHeader:
      name: X-OpenClaw-Machine-Id
      in: header
      required: false
      schema: { type: string }

  responses:
    BadRequest:
      description: Malformed request.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Unauthorized:
      description: Missing / invalid bearer.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    Forbidden:
      description: Machine disabled.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    NotFound:
      description: Unknown resource.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }
    UnprocessableEntity:
      description: Validation failed.
      content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } }

  schemas:
    Ok:
      type: object
      required: [ok]
      properties:
        ok: { type: boolean, example: true }

    Error:
      type: object
      required: [ok, error]
      properties:
        ok: { type: boolean, example: false }
        error: { type: string, example: "missing bearer token" }

    Capabilities:
      type: object
      properties:
        chatTypes:
          type: array
          items: { type: string, enum: [direct, group] }
        markdown: { type: boolean }
        blockStreaming: { type: boolean }

    PairRequest:
      type: object
      required: [channelId, accountId, machineId]
      properties:
        channelId: { type: string, enum: [lycus] }
        accountId: { type: string, example: default }
        machineId: { type: string, example: claw-mac-447f }
        machineName: { type: string, nullable: true }
        capabilities: { $ref: "#/components/schemas/Capabilities" }

    PairResponse:
      type: object
      required: [ok, paired, accountId, machineId, socketUrl]
      properties:
        ok: { type: boolean }
        paired: { type: boolean }
        accountId: { type: string }
        machineId: { type: string }
        socketUrl:
          type: string
          format: uri
          description: WebSocket URL the plugin should connect to (scheme wss/ws).
          example: "wss://claw-management.example.com/cable"

    AssistantReply:
      type: object
      required: [accountId, machineId, conversationId, text]
      properties:
        accountId: { type: string }
        machineId: { type: string }
        conversationId:
          type: string
          description: The `conversation_id` string used on the wire.
        text: { type: string }
        threadId: { type: string, nullable: true }
        replyToId:
          type: string
          nullable: true
          description: messageId of the user event being answered — used to ACK that event.
        replyId:
          type: string
          nullable: true
          description: |
            Idempotency key. If omitted the server derives a stable id from
            sha256(machineId|conversationId|replyToId|text).
        kind:
          type: string
          nullable: true
          example: final

    Indicator:
      type: object
      required: [accountId, machineId, conversationId, type]
      properties:
        accountId: { type: string }
        machineId: { type: string }
        conversationId: { type: string }
        type: { type: string, enum: [typing, typing_stopped, error] }
        threadId: { type: string, nullable: true }
        text:
          type: string
          nullable: true
          description: Human-readable body for `type=error`.

    OutboundEvent:
      type: object
      description: Shape the server broadcasts to the plugin over WebSocket.
      required: [type, eventId, messageId, conversationId, accountId, timestamp]
      properties:
        type: { type: string, enum: [message] }
        eventId: { type: string, example: "evt_8f2c1d0b-..." }
        messageId: { type: string, example: "msg_456" }
        conversationId: { type: string, example: "cnv_…" }
        accountId: { type: string }
        text: { type: string }
        chatType: { type: string, enum: [direct, group], default: direct }
        from: { type: string }
        senderId:
          type: string
          description: Public id of the sending user (`usr_<uuid>`) — the authenticated human who posted the message.
          example: "usr_b7d0998a-…"
        senderUserId:
          type: string
          nullable: true
          description: Always the same as senderId when present; future-proofing for non-user senders.
        senderName: { type: string, nullable: true }
        threadId: { type: string, nullable: true }
        timestamp: { type: string, format: date-time }

    PairingTicket:
      type: object
      required: [id, status, pending, expired, paired, token, suggested_machine_id, short_code, expires_at]
      properties:
        id: { type: integer }
        status: { type: string, enum: [pending, paired, expired, cancelled] }
        pending: { type: boolean }
        expired: { type: boolean }
        paired: { type: boolean }
        token: { type: string, description: "Value to paste into openclaw.json as machineToken." }
        suggested_machine_id: { type: string, description: "Suggested value for the plugin's machineId." }
        short_code: { type: string, example: "AB12-CD34" }
        expires_at: { type: string, format: date-time }
        machine:
          type: object
          nullable: true
          properties:
            id: { type: integer }
            account_id: { type: string }
            machine_id: { type: string }
            machine_name: { type: string, nullable: true }
            paired_at: { type: string, format: date-time, nullable: true }
        conversation:
          type: object
          nullable: true
          properties:
            id: { type: integer }

    Conversation:
      type: object
      required: [id, conversation_id, title, chat_type, machine, updated_at]
      properties:
        id: { type: integer }
        conversation_id:
          type: string
          description: Stable string id used by the plugin as `conversationId`.
        title: { type: string }
        chat_type: { type: string, enum: [direct, group] }
        machine:
          type: object
          required: [id, account_id, machine_id, status]
          properties:
            id: { type: integer }
            account_id: { type: string }
            machine_id: { type: string }
            machine_name: { type: string, nullable: true }
            status: { type: string, enum: [online, offline, disabled] }
        updated_at: { type: string, format: date-time }

    CreateConversationRequest:
      type: object
      properties:
        title: { type: string, nullable: true }
        chat_type: { type: string, enum: [direct, group], default: direct }
        machine_id:
          type: integer
          nullable: true
          description: Primary key of OpenclawMachine. Omit to use the first paired machine.
        user_sender_id: { type: string, default: local-user }
        user_sender_name: { type: string, default: You }

    TimelineEntry:
      type: object
      required: [id, role, text, created_at]
      properties:
        id: { type: string }
        role: { type: string, enum: [user, assistant, system] }
        text: { type: string }
        status:
          type: string
          nullable: true
          description: On `role=user` — the underlying OpenclawChannelEvent.status.
        message_id: { type: string, nullable: true }
        reply_to_id: { type: string, nullable: true }
        sender_id: { type: string, nullable: true }
        sender_name: { type: string, nullable: true }
        created_at: { type: string, format: date-time }

  examples:
    PairRequestExample:
      value:
        channelId: lycus
        accountId: default
        machineId: claw-mac-447f
        machineName: "Engineering 2"
        capabilities:
          chatTypes: [direct, group]
          markdown: true
          blockStreaming: true

    PairResponseExample:
      value:
        ok: true
        paired: true
        accountId: default
        machineId: claw-mac-447f
        socketUrl: "wss://claw-management.example.com/cable"

    AssistantReplyExample:
      value:
        accountId: default
        machineId: claw-mac-447f
        conversationId: "user_123"
        text: "Assistant reply"
        threadId: null
        replyToId: "msg_456"
        replyId: "rep_789"
        kind: final
