openapi: 3.1.0
info:
  title: Runlayer Custom Scanner Protocol
  version: "1.0.0"
  summary: The HTTP endpoint you host so Runlayer can ask your scanner for an allow/deny verdict.
  description: |
    A custom scanner is an HTTPS endpoint that you run. For each scanned event,
    Runlayer sends one signed `POST` containing a JSON **prompt frame**, and your
    endpoint answers with a verdict: `allow` or `deny`.

    Runlayer keeps enforcement. A `deny` goes through the same block / alert
    flow, audit log, and incidents as Runlayer's built-in scanners. Whether
    a deny blocks or only alerts is set in Runlayer, per scanner.

    ## What gets scanned

    You choose the directions per scanner in Runlayer:

    | Direction | Event | `messages` contains |
    | --- | --- | --- |
    | `input` | An MCP tool call, before it reaches the connector | one assistant message with a `tool_use` block |
    | `output` | An MCP tool result, before it reaches the client | the `tool_use` message, then a user message with a `tool_result` block |
    | `input` | An inbound prompt from a monitored agent | the prompt's messages, using the same block types |

    The **Test connection** button in Runlayer sends a frame with
    `source.application = "config-test"` and a single `text` block. It is
    never a real user's traffic. Return `allow` for it.

    ## Verdicts

    Respond with HTTP `200` and a JSON body:

    - `{"action": "allow"}` lets the call through.
    - `{"action": "deny", "deny_reason": "...", "reference_id": "..."}` flags it.
      Both extra fields are optional and advisory. `deny_reason` longer than
      500 characters is truncated. A `reference_id` that does not match
      `^[A-Za-z0-9._:/-]{1,50}$` is dropped, not rejected.

    **Anything else is a scanner failure**: any non-200 status (redirects
    included, since Runlayer does not follow them), a body that is not a valid
    verdict, a body over 64 KiB, a timeout, or a connection error.

    Each scanner has a failure mode, set once on the scanner (only its action
    can be overridden per scope):

    - **fail open** (`allow`, the default): the call proceeds.
    - **fail closed** (`block`): the failure counts as a deny. The call is
      blocked when the scanner's action for that call is Block; with Alert, or
      when the client is in Monitor mode, it is recorded and the call proceeds.

    Every failure is written to the Runlayer audit log with its cause and
    latency, and shown on the scanner's settings page.

    ## Timeouts and retries

    The timeout is configured per scanner, from 1 to 10000 ms (default 5000).
    It can be lowered further by the workspace's overall scan budget. If the TCP
    connection cannot be established, Runlayer retries once after 100 ms, within
    the same timeout. Nothing else is retried, so treat each `webhook-id` as
    one decision.

    Keep your handler fast. Your scanner sits on the request path of every
    covered tool call.

    ## Verifying requests (Standard Webhooks)

    Requests are signed using the [Standard Webhooks](https://www.standardwebhooks.com/)
    scheme, so any Standard Webhooks library can verify them.

    Runlayer shows the signing secret once, when you create the scanner and
    whenever you rotate it. It looks like `whsec_<base64>`.

    To verify a request:

    1. Take the base64 part after `whsec_` and decode it. That is the HMAC key.
    2. Build the signed content `{webhook-id}.{webhook-timestamp}.{raw request body}`.
       Use the exact body bytes received, not re-serialized JSON.
    3. Compute `base64(HMAC-SHA256(key, signed content))`.
    4. `webhook-signature` is a space-separated list of `v1,<signature>` entries.
       Accept the request if any entry matches, using a constant-time compare.
    5. Reject the request if `webhook-timestamp` (Unix seconds) is more than 5
       minutes from your clock.

    The timestamp check limits how long a captured request stays valid, but it
    does not stop repeats inside that window. A request verifies until 5
    minutes after its `webhook-timestamp`, and a timestamp up to 5 minutes
    ahead of your clock is accepted, so one request can verify for up to 10
    minutes after you first see it. If your handler has side effects (writes,
    alerts, tickets), store each `webhook-id` until `webhook-timestamp` plus 5
    minutes (or 10 minutes from first receipt) and ignore repeats.

    **Secret rotation.** When an admin rotates the secret with "keep previous"
    on, Runlayer signs with both the new and the old secret for 5 minutes, so
    `webhook-signature` carries two entries. Switch your verifier to the new
    secret any time within that window, and no request is rejected.

    ## Custom headers

    An admin can add static headers, for example an API key for your gateway.
    They are stored encrypted and sent on every request. They cannot override
    `Content-Type`, `User-Agent`, `Accept-Encoding`, `Host`, `Content-Length`,
    `Transfer-Encoding`, `Connection`, or the three `webhook-*` headers.

    ## Network

    - The endpoint must use `https`.
    - Credentials in the URL (`https://user:pass@...`) are not accepted. Use a custom header instead.
    - Private-network (RFC 1918) addresses are allowed, so a scanner can run
      next to a self-hosted Runlayer.
    - Loopback, link-local, and cloud-metadata addresses are refused.
    - Responses must not be compressed. Runlayer sends `Accept-Encoding: identity`.
  contact:
    name: Runlayer Support
    url: https://runlayer.com
servers:
  - url: "{scannerUrl}"
    description: Your scanner endpoint, exactly as entered in Runlayer.
    variables:
      scannerUrl:
        default: https://scanner.example.com/verdict
        description: The full HTTPS URL configured for the scanner. Runlayer POSTs to it as-is.
paths:
  /:
    post:
      operationId: scanVerdict
      summary: Return an allow/deny verdict for one scanned event
      description: |
        Called once per scanned event, per scanner. The path is the configured
        endpoint URL itself; Runlayer does not append anything to it.
      parameters:
        - $ref: "#/components/parameters/WebhookId"
        - $ref: "#/components/parameters/WebhookTimestamp"
        - $ref: "#/components/parameters/WebhookSignature"
        - name: User-Agent
          in: header
          required: true
          schema:
            type: string
            const: runlayer-scanner/1
        - name: Accept-Encoding
          in: header
          required: true
          schema:
            type: string
            const: identity
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PromptFrame"
            examples:
              toolInput:
                $ref: "#/components/examples/ToolInputFrame"
              toolOutput:
                $ref: "#/components/examples/ToolOutputFrame"
              connectionTest:
                $ref: "#/components/examples/ConnectionTestFrame"
      responses:
        "200":
          description: A verdict. Any other status is treated as a scanner failure.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Verdict"
              examples:
                allow:
                  summary: Let the call through
                  value:
                    action: allow
                deny:
                  summary: Flag the call
                  value:
                    action: deny
                    deny_reason: Response contains a customer account number.
                    reference_id: case-8f3a21
        default:
          description: |
            Any non-200 response, including 3xx, is a scanner failure, and the
            scanner's failure mode applies. The body is ignored.
components:
  parameters:
    WebhookId:
      name: webhook-id
      in: header
      required: true
      description: Unique per request. Equal to the frame's `request_id`. Part of the signed content.
      schema:
        type: string
        example: scan_7c0f3b2a9d4e4e1b8a6f2c5d1e0a9b37
    WebhookTimestamp:
      name: webhook-timestamp
      in: header
      required: true
      description: Unix time in seconds when the request was signed. Part of the signed content.
      schema:
        type: string
        pattern: "^[0-9]+$"
        example: "1790000000"
    WebhookSignature:
      name: webhook-signature
      in: header
      required: true
      description: |
        Space-separated `v1,<base64 HMAC-SHA256>` entries. Normally one entry,
        or two during the 5-minute window after a secret rotation. Accept if any
        entry verifies.
      schema:
        type: string
        pattern: "^v1,[A-Za-z0-9+/=]+( v1,[A-Za-z0-9+/=]+)*$"
        example: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
  schemas:
    PromptFrame:
      type: object
      description: One scanned event. New optional fields may be added; ignore fields you do not recognize.
      required:
        - type
        - request_id
        - tenant_id
        - actor
        - source
        - messages
        - metadata
      properties:
        type:
          type: string
          const: prompt
        request_id:
          type: string
          description: Unique per request. Same value as the `webhook-id` header. Starts with `scan_` for live traffic and `test_` for connection tests. Log it to correlate with Runlayer's audit log.
        tenant_id:
          type: string
          format: uuid
          description: The Runlayer organization the traffic belongs to.
        actor:
          $ref: "#/components/schemas/Actor"
        source:
          type: object
          required: [application]
          properties:
            application:
              type: string
              description: The MCP client or agent that originated the traffic (for example `cursor`), or `config-test` for a connection test.
        messages:
          type: array
          description: The content to judge, as role-tagged messages of content blocks.
          items:
            $ref: "#/components/schemas/Message"
        session_id:
          type: ["string", "null"]
          description: The Runlayer session the event belongs to, when known.
        model:
          type: ["string", "null"]
          description: The model involved, when known. Currently always `null` for MCP traffic.
        metadata:
          $ref: "#/components/schemas/FrameMetadata"
    Actor:
      type: object
      required: [type, id]
      properties:
        type:
          type: string
          description: "`user` for a person, otherwise the kind of non-human principal (for example an agent account)."
          examples: [user, agent]
        id:
          type: string
          description: Stable Runlayer id of the actor.
        email_address:
          type: ["string", "null"]
          format: email
          description: Present for users.
    FrameMetadata:
      type: object
      description: |
        String-valued context. Keys are omitted when unknown. More keys may be
        added later.
      additionalProperties:
        type: string
      properties:
        direction:
          type: string
          enum: [input, output]
        scan_path:
          type: string
          description: "`hook` for traffic reported by a client hook, `other` for traffic through the Runlayer MCP gateway, `config_test` for a connection test."
          examples: [other, hook, config_test]
        tool_name:
          type: string
        server_id:
          type: string
          description: Runlayer connector id.
        server_name:
          type: string
          description: Runlayer connector name.
        client_name:
          type: string
        truncated:
          type: string
          const: "true"
          description: |
            Present whenever the workspace's TRUNCATE payload budget applies,
            even if nothing was cut. Any part over the budget arrives cut:
            an over-size `tool_use` input becomes `{}` followed by a `text`
            block carrying the start of the original JSON (base64 content
            stripped first); an over-size prompt is replaced by one `user`
            message; an over-size tool result is cut to the budget.
    Message:
      type: object
      required: [role, content]
      properties:
        role:
          type: string
          enum: [user, assistant, system]
        content:
          type: array
          items:
            $ref: "#/components/schemas/ContentBlock"
    ContentBlock:
      description: Treat an unrecognized block type as opaque content, not as an error.
      oneOf:
        - $ref: "#/components/schemas/TextBlock"
        - $ref: "#/components/schemas/ToolUseBlock"
        - $ref: "#/components/schemas/ToolResultBlock"
      discriminator:
        propertyName: type
        mapping:
          text: "#/components/schemas/TextBlock"
          tool_use: "#/components/schemas/ToolUseBlock"
          tool_result: "#/components/schemas/ToolResultBlock"
    TextBlock:
      type: object
      required: [type, text]
      properties:
        type:
          type: string
          const: text
        text:
          type: string
    ToolUseBlock:
      type: object
      description: An MCP tool call as the client issued it.
      required: [type, id, tool_name, input]
      properties:
        type:
          type: string
          const: tool_use
        id:
          type: string
          description: Call id. A later `tool_result` references it as `tool_use_id`.
        tool_name:
          type: string
        input:
          type: object
          description: The tool arguments, as sent by the client.
          additionalProperties: true
    ToolResultBlock:
      type: object
      description: The connector's response to a tool call, as text. Binary content is not included.
      required: [type, tool_use_id, tool_name, content]
      properties:
        type:
          type: string
          const: tool_result
        tool_use_id:
          type: string
        tool_name:
          type: string
        content:
          type: array
          items:
            $ref: "#/components/schemas/TextBlock"
        is_error:
          type: boolean
          default: false
          description: True when the tool call failed and `content` is the error message.
    Verdict:
      type: object
      required: [action]
      properties:
        action:
          type: string
          enum: [allow, deny]
        deny_reason:
          type: string
          maxLength: 500
          description: Shown to admins in the audit log and incident. Ignored on `allow`. Truncated beyond 500 characters.
        reference_id:
          type: string
          maxLength: 50
          pattern: "^[A-Za-z0-9._:/-]{1,50}$"
          description: Your id for this decision, recorded in Runlayer's audit log. Ignored on `allow`. Dropped if it does not match the pattern.
  examples:
    ToolInputFrame:
      summary: Tool call (input direction)
      value:
        type: prompt
        request_id: scan_7c0f3b2a9d4e4e1b8a6f2c5d1e0a9b37
        tenant_id: 3f6c1a52-9b7e-4d0a-8c21-5e4f7a9b0c13
        actor:
          type: user
          id: 9a1d7e44-2b3c-4f5a-8e6d-0c1b2a3f4e5d
          email_address: dev@example.com
        source:
          application: cursor
        messages:
          - role: assistant
            content:
              - type: tool_use
                id: 0e8a3c5f-6b1d-4a2e-9f7c-1d3b5a7e9c2f
                tool_name: create_issue
                input:
                  repo: acme/payments
                  title: Rotate the prod DB password
                  body: "Current value is hunter2"
        session_id: 5d2e8f1a-3c4b-4e6d-9a7f-2b1c0d9e8f7a
        model: null
        metadata:
          direction: input
          scan_path: other
          tool_name: create_issue
          server_id: 1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e
          server_name: github
          client_name: cursor
    ToolOutputFrame:
      summary: Tool result (output direction)
      value:
        type: prompt
        request_id: scan_d41e9b7c2a3f4c5d8e6f7a8b9c0d1e2f
        tenant_id: 3f6c1a52-9b7e-4d0a-8c21-5e4f7a9b0c13
        actor:
          type: user
          id: 9a1d7e44-2b3c-4f5a-8e6d-0c1b2a3f4e5d
          email_address: dev@example.com
        source:
          application: cursor
        messages:
          - role: assistant
            content:
              - type: tool_use
                id: 7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918
                tool_name: query_customers
                input:
                  name: Jane Doe
          - role: user
            content:
              - type: tool_result
                tool_use_id: 7f6e5d4c-3b2a-4190-8f7e-6d5c4b3a2918
                tool_name: query_customers
                content:
                  - type: text
                    text: '{"name":"Jane Doe","account":"4111-1111-1111-1111"}'
                is_error: false
        session_id: 5d2e8f1a-3c4b-4e6d-9a7f-2b1c0d9e8f7a
        model: null
        metadata:
          direction: output
          scan_path: other
          tool_name: query_customers
          server_id: 6c5b4a39-2817-4f6e-9d8c-7b6a59483726
          server_name: crm
          client_name: cursor
    ConnectionTestFrame:
      summary: Connection test from the Runlayer settings page
      value:
        type: prompt
        request_id: test_0a1b2c3d4e5f60718293a4b5c6d7e8f9
        tenant_id: 3f6c1a52-9b7e-4d0a-8c21-5e4f7a9b0c13
        actor:
          type: user
          id: 9a1d7e44-2b3c-4f5a-8e6d-0c1b2a3f4e5d
          email_address: admin@example.com
        source:
          application: config-test
        messages:
          - role: user
            content:
              - type: text
                text: Runlayer connection test
        session_id: null
        model: null
        metadata:
          direction: input
          scan_path: config_test
