openapi: 3.1.0
info:
  title: Banana Peel Browser Agent API
  version: 2026-08-13
  summary: Smart routing for browser agents — OpenAI-compatible Responses API.
  description: "OpenAI Responses–compatible API for browser agents with smart routing. OpenAI SDK drop-in: baseURL `{base}/api/v1`, model `banana-peel`, Authorization Bearer. Send a task with `routing` (smart = production; learning / learning-all = teach the router the best path — calibrate the brain so later smart runs pick the winner; named runner / category / list; defaults to smart), and Banana Peel executes on the best available runner (Browserbase, Steel, Browser Use, Skyvern, Hyperbrowser, Playwright, Deck — 250+ executable runner combinations across 50+ single runners — hosted agents, infrastructure providers, extraction engines and OSS frameworks). Thin Anthropic Messages adapter at POST /v1/messages. Category values SOC2/HIPAA/EU select MVP fixed runner pools (not full compliance attestation). Provider SDK wrappers (Browserbase/Steel/…) are separate from the OpenAI Responses surface.\n\nBase URL: https://bananapeel.com\nOpenAI compat docs: https://bananapeel.com/docs/api/openai\nAnthropic adapter docs: https://bananapeel.com/docs/api/anthropic\nAgent guide (golden path): https://bananapeel.com/docs/agents\nAgent-native signup: https://bananapeel.com/docs/agents/signup — `npx -y @banana-peel/cli init --agent --json`. Do not send agents to /keys.\nMCP server: https://bananapeel.com/docs/mcp\nllms.txt: https://bananapeel.com/llms.txt\nFull agent guide: https://bananapeel.com/llms-full.txt"
  contact:
    name: Banana Peel
    url: "https://bananapeel.com"
  termsOfService: "https://bananapeel.com/terms"
servers:
  - url: "https://bananapeel.com/api"
    description: Production (set BANANA_PEEL_BASE to override)
  - url: /api
    description: Same-origin relative (local dev / custom host)
  - url: "https://bananapeel.com"
    description: "Bare-origin alias: /v1/* and /v2/* rewrite to /api/v1/* and /api/v2/* (so SDKs configured with the origin as baseURL still reach the API — e.g. /v1/sessions ≡ /api/v1/sessions). Always answers JSON, never HTML."
externalDocs:
  description: For AI agents — integration guide
  url: "https://bananapeel.com/docs/agents"
tags:
  - name: Browser Agent API
    description: Create and inspect browser-agent runs
  - name: Routing
    description: "Smart routing, named runners, and categories — `routing` selects; `banana_peel.runner` reports who executed"
  - name: Vault
    description: "Credential vault management — create/list/update/revoke login credentials for use on runs via `credential_id`. Secret material is write-only: no endpoint ever returns it."
  - name: Webhooks
    description: Outbound signed HTTP callbacks for Responses. At-least-once delivery; dedupe on event.id. Dual-signature 24h rotation grace. Distinct from Stripe inbound billing webhooks. See /docs/api/webhooks.
  - name: Agent signup
    description: Anonymous agent free-account creation + claim-later
  - name: Wrappers
    description: Drop-in compatibility for provider SDKs
  - name: Platform
    description: "Public platform health — ratios and timestamps only, never absolute volume"
paths:
  "/v1/agent-signup/challenge":
    get:
      tags:
        - Agent signup
      operationId: agentSignupChallenge
      summary: Machine-solvable signup challenge (not a human CAPTCHA)
      description: "Short-TTL (2 min), one-use, IP-bound puzzle. Solve `puzzle` then POST /v1/agent-signup. See https://bananapeel.com/docs/agents/signup."
      responses:
        "200":
          description: "challenge_id, instruction, puzzle, expires_in_seconds"
        "429":
          description: Rate limited
  "/v1/agent-signup":
    post:
      tags:
        - Agent signup
      operationId: agentSignup
      summary: Create an anonymous free Banana Peel account for an agent
      description: "Prefer `npx -y @banana-peel/cli init --agent --json` (stores the key locally; never paste it into chat). This endpoint is what the CLI calls. Requires a solved challenge. Creates the same free account a Console signup would (welcome credits, API key). api_key is returned once. Store it locally; never put it in LLM context. Share claim_url with the human. Do not send agents to /keys. Repeat signups from the same IP reuse the existing unclaimed account instead of granting another $10."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              type: object
              required:
                - challenge_id
                - challenge_answer
              properties:
                challenge_id:
                  type: string
                challenge_answer:
                  type: string
                client:
                  type: string
                  enum:
                    - cli
                    - cursor
                    - claude-code
                    - codex
                    - vscode
                    - windsurf
                    - claude-desktop
                email:
                  type: string
                  description: "Optional. Default agent_*@agents.bananapeel.com handle."
      responses:
        "200":
          description: "status=ready, account_id, api_key (once), claim_url, free_credit_usd, expires_at"
        "400":
          description: Missing/expired/wrong challenge
        "429":
          description: "Rate limited / welcome already granted to this IP in 24h (unclaimed accounts are reused, not 429)"
  "/v1/estimate":
    post:
      tags:
        - Browser Agent API
      operationId: estimateResponse
      summary: Dry-run cost estimate (no browser run)
      description: "Resolve likely runner + catalog cost estimate. Optional normalize add-on (+$0.10) included when requested. Does not start a run. The response `budget` section reflects the spend ceiling the run would be dispatched under (`max_cost_usd` from the request, the $20 Learning default, or the routing plan budget)."
      security:
        - bearerAuth: []
        - apiKey: []
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/CreateResponse"
      responses:
        "200":
          description: Catalog estimate
        "400":
          description: Invalid request
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/responses":
    post:
      tags:
        - Browser Agent API
      operationId: createResponse
      summary: Create a browser-agent response
      description: "Create a browser-agent run. OpenAI Responses–compatible shape with Banana Peel details under `banana_peel`. `routing` defaults to `smart` when omitted. Provide `input` and/or `url`. `stream: true` returns OpenAI-style SSE. `background: true` (or Prefer: respond-async) returns 202 + poll (or register POST /v1/webhooks and receive signed events — prefer webhooks at production scale). `routing: \"learning\"` and `routing: \"learning-all\"` exist to teach the router the best path (calibrate the brain on this domain/task so later `smart` runs pick the winner) — they always return 202 and run in the background; Learning fan-out execution has a per-instance concurrency ceiling — saturated creates are refused with 429 `learning_capacity` + `Retry-After` (nothing is created or billed) — and each account may run a limited number of Learning-family runs concurrently (default 3, shared across both modes); creates beyond that are refused with 429 `rate_limited` + `Retry-After` (nothing is created or billed). `learning-all` sweeps the full catalog (250+ options) with no early stop — every dispatchable option attempts exactly once (catalog-only coming-soon options are skipped, never billed); every attempt bills (most expensive mode). Every run honors a spend ceiling: `max_cost_usd` (any mode), the $20 Learning default, or the $20 learning-all default — once executed attempts reach it, no NEW attempts are dispatched (in-flight ones finish and bill) and the run ends with error code `budget_exhausted` and partial results under `banana_peel.routing_attempts`. Run TIME is caller-controlled: optional `time_budget_ms` caps smart/learning-family runs; omitted = UNCAPPED (no run-level deadline — the task runs to completion; only true-hang protection stops it; prefer `background: true`). Optional `normalize` + `output_schema` (or OpenAI `text.format` / `response_format`) reshapes output (+$0.10 flat, charged only when the pass executes; the outcome is echoed under `banana_peel.normalization`). A supplied `output_schema` is a hard contract: success populates exactly those keys or the run fails with error code `schema_violation` (add-on not billed). Without a schema, `banana_peel.answer` / `answer_present` / `evidence` is the guaranteed stable read path. Credential custody is enforced: a vault `credential_id` applies its stored custody policy, and the optional `custody` field applies one to inline credentials — the runner candidate set is hard-filtered BEFORE routing, an unsatisfiable policy fails with 409 `custody_unsatisfiable` (nothing billed), and enforcement is echoed under `banana_peel.custody`. Idempotent retries: send an `Idempotency-Key` header (or body `idempotency_key`) — reuse within 24h with an identical body replays the original run (`Idempotent-Replayed: true`, original `created_at`); a different body is refused with 422 `idempotency_conflict`; a duplicate racing the original create gets 409 `idempotency_in_flight` + Retry-After. Runs debit credits: runner listed price, plus a 5% platform fee after your first top-up (0% while on trial credits)."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: "Client retry key (Stripe / IETF idempotency-key style; header names are case-insensitive). Equivalent to the body `idempotency_key` — if both are sent they must match (400 `idempotency_key_mismatch` otherwise). Keys are scoped to the workspace, shared across every run-creating endpoint (/v1/responses, /v1/messages, wrapper creates), and expire 24 hours after first use. Reuse with an IDENTICAL body (delivery-mode fields `stream`/`background` excluded) replays the original run — original `created_at`, `Idempotent-Replayed: true` response header, 200 when terminal / 202 while running. Reuse with a DIFFERENT body is refused with 422 `idempotency_conflict`. A duplicate sent while the original create is still being admitted is refused with 409 `idempotency_in_flight` + `Retry-After: 1` — retry to receive the original run. A create refused before its run exists (validation, credits, custody, capacity) does not consume the key."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/CreateResponse"
            examples:
              minimal:
                summary: Minimal create
                value:
                  model: banana-peel
                  input: "Extract the title from https://example.com"
                  routing: smart
              with_normalize:
                summary: Output normalization (+$0.10)
                value:
                  model: banana-peel
                  input: "Extract the latest order from https://example.com/orders"
                  routing: smart
                  normalize: true
                  output_schema:
                    type: object
                    properties:
                      order_id:
                        type: string
                      total:
                        type: string
                    required:
                      - order_id
                      - total
      responses:
        "200":
          description: "Completed / failed / blocked JSON, or text/event-stream when stream:true"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/ResponseObject"
            "text/event-stream":
              schema:
                type: string
                description: OpenAI Responses SSE events
        "202":
          description: Queued / still running (background)
        "400":
          description: Invalid request
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "402":
          description: Insufficient credits
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "409":
          description: "Conflict — nothing was created and nothing was billed. Error code `custody_unsatisfiable`: the credential custody policy and the requested routing have no runner in common (loosen the policy in Console → Vault or PATCH /v1/credentials/{id}, or route within the allowed set). Error code `idempotency_in_flight`: a request with the same Idempotency-Key is still being admitted — honor `Retry-After` (~1s) and resend the identical request to receive the original run."
          headers:
            Retry-After:
              description: "Present on `idempotency_in_flight`: seconds to wait before resending the identical request."
              schema:
                type: integer
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "422":
          description: "Idempotency conflict (error code `idempotency_conflict`): the Idempotency-Key was already used with DIFFERENT request parameters within its 24h window — almost always a client bug (a retry loop mutating its payload). Use a fresh key for the new request, or resend the exact original body to replay the original run. Nothing was created and nothing was billed."
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "429":
          description: "Backpressure — no run was created and nothing was billed; honor the `Retry-After` header (seconds) and retry. Error code `rate_limited`: this account already has the maximum concurrent Learning-family runs in flight (`\"learning\"` + `\"learning-all\"`, default 3). Error code `learning_capacity`: Learning fan-out execution capacity is saturated on this instance."
          headers:
            Retry-After:
              description: Seconds to wait before retrying the create.
              schema:
                type: integer
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "502":
          description: Run failed at transport layer
    get:
      tags:
        - Browser Agent API
      operationId: listResponses
      summary: List recent responses
      description: OpenAI-shaped list of recent Responses for this workspace (same underlying runs as GET /v1/runs).
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: "List object with data[] of ResponseObject"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/messages":
    post:
      tags:
        - Browser Agent API
      operationId: createAnthropicMessage
      summary: Anthropic Messages adapter (thin)
      description: "Maps Anthropic Messages create onto the Responses browser-agent pipeline. Point Anthropic SDK baseURL at `{base}/api` with x-api-key bp_live_…. Honors the `Idempotency-Key` header / body `idempotency_key` with the same key space and replay / 422-conflict / 409-in-flight semantics as POST /v1/responses (errors here are Anthropic-shaped). Not a full Anthropic API. See /docs/api/anthropic."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema:
            type: string
            maxLength: 255
          description: "Client retry key — same key space and semantics as POST /v1/responses: identical-body reuse within 24h replays the original run (`Idempotent-Replayed: true`, original `created_at`); different body → 422 `idempotency_conflict`; duplicate racing the original create → 409 `idempotency_in_flight` + Retry-After. If the body also carries `idempotency_key`, the values must match (400 `idempotency_key_mismatch`)."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              type: object
              properties:
                model:
                  type: string
                  default: banana-peel
                max_tokens:
                  type: integer
                system: {}
                messages:
                  type: array
                routing:
                  description: Banana Peel routing (default smart)
                background:
                  type: boolean
                normalize:
                  type: boolean
                output_schema:
                  type: object
                  additionalProperties: true
            example:
              model: banana-peel
              max_tokens: 1024
              messages:
                - role: user
                  content: "Extract the title from https://example.com"
              routing: smart
      responses:
        "200":
          description: "Anthropic-shaped message (+ banana_peel, response_id)"
        "202":
          description: Background / in progress
        "400":
          description: "Invalid request (Anthropic-shaped envelope). Includes `idempotency_key_mismatch` when the Idempotency-Key header and body `idempotency_key` are both set but differ."
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "409":
          description: "In-flight idempotency duplicate (code `idempotency_in_flight`, Anthropic-shaped envelope): a request with the same Idempotency-Key is still being admitted. Honor `Retry-After` (~1s) and resend the identical request to receive the original run. Nothing was created or billed."
          headers:
            Retry-After:
              description: Seconds to wait before resending the identical request.
              schema:
                type: integer
        "422":
          description: "Idempotency conflict (code `idempotency_conflict`, Anthropic-shaped envelope): the Idempotency-Key was already used with different request parameters within its 24h window. Use a fresh key or resend the exact original body. Nothing was created or billed."
        "429":
          description: "Backpressure — same limits as POST /v1/responses (per-account Learning-family concurrency cap `rate_limited`, default 3; per-instance Learning fan-out ceiling `learning_capacity`). Nothing was created or billed. The envelope is Anthropic-shaped (`{type:\"error\",error:{type:\"rate_limit_error\",message,code}}`) and always carries the `Retry-After` header (seconds) — honor it and back off."
          headers:
            Retry-After:
              description: Seconds to wait before retrying the create.
              schema:
                type: integer
  "/v1/responses/{id}":
    get:
      tags:
        - Browser Agent API
      operationId: getResponse
      summary: Get a response by id
      description: "Poll until terminal. When a target site asks for MFA/2FA/OTP, the run pauses immediately (`status: requires_action`) — it does not keep trying other runners. `required_action.type = submit_input` with kind, prompt, channel (totp|sms|email|unknown), message, and submit {method, path}. Captcha / bot-detection gates never pause — they solve, re-route, or end `blocked` (reason bot_challenge). Submit the code via POST /v1/responses/{id}/input `{ \"code\": \"123456\" }`. At production scale prefer POST /v1/webhooks (`response.requires_action` is time-sensitive) instead of spinning GET."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Response
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/ResponseObject"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "404":
          description: Not found
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
        - Browser Agent API
      operationId: deleteResponse
      summary: Delete a response and purge copied artifacts
      description: "Immediately deletes copied screenshots and artifact blobs for this run and tombstones the response. Subsequent GET returns 404. Unknown ids and other-workspace ids are also 404. Copied documents otherwise expire after 7 days (Enterprise: 24h / 7d / 30d). Provider-hosted session replays are not copied — see /docs/security/retention."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Deleted
          content:
            "application/json":
              schema:
                type: object
                properties:
                  id:
                    type: string
                  object:
                    type: string
                    const: response
                  deleted:
                    type: boolean
                    const: true
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "404":
          description: "Not found (unknown, already deleted, or another workspace)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/responses/{id}/artifacts/{artifactId}":
    get:
      tags:
        - Browser Agent API
      operationId: getResponseArtifact
      summary: Download a captured run artifact
      description: "Retrieve the bytes of a file the run captured (downloaded files, screenshots). Responds 302 with a short-lived signed URL (~10 min TTL) for cloud-stored artifacts, or streams the bytes directly. Artifact ids are listed under `banana_peel.artifacts[].id`; the `url` on each artifact already points here. Scoped to the API key's workspace — other-workspace and unknown ids return 404. Artifacts follow the run retention window (410 after expiry) and are purged by DELETE /v1/responses/{id}."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: artifactId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "Artifact bytes (Content-Disposition: attachment)"
          content:
            "application/octet-stream":
              schema:
                type: string
                format: binary
        "302":
          description: Redirect to a short-lived signed URL
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "404":
          description: "Unknown response/artifact id, another workspace, or bytes not captured (see error.message)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "410":
          description: Artifact expired per the retention window
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/responses/{id}/cancel":
    post:
      tags:
        - Browser Agent API
      operationId: cancelResponse
      summary: Cancel an in-flight response
      description: "OpenAI-compatible cancel: returns the response object with `status: \"cancelled\"`. Stops dispatching new runner attempts / Learning batches immediately; runner attempts that already executed still bill (every-attempt billing), nothing new bills after the cancel. Where the upstream adapter supports it (e.g. Skyvern) the in-flight attempt is also aborted best-effort. Cancelling an already-terminal response is a no-op that returns the object unchanged."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "Response object (status: cancelled, or unchanged if already terminal)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/ResponseObject"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "404":
          description: Not found
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/long-tasks":
    post:
      tags:
        - Browser Agent API
      operationId: createLongTask
      summary: DISABLED — create a multi-entity long task
      deprecated: true
      description: "DISABLED: multi-entity orchestration is switched off — this endpoint always returns 403 `feature_disabled`. Every task runs as a single run: submit each task individually via POST /v1/responses (smart routing treats all tasks the same; use an idempotency_key per entity so retries never double-run). An explicit multi-entity request is never silently converted into a single run. Previously created lt_… objects remain readable via the GET endpoints below."
      security:
        - bearerAuth: []
        - apiKey: []
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/LongTaskCreate"
            example:
              input: Download the most recent bill PDF for ALL properties on this account.
              url: "https://myaccount.example-utility.com"
              credential_id: cred_abc123
      responses:
        "403":
          description: "Always — multi-entity orchestration is disabled (`feature_disabled`). Submit tasks individually via POST /v1/responses."
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    get:
      tags:
        - Browser Agent API
      operationId: listLongTasks
      summary: List long tasks (history)
      description: Historical read — orchestration is disabled; previously created long tasks remain readable here. Newest first. No cursor or filters (limit only).
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: List of long task objects (newest first)
          content:
            "application/json":
              schema:
                type: object
                properties:
                  object:
                    type: string
                    example: list
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/LongTaskObject"
                  first_id:
                    type: string
                    nullable: true
                  last_id:
                    type: string
                    nullable: true
                  has_more:
                    type: boolean
  "/v1/long-tasks/{id}":
    get:
      tags:
        - Browser Agent API
      operationId: getLongTask
      summary: "Long task detail (history): per-entity table, counts, gaps, spend"
      description: "Historical read — orchestration is disabled; existing lt_… objects stay readable here. `entities[]` carries per-entity status (pending | dispatched | delivered | unfetchable | exhausted), attempts, the sub-task run id, artifact id when delivered, and the grader-verified reason when unfetchable. `gaps[]` is the honest not-delivered list. `events[]` is the audit log (dispatches, retries, credential cooling, discoveries, pauses)."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Long task object
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/LongTaskObject"
        "404":
          description: Not found
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/long-tasks/{id}/cancel":
    post:
      tags:
        - Browser Agent API
      operationId: cancelLongTask
      summary: Cancel a long task (history — no-op on terminal tasks)
      description: "No new sub-tasks dispatch; in-flight sub-tasks settle through their own cancellation path; the task lands `cancelled` with the per-entity ledger of what was already delivered. Already-terminal tasks return unchanged (no-op). With orchestration disabled, any non-terminal task is drained to `cancelled` automatically, so this is effectively a historical no-op."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "202":
          description: Cancellation requested
        "404":
          description: Not found
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/long-tasks/{id}/runs":
    get:
      tags:
        - Browser Agent API
      operationId: listLongTaskRuns
      summary: "A long task's child runs (history: enumeration + per-entity fetches)"
      description: "Historical read — orchestration is disabled; child runs of existing lt_… objects stay reachable here. Every sub-run the orchestrator dispatched for this task, oldest-first (execution order). Each row carries role (enumeration | entity_fetch | verification — the last is a sampled spot-check of a large claimed entity list), the manifest entity_id it serves, and entity_label when the manifest knows a human label. Children are bounded by the manifest, so this is a plain ordered list (no cursor). The same rows are reachable on the runs list via ?parent_id=."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Child-run list (oldest first)
          content:
            "application/json":
              schema:
                type: object
                properties:
                  object:
                    type: string
                    const: list
                  parent_id:
                    type: string
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        object:
                          type: string
                          const: banana_peel.run
                        created_at:
                          type: string
                        status:
                          type: string
                        runner:
                          type: string
                        cost_usd:
                          type: number
                        timing_ms:
                          type: number
                        parent_id:
                          type: string
                        role:
                          type: string
                          enum:
                            - enumeration
                            - entity_fetch
                            - verification
                        entity_id:
                          type: string
                          description: Manifest entity served (absent on the enumeration step).
                        entity_label:
                          type: string
                  first_id:
                    type: string
                    nullable: true
                  last_id:
                    type: string
                    nullable: true
                  has_more:
                    type: boolean
                    const: false
                required:
                  - object
                  - parent_id
                  - data
        "404":
          description: Not found
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/responses/{id}/input":
    post:
      tags:
        - Browser Agent API
      operationId: submitResponseInput
      summary: Submit MFA/OTP or text for a paused run
      description: "Answer a pending human-input challenge. Body: `{ \"code\": \"123456\" }` (OTP) or `{ \"answer\": \"…\" }`. The OTP is not stored in run history or echoed back. Prefer creating with `background: true` so you can poll + answer while the run waits."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              type: object
              properties:
                code:
                  type: string
                  description: OTP / MFA code
                answer:
                  type: string
                  description: Generic text / confirm reply
                input:
                  type: string
                  description: Alias of answer
            example:
              code: 123456
      responses:
        "200":
          description: "Accepted — keep polling GET /v1/responses/{id}"
        "400":
          description: Missing code/answer
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "409":
          description: Run is not waiting for input
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    get:
      tags:
        - Browser Agent API
      operationId: getResponseInput
      summary: Poll pending interaction (prompt/kind only)
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Interaction view (never includes the secret)
  "/v1/responses/{id}/explanation":
    get:
      tags:
        - Routing
      operationId: getResponseExplanation
      summary: Routing decision card for a smart-routed response
      description: "Why smart routing picked the runner it did: the full candidate set with expected-utility decomposition (success probability, expected cost, expected latency, block risk), the chosen runner + machine-readable reason, whether the pick exploited known winners or explored, how much per-site evidence informed it, and — once the run finished — every attempt with fault attribution (platform faults are excluded from runner scores). The explanation also carries the site profile that informed the decision (`siteProfile`: detected defense — cloudflare | datadome | perimeterx | recaptcha | turnstile | akamai | none | unknown — and its defense cluster), the evidence depth per candidate (`priorLevel`: domain_task | domain | cluster_task | cluster | task | global), neighbor domains whose learned stats were transferred for cold starts (`neighborsUsed`), operator hint ids applied to the decision (`appliedHints`), and the per-domain exploration-quota state (`explorationQuota`). Recorded for `routing: \"smart\"` decisions; pinned runners and chains have none."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Route explanation (decision + outcome when terminal)
        "404":
          description: "Unknown response id, or no explanation recorded for it"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/runs":
    get:
      tags:
        - Browser Agent API
      operationId: listRuns
      summary: List runs for the API key owner
      description: "Newest first (created_at desc). Cursor pagination: pass the last id (or `next_starting_after` when present) as `starting_after`; `has_more` says whether to keep going. Filters are honest post-filters over the indexed owner scan — when a bounded scan window ends before a filtered page fills, the response still has `has_more: true` and `next_starting_after` (the last SCANNED run id, even with an empty `data`), so pagination always makes forward progress and never silently drops history. `status` matches the effective status the API returns (watchdog-expired ghosts filter as failed). `runner` is the executed slug; `routing` is the routing CLASS of the request param (use runner= for a specific pinned slug)."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: starting_after
          in: query
          required: false
          description: Run id cursor — return runs strictly after it in the newest-first order. Unknown ids yield an empty terminal page.
          schema:
            type: string
        - name: created_after
          in: query
          required: false
          description: ISO 8601 — only runs created strictly after this instant.
          schema:
            type: string
            format: date-time
        - name: created_before
          in: query
          required: false
          description: ISO 8601 — only runs created strictly before this instant.
          schema:
            type: string
            format: date-time
        - name: status
          in: query
          required: false
          description: Effective run status (post watchdog fix-up).
          schema:
            type: string
            enum:
              - running
              - needs_human
              - succeeded
              - failed
              - blocked
              - empty
              - cancelled
        - name: runner
          in: query
          required: false
          description: Executed runner slug (exact match).
          schema:
            type: string
        - name: routing
          in: query
          required: false
          description: "Routing class of the requested routing param: runner = pinned single runner, chain = runner array, category = SOC2/HIPAA/EU."
          schema:
            type: string
            enum:
              - smart
              - learning
              - learning-all
              - category
              - runner
              - chain
        - name: parent_id
          in: query
          required: false
          description: "Only the sub-runs of one long task (lt_…). Implies exclude_children=false."
          schema:
            type: string
        - name: exclude_children
          in: query
          required: false
          description: "DEFAULT true: long-task sub-runs are omitted from the top-level list so one fan-out does not bury user-initiated runs. Pass false to interleave them; they are always reachable via parent_id= or GET /v1/long-tasks/{id}/runs."
          schema:
            type: boolean
            default: true
      responses:
        "200":
          description: Run list page (canonical banana_peel.run/v1 rows)
          content:
            "application/json":
              schema:
                type: object
                properties:
                  object:
                    type: string
                    const: list
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/BananaPeelRun"
                  has_more:
                    type: boolean
                  first_id:
                    type: string
                    nullable: true
                  last_id:
                    type: string
                    nullable: true
                  next_starting_after:
                    type: string
                    description: Present when has_more — cursor for the next page. Usually equals last_id; after a filtered scan window it is the last scanned run id (data may even be empty). Always pass it (not last_id) when present.
                required:
                  - object
                  - data
                  - has_more
        "400":
          description: Invalid filter (invalid_list_filter)
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/me":
    get:
      tags:
        - Browser Agent API
      operationId: getAccount
      summary: Account identity for this API key
      description: "Returns account_id, created_at, and claim_url (when the key belongs to an unclaimed agent account). Used by `npx -y @banana-peel/cli whoami`. Works for env keys and credential-file keys. Does not return the API secret. Unclaimed-freeze does not apply — a frozen agent account still resolves."
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Account identity
          content:
            "application/json":
              schema:
                type: object
                properties:
                  object:
                    type: string
                    const: banana_peel.account
                  account_id:
                    type: string
                  created_at:
                    type: string
                    format: date-time
                    nullable: true
                  claim_url:
                    type: string
                    format: uri
                    nullable: true
                  claimable:
                    type: boolean
                  origin:
                    type: string
                    enum:
                      - agent
                      - console
                required:
                  - object
                  - account_id
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/workspace":
    get:
      tags:
        - Browser Agent API
      operationId: getWorkspace
      summary: Workspace retention policy
      description: Returns the document retention window for this API key. Non-enterprise workspaces are fixed at 7 days. See /docs/security/retention.
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Workspace retention view
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
        - Browser Agent API
      operationId: updateWorkspace
      summary: Set Enterprise retention (24h / 7d / 30d)
      description: "Enterprise self-serve: body `{ \"retention\": \"24h\"|\"7d\"|\"30d\" }` or `{ \"retention_days\": 1|7|30 }`. Non-enterprise → 403. Zero-retention and longer windows are contract terms, not PATCH values."
      security:
        - bearerAuth: []
        - apiKey: []
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              type: object
              properties:
                retention:
                  type: string
                  enum:
                    - 24h
                    - 7d
                    - 30d
                retention_days:
                  type: integer
                  enum:
                    - 1
                    - 7
                    - 30
            example:
              retention: 24h
      responses:
        "200":
          description: Updated workspace view
        "400":
          description: "Invalid retention (`invalid_retention` or `retention_not_self_serve`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "403":
          description: "Not Enterprise (`enterprise_required`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/credentials":
    post:
      tags:
        - Vault
      operationId: createCredential
      summary: Create a vault credential
      description: "Store a login credential in the account vault (same fields as Console → Vault). Username, password, and optional TOTP secret are encrypted at rest; the response returns id + metadata only — secret material is never returned by any endpoint. Use the returned id as `credential_id` on POST /v1/responses. The custody policy (any | trusted_runners | deck_only | pinned:<runner>[,…]; default trusted_runners) hard-limits which runners may touch the credential — see /docs/security/custody."
      security:
        - bearerAuth: []
        - apiKey: []
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/CreateCredential"
            example:
              domain: portal.example-utility.com
              username: "jdoe@example.com"
              password: hunter2-rotate-me
              totp_secret: JBSWY3DPEHPK3PXP
              custody: trusted_runners
      responses:
        "201":
          description: Created — id + metadata only (never the secret)
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Credential"
        "400":
          description: "Invalid request (`invalid_credential` for missing/oversized fields, `invalid_custody` for a malformed policy)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    get:
      tags:
        - Vault
      operationId: listCredentials
      summary: List vault credentials (metadata only)
      description: "All non-revoked credentials owned by the API key’s account: id, domain, masked username, mfa, custody policy, health, created_at, last_used. Never includes secret material."
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: "List object with data[] of Credential"
          content:
            "application/json":
              schema:
                type: object
                properties:
                  object:
                    type: string
                    const: list
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/Credential"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/credentials/{id}":
    get:
      tags:
        - Vault
      operationId: getCredential
      summary: Get one credential (metadata only)
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Credential metadata (never the secret)
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Credential"
        "404":
          description: "Not found (`credential_not_found`) — unknown, revoked, or owned by another account"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
        - Vault
      operationId: updateCredential
      summary: Update custody policy or rotate secrets
      description: "Update the custody policy, rotate the password, or set/replace/remove the TOTP secret (`totp_secret: null` removes it). Returns metadata only."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              type: object
              properties:
                custody:
                  type: string
                  description: "any | trusted_runners | deck_only | pinned:<runner>[,<runner>…]"
                password:
                  type: string
                  description: Rotate the stored password.
                totp_secret:
                  type:
                    - string
                    - "null"
                  description: "Replace the TOTP secret, or null to remove it."
                mfa:
                  type: string
                  enum:
                    - none
                    - totp
                    - sms-relay
                    - passkey
            example:
              custody: "pinned:browserbase,steel"
      responses:
        "200":
          description: Updated credential metadata
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Credential"
        "400":
          description: "Invalid field (`invalid_credential` / `invalid_custody`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "404":
          description: "Not found (`credential_not_found`) — unknown, revoked, or owned by another account"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
        - Vault
      operationId: deleteCredential
      summary: Revoke a credential
      description: "Soft-deletes the credential: it disappears from lists and can no longer be used on runs. Ciphertext is retained for audit; it is never decrypted again."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Deleted confirmation
          content:
            "application/json":
              schema:
                type: object
                properties:
                  id:
                    type: string
                  object:
                    type: string
                    const: banana_peel.credential
                  deleted:
                    type: boolean
                    const: true
        "404":
          description: "Not found (`credential_not_found`) — unknown, already revoked, or owned by another account"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/webhooks":
    post:
      tags:
        - Webhooks
      operationId: createWebhook
      summary: Register an outbound webhook endpoint
      description: "Create a per-account HTTPS endpoint. Banana Peel POSTs signed JSON when a response completes, fails, is cancelled, or requires human input (MFA). If `secret` is omitted, a `whsec_…` signing secret is minted and returned once — list/get never include it. https only (http://localhost allowed for development). Private, metadata, and .internal hosts are rejected. See /docs/api/webhooks."
      security:
        - bearerAuth: []
        - apiKey: []
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/CreateWebhook"
            example:
              url: "https://example.com/webhooks/bananapeel"
              events:
                - response.completed
                - response.failed
                - response.requires_action
                - response.cancelled
      responses:
        "201":
          description: "Created — includes `secret` once"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/WebhookCreated"
        "400":
          description: "Invalid URL or events (`invalid_webhook_url` / `invalid_webhook_event`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    get:
      tags:
        - Webhooks
      operationId: listWebhooks
      summary: List webhook endpoints (never the secret)
      description: "All outbound endpoints owned by the API key’s account: url, events, disabled, consecutive_failures, timestamps. Signing secrets are never returned."
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: "List object with data[] of WebhookEndpoint"
          content:
            "application/json":
              schema:
                type: object
                properties:
                  object:
                    type: string
                    const: list
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WebhookEndpoint"
        "401":
          description: Invalid API key
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/webhooks/{id}":
    get:
      tags:
        - Webhooks
      operationId: getWebhook
      summary: Get one webhook endpoint (never the secret)
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Webhook metadata
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/WebhookEndpoint"
        "404":
          description: "Not found (`webhook_not_found`) — unknown or owned by another account"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    patch:
      tags:
        - Webhooks
      operationId: updateWebhook
      summary: "Update url, events, or enabled"
      description: "Patch the endpoint URL, subscribed events, or enabled/disabled. Re-enabling clears consecutive_failures. Signing secret is unchanged (use POST …/rotate)."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/UpdateWebhook"
            example:
              enabled: true
      responses:
        "200":
          description: Updated webhook metadata
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/WebhookEndpoint"
        "400":
          description: Invalid field
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
        "404":
          description: "Not found (`webhook_not_found`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
    delete:
      tags:
        - Webhooks
      operationId: deleteWebhook
      summary: Delete a webhook endpoint
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Deleted confirmation
          content:
            "application/json":
              schema:
                type: object
                properties:
                  id:
                    type: string
                  object:
                    type: string
                    const: banana_peel.webhook
                  deleted:
                    type: boolean
                    const: true
        "404":
          description: "Not found (`webhook_not_found`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/webhooks/{id}/rotate":
    post:
      tags:
        - Webhooks
      operationId: rotateWebhookSecret
      summary: Rotate the signing secret (returned once)
      description: "Mints a new `whsec_…` secret. The previous secret remains valid for 24 hours (`old_secret_expires_at`). During grace, Banana Peel signs with the new secret and includes BOTH signatures: `X-Banana-Peel-Signature: t=<unix>,v1=<new>,v1=<old>` — verify against ANY v1. The new secret is returned once and never listed again."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: "Rotated — includes `secret` once plus `old_secret_expires_at`"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/WebhookCreated"
        "404":
          description: "Not found (`webhook_not_found`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/webhooks/{id}/deliveries":
    get:
      tags:
        - Webhooks
      operationId: listWebhookDeliveries
      summary: List recent deliveries for an endpoint
      description: "Paginated delivery history (newest first). `limit` 1–100 (default 20), `starting_after` = delivery id. Never includes signing secrets. Response body previews are capped. Other accounts’ ids 404."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: starting_after
          in: query
          schema:
            type: string
          description: Delivery id cursor — return rows older than this id.
      responses:
        "200":
          description: List of WebhookDelivery
          content:
            "application/json":
              schema:
                type: object
                properties:
                  object:
                    type: string
                    const: list
                  data:
                    type: array
                    items:
                      "$ref": "#/components/schemas/WebhookDelivery"
                  has_more:
                    type: boolean
                  first_id:
                    type: string
                    nullable: true
                  last_id:
                    type: string
                    nullable: true
        "404":
          description: "Not found (`webhook_not_found`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/webhooks/{id}/deliveries/{delivery_id}":
    get:
      tags:
        - Webhooks
      operationId: getWebhookDelivery
      summary: "Get one delivery (headers, payload preview, HTTP, latency)"
      description: "Includes request header summary, capped payload preview, last HTTP status, response body first 512 bytes, latency, and attempt count. No secrets."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: delivery_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Delivery detail
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/WebhookDelivery"
        "404":
          description: "Not found (`webhook_delivery_not_found` / `webhook_not_found`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/webhooks/{id}/deliveries/{delivery_id}/retry":
    post:
      tags:
        - Webhooks
      operationId: retryWebhookDelivery
      summary: Manually redeliver (same event.id)
      description: "POSTs the frozen payload again with the same `event.id`. Use after fixing your endpoint. Does not mint a new event."
      security:
        - bearerAuth: []
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: delivery_id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Updated delivery after the attempt
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/WebhookDelivery"
        "404":
          description: "Not found (`webhook_delivery_not_found`)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Error"
  "/v1/rankings":
    get:
      tags:
        - Routing
      operationId: liveRankings
      summary: Live runner benchmark aggregated across all domains from real outcomes
      responses:
        "200":
          description: "Live runner benchmarks — one entry per runner, aggregated across all domains"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/Rankings"
  "/v1/capabilities":
    get:
      tags:
        - Routing
      operationId: runnerCapabilities
      summary: Runner capability matrix (canonical model) with live availability
      description: "Each row includes `availability`: `live`, `suspended` (circuit-breaker cooldown — provider credits/quota exhausted, auth rejected, repeated hangs), or `probation` (cooldown expired, half-open re-probe) with a machine-readable `reason` and the `until` re-probe timestamp where known. `runner_counts` reports the honest pool size (total vs live). Suspended runners are already skipped by smart routing, fallback chains, and fan-outs — no client-side filtering is required."
      responses:
        "200":
          description: "Capability rows for runners, each with live `availability`, plus honest `runner_counts` (live vs total)"
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/CapabilityMatrix"
  "/status":
    get:
      tags:
        - Platform
      operationId: platformStatus
      summary: Public platform status (no auth)
      description: Powers the /status page and external monitors. Ratios and timestamps only — absolute run/runner volume is never exposed. Cached ~30s.
      security: []
      responses:
        "200":
          description: Platform status snapshot
          content:
            "application/json":
              schema:
                "$ref": "#/components/schemas/PlatformStatus"
  "/v1/sessions":
    post:
      tags:
        - Wrappers
      operationId: browserbaseCreateSession
      summary: Browserbase-compatible session create (proxied)
      description: "Creates a session: a live browser you hold open and drive yourself over CDP (Playwright/Puppeteer). Unlike a run (POST /v1/responses — a routed unit of work), a session carries no task and is not smart-routed. Drop-in for @browserbasehq/sdk. Authenticate with a Banana Peel key (Bearer / X-BB-API-Key). Path is /api/v1/sessions — NOT /api/wrappers/browserbase. Proxied to real Browserbase unless Deck routing headers/metadata are present."
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "201":
          description: Browserbase session object
        "401":
          description: Unauthorized (Browserbase error shape)
    get:
      tags:
        - Wrappers
      operationId: browserbaseListSessions
      summary: Browserbase-compatible session list (proxied)
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Session list
  "/wrappers/steel/tasks":
    post:
      tags:
        - Wrappers
      operationId: wrapperSteelTasks
      summary: Steel-shaped migration wrapper
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Normalized Banana Peel result
  "/wrappers/steel/v1/sessions":
    post:
      tags:
        - Wrappers
      operationId: wrapperSteelSessionsCreate
      summary: Steel sessions proxy (steel-sdk baseURL drop-in)
      description: "Creates a session — a live browser you drive yourself over CDP, always on Steel (no task, not smart-routed; for routed work use POST /v1/responses). Alias for official steel-sdk `/v1/sessions`. Point baseURL at `${BANANA_PEEL_BASE}/api/wrappers/steel`. Requires STEEL_API_KEY on the deployment."
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Upstream Steel session
        "503":
          description: Steel sessions proxy not configured
    get:
      tags:
        - Wrappers
      operationId: wrapperSteelSessionsList
      summary: Steel sessions list proxy
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Upstream Steel session list
  "/wrappers/browser-use/run":
    post:
      tags:
        - Wrappers
      operationId: wrapperBrowserUseRun
      summary: Browser Use–shaped migration wrapper
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Normalized Banana Peel result
  "/wrappers/skyvern/run":
    post:
      tags:
        - Wrappers
      operationId: wrapperSkyvernRun
      summary: Skyvern-shaped migration wrapper
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Normalized Banana Peel result
  "/wrappers/hyperbrowser/task":
    post:
      tags:
        - Wrappers
      operationId: wrapperHyperbrowserTask
      summary: Hyperbrowser-shaped migration wrapper
      security:
        - bearerAuth: []
        - apiKey: []
      responses:
        "200":
          description: Normalized Banana Peel result
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "Authorization: Bearer bp_live_… (or bp_test_…)"
    apiKey:
      type: apiKey
      in: header
      name: x-api-key
      description: "Alternate: x-api-key: bp_live_…"
  schemas:
    LongTaskCreate:
      type: object
      required:
        - input
      properties:
        input:
          type: string
          description: "The multi-entity job in plain language (e.g. \"download the latest bill for ALL accounts\")."
        url:
          type: string
          description: Portal URL.
        credential_id:
          type: string
          description: Vault credential (cred_…) for the portal login.
        expected_entities:
          type: integer
          minimum: 2
          maximum: 500
          description: "Caller override when the text alone does not look multi-entity: \"I know this covers N entities\". Also threaded into the enumeration instruction and the manifest verifier as ground truth — provide it when you know K so short enumerations trigger an escalated search. Out of range → 400 invalid_expected_entities."
        verification:
          type: string
          enum:
            - cross_run
          description: "High-stakes manifest verification (opt-in). \"cross_run\" runs a SECOND independent enumeration on a different runner up front and merges the union of both entity lists; manifest_verification reports method \"cross_run\" with an agreement score (set overlap) and a disagreement-derived confidence. The second enumeration bills like any run (one extra login + one extra enumeration). If the confirming enumeration fails, the task proceeds on the first grader-verified list — the opt-in never fails a job. Omit for the default single grader-verified enumeration. Unknown values → 400 invalid_verification."
        max_cost_usd:
          type: number
          description: Total spend ceiling for the whole task.
        time_budget_ms:
          type: integer
          description: Wall-clock ceiling for the whole task (ms). Omit for uncapped (recommended) — hang protection and the credential-safe login pacing still apply.
    LongTaskObject:
      type: object
      properties:
        id:
          type: string
          example: lt_9f2c…
        object:
          type: string
          example: long_task
        status:
          type: string
          enum:
            - enumerating
            - fanning_out
            - paused_interaction
            - complete
            - partial_complete
            - failed
            - cancelled
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        request:
          type: object
          description: "Echo of the accepted request (input, url, credential_id, classified intent, verification opt-in when present)."
          properties:
            input:
              type: string
            url:
              type: string
            credential_id:
              type: string
            intent:
              type: string
            verification:
              type: string
              enum:
                - cross_run
        scale:
          type: object
          description: How the multi-entity gate admitted the task.
          properties:
            detected_by:
              type: string
              enum:
                - text
                - domain_prior
                - discovery
                - caller
            k_expected:
              type: integer
              nullable: true
        manifest_rev:
          type: integer
          description: Starts at 0; bumps when mid-run discovery appends entities.
        manifest:
          type: object
          nullable: true
          description: Manifest acceptance facts (spec §1). null until an enumeration is accepted.
          properties:
            source_run_id:
              type: string
            verification:
              type: object
              properties:
                method:
                  type: string
                  enum:
                    - grader
                    - cross_run
                    - sampled
                    - exhaustion_union
                confidence:
                  type: number
                agreement:
                  type: number
                low_confidence:
                  type: boolean
        manifest_verification:
          type: object
          description: "Alias of manifest.verification. How the entity list was accepted: method \"grader\" (verified above the confidence bar) or \"cross_run\". cross_run comes in two flavors: the PROACTIVE verification:\"cross_run\" opt-in reports \"agreement\" (set overlap of the two independent enumerations, 1.0 = identical) with confidence = min(leg confidences) × agreement and is graded on the normal confidence bar; the REACTIVE unanimity acceptance (a rejected claim later matched identically by a different runner, no agreement field) is always low_confidence: true — coverage may be partial and discovery can still append mid-run. Two more methods scale the evidence bar with K: \"sampled\" — a large claimed list (beyond one viewport, >~12) was accepted at normal confidence after a spot-check sub-task navigated to randomly chosen claimed ids in-session and confirmed they exist; \"exhaustion_union\" — retries exhausted with verified entities held, so the union across attempts was accepted at low_confidence: true and fanned out open-world (verified work is never discarded; discovery appends/corrects)."
          properties:
            method:
              type: string
              enum:
                - grader
                - cross_run
                - sampled
                - exhaustion_union
            confidence:
              type: number
            agreement:
              type: number
            low_confidence:
              type: boolean
        entities:
          type: array
          description: Per-entity ledger (the progress table).
          items:
            type: object
            properties:
              entity_id:
                type: string
              label:
                type: string
              status:
                type: string
                enum:
                  - pending
                  - dispatched
                  - delivered
                  - unfetchable
                  - exhausted
              attempts:
                type: integer
                description: Runner-class attempts only (environment failures do not count).
              run_id:
                type: string
              artifact_id:
                type: string
                description: "Verified file artifact (delivered only) — GET /v1/responses/{run_id}/artifacts/{artifact_id}."
              terminal_reason:
                type: string
                description: Grader-verified absence reason (unfetchable only).
              last_error:
                type: string
        counts:
          type: object
          properties:
            total:
              type: integer
            pending:
              type: integer
            dispatched:
              type: integer
            delivered:
              type: integer
            unfetchable:
              type: integer
            exhausted:
              type: integer
        gaps:
          type: array
          description: Honest not-delivered list (what a partial_complete is missing and why).
          items:
            type: object
            properties:
              entity_id:
                type: string
              status:
                type: string
              terminal_reason:
                type: string
              last_error:
                type: string
        budget:
          type: object
          properties:
            max_usd:
              type: number
            spent_usd:
              type: number
              description: "Real sub-task spend to date, accrued regardless of outcome."
            per_entity_max_attempts:
              type: integer
              default: 3
        time_budget_ms:
          type: integer
          nullable: true
          description: "Wall-clock ceiling. null + uncapped:true when omitted at create."
        uncapped:
          type: boolean
        session:
          type: object
          description: "Session facts: mode (reused = one login covers all sub-tasks; per_subtask = each sub-task logs in, paced by the credential bucket), established, the fresh-login count, and refreshes (proactive pre-expiry re-logins, one each). In reused mode the authenticated context continues across sub-tasks — and across runner classes where the runner documents accepting external session state. The stored session state itself is encrypted with the same key custody as vault credentials, never appears in any API response, webhook, or event, and is deleted when the task reaches a terminal state. The vendor session handle is never exposed."
          properties:
            mode:
              type: string
              enum:
                - reused
                - per_subtask
            established:
              type: boolean
            logins:
              type: integer
            refreshes:
              type: integer
        interaction:
          type: object
          description: "Present while paused_interaction: the sub-task run holding the MFA prompt — answer via POST /v1/responses/{run_id}/input."
          properties:
            run_id:
              type: string
            entity_id:
              type: string
            since:
              type: string
              format: date-time
        stop_reason:
          type: string
          description: "Why dispatch stopped early (budget_exhausted | time_budget_exhausted | enumeration_failed | cancelled)."
        cancel_requested:
          type: boolean
        events:
          type: array
          description: "Audit log — last 50 entries (dispatches — incl. chunk_dispatched when one sub-task carries several entities — retries, credential_cooling, discoveries, pauses, terminals; sampling_dispatched when a large claimed entity list is spot-checked, and manifest_accepted_at_exhaustion when retries exhaust with verified entities held)."
          items:
            type: object
            properties:
              at:
                type: string
                format: date-time
              type:
                type: string
              entity_id:
                type: string
              message:
                type: string
    CreateResponse:
      type: object
      properties:
        model:
          type: string
          default: banana-peel
        input:
          description: "Task text (string, OpenAI content array, or structured {url, goal, success_criteria, steps}). Required unless `url` alone is enough with goal/task."
          oneOf:
            - type: string
            - type: array
            - type: object
        url:
          type: string
          format: uri
        instructions:
          type: string
        goal:
          type: string
        task:
          type: string
        credential_id:
          type: string
          description: "Optional vault credential id for the API key owner (create/list via /v1/credentials). After custody is satisfiable, secrets are decrypted and injected into the task for whichever runner routing selects (smart, steel, browserbase, …). Vault usage never pins routing to deck (deck_only is an opt-in custody policy; create default is trusted_runners). When routing does select Deck — pinned, smart, race, or learning — the credential is additionally attached through Deck’s native vault (registered per source, reused across runs), since Deck gates login-walled sources on an attached credential rather than the instruction text."
        stream:
          type: boolean
          description: OpenAI Responses SSE stream (text/event-stream). Takes precedence over background for this request.
        background:
          type: boolean
          description: Return 202 immediately and finish in the background. Preferred for human MFA/OTP relay (poll + POST …/input).
        metadata:
          type: object
          additionalProperties:
            type: string
          description: OpenAI metadata (string values); echoed on the response object.
        allow_interaction:
          type: boolean
          description: Human-in-the-loop. Default on (response id is the relay channel). false opts out; true refuses non-relay runners.
        interaction_timeout_ms:
          type: integer
          description: Max wait per MFA/OTP/text question (default 240000).
        totp_secret:
          type: string
          description: Base32 TOTP secret — OTP asks answered in-process (prefer vault credential_id).
        totp_identifier:
          type: string
          description: Skyvern-native TOTP inbox/phone identifier.
        totp_url:
          type: string
          description: Skyvern-native TOTP poll URL.
        normalize:
          type: boolean
          description: Paid output normalization (+$0.10 / run). Requires output_schema (or text.format / response_format schema). Build schemas in Console → Normalize.
        output_schema:
          type: object
          description: "Desired output JSON Schema (or property map). Used when normalize is true or when a schema is supplied via text.format / response_format. HARD CONTRACT: a terminal success must populate exactly these keys (required keys present, non-empty, right type; no undeclared extra data keys unless additionalProperties: true — run metadata like url/final_url is tolerated). A run whose output cannot satisfy the schema terminates status:failed with error.code schema_violation and the nonconforming output kept under banana_peel.output for inspection; the normalization add-on is not billed on violation. Without a schema, read the stable default envelope instead: banana_peel.answer / answer_present / evidence."
          additionalProperties: true
        success_criteria:
          type: string
          description: "Explicit success criterion for the quality grader (e.g. \"an order number is present\")."
        steps:
          type: array
          items:
            type: string
          description: Optional ordered steps for structured task input.
        credentials_ref:
          type: string
          description: Alias for credential_id (vault credential).
        custody:
          type: string
          description: "Credential custody policy for THIS request: any | trusted_runners | deck_only | pinned:<runner>[,<runner>…]. Hard-filters the runner candidate set before any routing / scoring / fan-out; when no runner survives, the request fails fast with 409 `custody_unsatisfiable` and nothing is billed. Use with inline credentials in the task text; vault credentials carry their own stored policy (both compose as AND). Enforcement is echoed under `banana_peel.custody`."
          examples:
            - trusted_runners
            - deck_only
            - "pinned:browserbase,steel"
        text:
          type: object
          description: OpenAI Responses text.format passthrough (json_schema → normalize).
          additionalProperties: true
        response_format:
          type: object
          description: OpenAI-style response_format (json_schema / json_object).
          additionalProperties: true
        idempotency_key:
          type: string
          maxLength: 255
          description: "Client retry key — equivalent to the `Idempotency-Key` HTTP header (send either; if both, they must match or the create fails with 400 `idempotency_key_mismatch`). Honored on sync, background (`background: true` / Prefer: respond-async), and streaming create; keys are per-workspace, shared across every run-creating endpoint, and expire 24h after first use. The key is reserved before dispatch: reuse with an identical body (`stream`/`background` excluded from the comparison) replays the existing run (original `created_at`, `Idempotent-Replayed: true` header) instead of starting a duplicate; reuse with a different body is refused with 422 `idempotency_conflict`; a duplicate racing the original create gets 409 `idempotency_in_flight`."
        routing:
          description: "Optional (default smart). Learning and learning-all exist to teach the router the best path: they calibrate the routing brain on a domain/task so later smart runs pick the winning runner. smart = production (router uses the brain to pick/fallback) | learning = paid calibration: probe the curated live ~54-single-runner pool on this task/URL (not the default for production traffic) | learning-all = paid thorough calibration: full-catalog sweep (250+ options, NO early stop; most expensive; do not use for every production job) | named runner (browserbase, steel, browser-use, skyvern, hyperbrowser, playwright, deck, …) | category MVP pool (SOC2/HIPAA/EU — fixed runner lists, not attestation) | ordered runner list. Learning probes batches of 9 (concurrency 8) and stops after the first wave containing a success; learning-all keeps the same waves but sweeps the full catalog (250+ options): every dispatchable option attempts once; coming-soon catalog rows are skipped (never billed); $20 default ceiling (a catalog-wide sweep typically hits it). Learning-family runs are paid calibration sweeps (executed attempts bill); winners ranked fastest+cheapest feed future smart routing."
          oneOf:
            - type: string
            - type: array
              items:
                type: string
          default: smart
          examples:
            - smart
            - learning
            - learning-all
            - browserbase
            - skyvern
            - SOC2
            - - steel
              - hyperbrowser
              - playwright
        sla_profile:
          type: string
          enum:
            - balanced
            - cheapest
            - fastest
            - most_reliable
          default: balanced
          description: "Smart-routing preference: how the router weighs cost, speed, and block-avoidance against success probability when choosing runners. Only affects routing: \"smart\"."
        max_fallbacks:
          type: integer
          minimum: 0
          maximum: 12
          default: 5
          description: "Smart routing: how many additional runners after the primary (1 + N). Default 5 (up to 6 attempts unless spend, time, or a breaker stops earlier). 0 pins to the first pick only. Invalid values fail free with 400 invalid_max_fallbacks. Only affects routing: \"smart\" (and category). Staggered race still starts the primary first."
        max_cost_usd:
          type: number
          exclusiveMinimum: 0
          description: "Per-run spend ceiling (USD), honored by every routing mode. Once executed attempts’ spend reaches the ceiling, no NEW attempts / batches / waves are dispatched; in-flight attempts finish (and bill) and the run ends with error code `budget_exhausted` plus partial results under `banana_peel.routing_attempts`. Learning runs default to $20 when omitted (an explicit max_cost_usd overrides the default in either direction). The active ceiling is echoed under `banana_peel.budget` and reflected by POST /v1/estimate."
          examples:
            - 2.5
            - 0.25
        time_budget_ms:
          type: integer
          minimum: 30000
          description: "OPTIONAL run time budget (ms) for routing: \"smart\", \"category\", \"learning\", and \"learning-all\". When set it is THE orchestration ceiling: smart attempts share the window, learning sweeps stop dispatching new batches at it, and the run is reaped at budget + a 10-minute grace. When OMITTED the run is UNCAPPED — no run-level deadline exists; attempts defer to adapter-native leashes and per-attempt watchdogs, and only true-hang protection (no-progress watchdog, repeating-error detection, progress-staleness reaping, and a 24 h absolute safety ceiling) can stop the run. Uncapped runs should use background: true — a synchronous request is still bounded by the HTTP request timeout. Not accepted for pinned runner/chain routing (400 time_budget_unsupported). Values below 30000 fail free with 400 invalid_time_budget_ms; there is no upper bound. Spend ceilings (max_cost_usd) are independent. Alias: timeout_ms. The active contract is echoed as banana_peel.time_budget_ms (null + uncapped: true when uncapped)."
          examples:
            - 1800000
    CreateCredential:
      type: object
      required:
        - domain
        - username
        - password
      properties:
        domain:
          type: string
          maxLength: 253
          description: "Login site domain, e.g. portal.example.com (scheme/path stripped)."
        username:
          type: string
          maxLength: 320
        password:
          type: string
          maxLength: 1024
          description: "Stored envelope-encrypted: per-secret AES-256-GCM data key wrapped by Cloud KMS (automatic key rotation). Never returned by any endpoint."
        totp_secret:
          type: string
          maxLength: 128
          description: "Optional base32 TOTP seed — OTP challenges are answered in-process at run time. Sets mfa: totp."
        custody:
          type: string
          default: trusted_runners
          description: "Custody policy: any | trusted_runners | deck_only | pinned:<runner>[,<runner>…]. See /docs/security/custody."
          examples:
            - trusted_runners
            - deck_only
            - "pinned:browserbase,steel"
        mfa:
          type: string
          enum:
            - none
            - totp
            - sms-relay
            - passkey
          description: MFA mode; inferred as totp when totp_secret is set.
        source_id:
          type: string
          maxLength: 120
          description: Optional catalog source id when the site comes from the sources list.
    Credential:
      type: object
      description: Vault credential metadata. Secret material (password / TOTP seed) is write-only and never appears here.
      properties:
        id:
          type: string
          description: cred_… — pass as credential_id on POST /v1/responses
        object:
          type: string
          const: banana_peel.credential
        domain:
          type: string
        source_id:
          type: string
          nullable: true
        username_masked:
          type: string
          description: "Masked username for display (e.g. jdo***@example.com)."
        mfa:
          type: string
          enum:
            - none
            - totp
            - sms-relay
            - passkey
        custody:
          type: string
          description: "any | trusted_runners | deck_only | pinned:<runner>[,<runner>…]"
        health:
          type: string
          enum:
            - ok
            - stale
            - failed
        created_at:
          type: string
          format: date-time
        last_used:
          type: string
          format: date-time
          nullable: true
    CreateWebhook:
      type: object
      required:
        - url
      properties:
        url:
          type: string
          format: uri
          description: "Public HTTPS callback URL. Private, metadata, .internal, and localhost/loopback hosts are rejected in production (http://localhost is allowed only in local development)."
        events:
          type: array
          items:
            type: string
            enum:
              - response.completed
              - response.failed
              - response.requires_action
              - response.cancelled
              - long_task.progress
              - long_task.interaction_required
              - long_task.completed
              - long_task.partial
              - long_task.failed
          description: Defaults to all event types when omitted.
        secret:
          type: string
          minLength: 16
          description: "Optional signing secret. If omitted, Banana Peel mints `whsec_…` and returns it once."
    UpdateWebhook:
      type: object
      properties:
        url:
          type: string
          format: uri
        events:
          type: array
          items:
            type: string
            enum:
              - response.completed
              - response.failed
              - response.requires_action
              - response.cancelled
              - long_task.progress
              - long_task.interaction_required
              - long_task.completed
              - long_task.partial
              - long_task.failed
        enabled:
          type: boolean
          description: Set false to disable delivery.
        disabled:
          type: boolean
    WebhookEndpoint:
      type: object
      description: Registered outbound webhook. The signing secret is write-only and never appears here.
      properties:
        id:
          type: string
          description: wh_…
        object:
          type: string
          const: banana_peel.webhook
        url:
          type: string
        events:
          type: array
          items:
            type: string
        disabled:
          type: boolean
        disabled_reason:
          type: string
          nullable: true
        consecutive_failures:
          type: integer
        last_delivery_at:
          type: string
          format: date-time
          nullable: true
        last_http_status:
          type: integer
          nullable: true
        old_secret_expires_at:
          type: string
          format: date-time
          nullable: true
          description: "When set, the previous signing secret still verifies until this instant (24h rotation grace)."
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    WebhookCreated:
      allOf:
        - "$ref": "#/components/schemas/WebhookEndpoint"
        - type: object
          properties:
            secret:
              type: string
              description: "Signing secret (`whsec_…`) — returned once at create/rotate."
            old_secret_expires_at:
              type: string
              format: date-time
              nullable: true
              description: "On rotate: the previous secret remains valid until this timestamp (24h). Absent on create."
            warning:
              type: string
    WebhookEvent:
      type: object
      description: "Outbound webhook POST body. Delivered at least once — store `id` and ignore duplicates. `data` is the same object as GET /v1/responses/{id}, redacted (no secrets). Delivery order is not guaranteed; `sequence` is monotonic per response so you can drop stale events. Treat the current GET /responses/:id state as authoritative."
      required:
        - id
        - object
        - api_version
        - type
        - sequence
        - created_at
        - data
      properties:
        id:
          type: string
          description: evt_… — canonical idempotency key. The same id may arrive more than once (retries / redelivery). Header X-Banana-Peel-Event-Id is a convenience copy.
        object:
          type: string
          const: banana_peel.event
        api_version:
          type: string
          description: Date-version of the event envelope (currently 2026-08-13).
          example: 2026-08-13
        type:
          type: string
          enum:
            - response.completed
            - response.failed
            - response.requires_action
            - response.cancelled
            - long_task.progress
            - long_task.interaction_required
            - long_task.completed
            - long_task.partial
            - long_task.failed
        sequence:
          type: integer
          description: Monotonic per response. Incremented once per event emitted for that response (shared across endpoints). Drop events with sequence less than the highest you have already applied for that response.id.
        created_at:
          type: string
          format: date-time
        data:
          allOf:
            - "$ref": "#/components/schemas/ResponseObject"
          description: "Redacted response object. For requires_action, includes required_action.submit_input (kind + prompt) so the customer can POST /v1/responses/{id}/input — never the secret the user will type. On brain-routed runs, completed/failed events carry data.banana_peel.routing_summary — runner, strategy, reason code, one redacted sentence, per-attempt fault attribution, policy version, widening echo, and the path to the full decision card — so \"why was it routed there\" needs no second fetch."
    WebhookDelivery:
      type: object
      description: One attempt-history row for an outbound event. Payload/response previews are capped; secrets are never stored.
      properties:
        id:
          type: string
          description: whdel_…
        object:
          type: string
          const: banana_peel.webhook_delivery
        webhook_id:
          type: string
        event_id:
          type: string
          description: Frozen evt_… — redelivery reuses this id.
        type:
          type: string
        response_id:
          type: string
        sequence:
          type: integer
        status:
          type: string
          enum:
            - pending
            - delivered
            - failed
        attempt:
          type: integer
        next_attempt_at:
          type: string
          format: date-time
          nullable: true
        last_http_status:
          type: integer
          nullable: true
        last_error:
          type: string
          nullable: true
        last_latency_ms:
          type: integer
          nullable: true
        response_body_preview:
          type: string
          nullable: true
          description: First 512 bytes of the customer endpoint’s response body.
        request_headers:
          type: object
          additionalProperties:
            type: string
          nullable: true
          description: Sent headers except the HMAC signature.
        redelivery_count:
          type: integer
        payload_preview:
          type: string
          description: Truncated frozen event JSON (GET one only).
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
    Error:
      type: object
      description: Browser Agent API error envelope. Branch on error.code (stable); the full code table with remediations lives at /docs/api/errors.
      properties:
        error:
          type: object
          required:
            - message
            - type
            - code
          properties:
            message:
              type: string
            type:
              type: string
            code:
              type: string
            param:
              type: string
              nullable: true
            doc_url:
              type: string
            hint:
              type: string
            remediation:
              type: string
              description: Short next-step guidance for agents.
            claim_url:
              type: string
              description: Present on account_unclaimed — open it to claim the account.
    ResponseObject:
      type: object
      properties:
        id:
          type: string
        object:
          type: string
          const: response
        created_at:
          type: integer
          description: Unix seconds
        status:
          type: string
          enum:
            - completed
            - failed
            - in_progress
            - requires_action
            - cancelled
        required_action:
          nullable: true
          type: object
          description: Present when status is requires_action — owner secrets only (MFA/OTP/text/confirm). Never emitted for captcha / bot challenges. Visuals (live_view / screenshot / replay_url) are included when available.
          properties:
            type:
              type: string
              const: submit_input
            submit_input:
              type: object
              properties:
                kind:
                  type: string
                  enum:
                    - otp
                    - text
                    - confirm
                prompt:
                  type: string
                created_at:
                  type: integer
                channel:
                  type: string
                  enum:
                    - totp
                    - sms
                    - email
                    - unknown
                  description: Best-effort delivery channel inferred from the prompt.
                message:
                  type: string
                  description: Human-readable ask (same as prompt).
                submit:
                  type: object
                  description: How to send the code.
                  properties:
                    method:
                      type: string
                      const: POST
                    path:
                      type: string
                      description: "/api/v1/responses/{id}/input"
                live_view:
                  type: string
                  nullable: true
                screenshot:
                  type: string
                  nullable: true
                replay_url:
                  type: string
                  nullable: true
        incomplete_details:
          nullable: true
          type: object
        model:
          type: string
        confidence:
          nullable: true
          type: number
        output:
          type: array
          items:
            type: object
        output_text:
          type: string
          description: "ALWAYS a plain string, on every runner: freeform answers verbatim; structured results (including output_schema-normalized ones) as pretty-printed JSON. The structured value itself is under banana_peel.output. Guaranteed never a character-indexed map ({\"0\":\"T\",\"1\":\"h\",…}) and never double-encoded JSON-in-a-string — outputs are repaired/unwrapped at the canonical normalization choke point before the envelope is built or persisted."
        usage:
          type: object
          description: Approximate tokens (char/4); billing is USD under banana_peel.billing
          properties:
            input_tokens:
              type: integer
            output_tokens:
              type: integer
            total_tokens:
              type: integer
        metadata:
          type: object
          additionalProperties:
            type: string
        error:
          nullable: true
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            remediation:
              type: string
        banana_peel:
          "$ref": "#/components/schemas/BananaPeelRun"
    BananaPeelSession:
      type: object
      nullable: true
      description: "Metadata about the browser this run used (live view, replay, CDP connect). A record of the run’s browser — not a standalone session you manage. Unused fields are null."
      properties:
        id:
          type: string
          nullable: true
        provider_id:
          type: string
          nullable: true
        live_url:
          type: string
          nullable: true
        replay_url:
          type: string
          nullable: true
        connect_url:
          type: string
          nullable: true
        selenium_remote_url:
          type: string
          nullable: true
        region:
          type: string
          nullable: true
        status:
          type: string
          nullable: true
    BananaPeelArtifact:
      type: object
      description: "A file captured from the run (downloads, screenshots, recordings). Captured artifacts are hosted by Banana Peel: `url` points at the authenticated retrieval endpoint (GET /v1/responses/{id}/artifacts/{artifactId}, same API-key auth), which 302-redirects to a short-lived signed URL (~10 min TTL) or streams the bytes. Artifacts follow the run retention window (default 7 days) and are purged on response delete / account deletion. Per-file capture cap is 30 MB; up to 10 artifacts per run. When bytes could not be captured, `url` falls back to the provider-hosted URL (typically session-gated and short-lived) and `unavailable_reason` says why."
      properties:
        id:
          type: string
          nullable: true
        name:
          type: string
        content_type:
          type: string
          description: Sniffed from magic bytes when the provider declares a generic type.
        size:
          type: integer
        url:
          type: string
          nullable: true
          description: Authenticated Banana Peel retrieval URL for captured artifacts; provider URL only when capture failed.
        kind:
          type: string
          enum:
            - download
            - screenshot
            - recording
            - log
            - other
        unavailable_reason:
          type: string
          description: "Present when the bytes are not retrievable from Banana Peel (oversize, not captured, …)."
    BananaPeelRun:
      type: object
      description: "Canonical banana_peel.run/v1 — superset over all runners. `runner` is the executed runner slug; `routing` is what you requested; `engine` is the execution backend when distinct."
      properties:
        schema:
          type: string
          const: banana_peel.run/v1
        id:
          type: string
        status:
          type: string
          enum:
            - succeeded
            - failed
            - running
            - blocked
            - empty
            - needs_human
            - cancelled
        progress:
          nullable: true
          type: object
          description: "Live in-flight visibility (only while status is in_progress / requires_action): runner attempts so far, current Learning batch, and runner spend to date. Superseded by routing_attempts on the terminal object."
          properties:
            attempts:
              type: array
              items:
                type: object
                properties:
                  runner:
                    type: string
                  status:
                    type: string
                    description: "running | succeeded | failed | blocked"
                  started_at:
                    type: string
                    format: date-time
                  finished_at:
                    type: string
                    format: date-time
                  cost_usd:
                    type: number
                  timing_ms:
                    type: number
            attempts_started:
              type: integer
            attempts_finished:
              type: integer
            batch:
              type: object
              description: Present for Learning-mode batch probes (1-based current batch).
              properties:
                current:
                  type: integer
                total:
                  type: integer
                size:
                  type: integer
            spend_usd:
              type: number
              description: Runner spend so far (pre-fee).
            updated_at:
              type: string
              format: date-time
        required_action:
          nullable: true
          type: object
          description: MFA/OTP pause mirror (prompt/kind only; never the secret).
        model:
          type: string
        routing: {}
        routing_note:
          type: string
          nullable: true
        ranked_runners:
          type: array
          items:
            type: string
        parent_id:
          type: string
          description: "Present on long-task sub-runs only — the parent long task (lt_…). See GET /v1/long-tasks/{id}/runs."
        entity_id:
          type: string
          description: Present on long-task sub-runs — the manifest entity this run serves (__enumeration__ for the enumeration step).
        role:
          type: string
          enum:
            - enumeration
            - entity_fetch
            - verification
          description: "Role of a long-task sub-run within its parent. verification = a sampled spot-check confirming randomly chosen claimed entity ids exist."
        routing_summary:
          type: object
          nullable: true
          description: "Compact \"why was it routed there\" summary — present on every brain-routed run (routing: \"smart\"/category and forced-runner decisions the brain recorded; absent on pinned/chain/learning runs, which have no decision to explain). In-flight polls carry the honest base (planned primary + decision reason); the terminal object adds per-attempt fault attribution, the policy version, and a budget-widening echo. The `summary` sentence is redacted before it leaves the API. The same object is embedded in response.completed / response.failed webhook payloads (data.banana_peel.routing_summary). For the full decision card (candidate EU table, site defense, widening steps + stop reasons) follow `explanation` — GET /v1/responses/{id}/explanation."
          properties:
            runner:
              type: string
              nullable: true
              description: Executed runner (terminal) or the planned primary (in-flight).
            strategy:
              type: string
              nullable: true
              description: "explore | exploit | forced | fallback (additive enum; widened runs report explore)."
            reason:
              type: string
              nullable: true
              description: "Machine reason code: exploit_expected_utility, exploit_recent_winner, explore_cold_domain, explore_low_confidence, explore_floor, explore_streak_breaker, explore_budget_widening, forced_by_caller, fallback_static_chain, … (additive enum)."
            summary:
              type: string
              description: One redacted plain-words sentence — the same headline the console decision card shows.
            policy_version:
              type: string
              description: Routing policy version that made the decision (terminal runs).
            attempts:
              type: array
              description: Executed attempts in dispatch order.
              items:
                type: object
                properties:
                  runner:
                    type: string
                  status:
                    type: string
                  attribution:
                    type: string
                    description: "Fault attribution from the reward scorer (runner | platform | environment | orchestration | task) — only runner-attributed outcomes affect runner scores."
            widening:
              type: object
              description: Present when the run widened past the planned candidate set (budget-governed widening).
              properties:
                dispatches:
                  type: integer
                  description: Widened dispatches executed.
                stop_reason:
                  type: string
                  nullable: true
                  description: "widening_exhausted | pool_exhausted | null (null when the run ended for run-level reasons)."
            explanation:
              type: string
              description: "Path to the full decision card: /api/v1/responses/{id}/explanation."
        runner:
          type: string
          nullable: true
          description: Executed runner slug
        engine:
          type: string
          nullable: true
          description: Execution backend behind the runner when distinct (e.g. browser-use-cloud)
        url:
          type: string
          nullable: true
        output:
          description: "Run result in canonical shape, identical across runners: a plain string for freeform tasks, or a structured object/array when the runner returned real JSON (or output_schema was requested). Normalized at a single choke point before persist + envelope build: char-exploded strings ({\"0\":\"T\",\"1\":\"h\",…}) are reassembled and JSON-encoded strings unwrapped, so the shape never varies by runner. With an output_schema this is a HARD CONTRACT: a terminal success populates exactly the schema keys, or the run is failed with error.code schema_violation."
        answer:
          type: string
          nullable: true
          description: "GUARANTEED READ PATH (stable default envelope): best-effort distilled answer string, always present as a key. Runner outputs name their keys differently run to run (current_outstanding_balance vs result vs prose-in-summary) — `answer` is the stable projection, so clients without an output_schema never chase keys. Null exactly when answer_present is false. For legitimately empty results the answer states the empty result (e.g. the \"No Order History Found\" evidence); for download tasks it lists the delivered files."
        answer_present:
          type: boolean
          description: "Delivery-verdict bit: was the REQUESTED ANSWER actually delivered? True on every genuine success — including a legitimate empty/zero result (\"0 orders\" IS the answer; empty-result ≠ failure). False on failed/blocked runs and on hollow completions (output that only narrates login/click actions without the requested value — those are status failed, never billed-and-claimed-successful). Meaningful on terminal states; false while still running."
        evidence:
          nullable: true
          type: object
          description: "Evidence trail backing `answer`: verdict reason, final URL, artifact names."
          properties:
            quality_reason:
              type: string
              nullable: true
            final_url:
              type: string
              nullable: true
            artifacts:
              type: array
              items:
                type: string
        error:
          nullable: true
          type: object
        quality:
          nullable: true
          type: object
          description: "Delivery verdict from the honesty layer (deterministic checks + QA grader): did the run deliver the requested answer? Terminal `status` follows this verdict — not the runner’s mechanical exit code — and the routing scoreboard/brain learn from the same verdict-based labels."
          properties:
            verdict:
              type: string
              enum:
                - succeeded
                - blocked
                - failed
                - empty
            confidence:
              type: number
            reason:
              type: string
            source:
              type: string
              enum:
                - deterministic
                - grader
            answer_present:
              type: boolean
              description: Mirrored to banana_peel.answer_present (see there).
        deliverables:
          nullable: true
          type: object
          description: "Delivery verdict for file-deliverable tasks: claimed downloads vs files actually captured as retrievable artifacts. Null for text-only tasks. A run that claims downloads but delivers zero captured files is demoted to failed (verdict not_delivered) — in-session download URLs expire with the browser session and are not deliverables. In structured output, `download_succeeded` is renamed `download_triggered` (the in-session click) with a sibling `file_delivered` boolean (actual capture)."
          properties:
            claimed:
              type: integer
              description: Files the run output claims were downloaded.
            delivered:
              type: integer
              description: File artifacts actually captured and retrievable.
            verdict:
              type: string
              enum:
                - delivered
                - partial
                - not_delivered
            note:
              type: string
        block_reason:
          type: string
          nullable: true
        timing_ms:
          type: number
          nullable: true
        cost:
          nullable: true
          type: object
        normalization:
          type: object
          description: "Output normalization echo — present whenever normalization was requested. `applied: true` means the reshape pass executed (and the +$0.10 add-on was billed); `applied: false` includes a short `reason` and `addon_usd: 0` (never charged for a pass that did not run, and never charged when the output_schema contract was violated — see error.code schema_violation)."
          properties:
            requested:
              type: boolean
              const: true
            applied:
              type: boolean
            reason:
              type: string
              nullable: true
              description: Why the pass did not apply; null when applied.
            addon_usd:
              type: number
              description: "Add-on billed for this run (0.1 when applied, else 0)."
        custody:
          type: object
          description: "Custody enforcement echo — present whenever a credential custody policy applied to this run (vault credential and/or request `custody`). The runner candidate set was hard-filtered to `allowed_runners` BEFORE routing; no fallback, race, wave, or learning probe left that set. Persisted on the run record for audit."
          properties:
            policy:
              type: string
              description: "Composed policy label (any | trusted_runners | deck_only | pinned:<runner> — joined with + when composed)."
            source:
              type: string
              enum:
                - vault
                - request
                - vault+request
            credential_id:
              type: string
              nullable: true
              description: Vault credential id; null for inline credentials.
            allowed_runners:
              type: array
              items:
                type: string
              description: Effective allowed-runner set after the filter (capped at 40 slugs).
            allowed_runner_count:
              type: integer
            candidates_before_filter:
              type: integer
            enforced:
              type: boolean
              const: true
        session:
          "$ref": "#/components/schemas/BananaPeelSession"
        live_view:
          type: string
          nullable: true
        replay_url:
          type: string
          nullable: true
        screenshot:
          type: string
          nullable: true
        screenshot_unavailable_reason:
          type: string
          nullable: true
        artifacts:
          type: array
          items:
            "$ref": "#/components/schemas/BananaPeelArtifact"
        steps:
          type: array
          items:
            type: object
        fallback_from:
          type: array
          items:
            type: string
        act_script:
          nullable: true
          type: array
        created_at:
          type: string
          format: date-time
        finished_at:
          type: string
          format: date-time
          nullable: true
    RankingTrendBucket:
      type: object
      description: Per-day relative traffic bucket. Volume is published as shares only — absolute per-day run counts are not part of the public contract.
      properties:
        day:
          type: string
          description: "UTC day, YYYY-MM-DD"
        share:
          type: number
          description: "Runner's share of that day's routed attempts (0–1)."
        success_rate:
          type: number
          nullable: true
          description: "Runner's own success rate for the day; null when it had no attempts."
    RunnerAvailability:
      type: object
      description: "Live availability from the runner-health circuit breaker. `suspended` runners are skipped by smart routing, fallback chains, fan-outs, and races until `until`; `probation` is half-open — the cooldown expired, the runner is eligible again, and the next routed attempt is the re-probe (reason/detail/until describe the suspension it is recovering from). Pure surfacing — routing already excludes suspended runners, so you never need to filter yourself; use this to explain pool coverage or to avoid pinning a suspended runner."
      properties:
        status:
          type: string
          enum:
            - live
            - suspended
            - probation
        reason:
          type: string
          nullable: true
          enum:
            - provider_credits_exhausted
            - provider_payment_required
            - provider_auth_rejected
            - provider_key_missing
            - repeated_hangs
            - suspended
            - null
          description: Machine-readable suspension reason; null when live.
        detail:
          type: string
          nullable: true
          description: Human-readable circuit-breaker detail; null when live.
        until:
          type: string
          format: date-time
          nullable: true
          description: When the suspension cooldown ends/ended and the runner becomes re-probe eligible (in the past for probation); null when live or unknown.
    RunnerPoolCounts:
      type: object
      description: "Honest pool size: `total` advertised runners, `live` fully healthy, `suspended` inside a circuit-breaker cooldown right now (skipped by routing), `probation` half-open (eligible; next attempt re-probes). total = live + suspended + probation."
      properties:
        total:
          type: integer
        live:
          type: integer
        suspended:
          type: integer
        probation:
          type: integer
    RankingRow:
      type: object
      description: "Runner-level aggregate. Volume fields are relative-only (traffic_share, traffic_delta_pct); the absolute-count fields (successes/failures/blocks/total/window_runs) were removed from the contract."
      properties:
        runner:
          type: string
        label:
          type: string
        mode:
          type: string
          enum:
            - infra
            - agent
            - oss
          nullable: true
        availability:
          "$ref": "#/components/schemas/RunnerAvailability"
        success_rate:
          type: number
        failed_rate:
          type: number
        blocked_rate:
          type: number
        rank_score:
          type: number
          description: "Wilson lower bound (95%) on lifetime success rate — default sort key."
        domains:
          type: integer
          description: Distinct domains with recorded outcomes.
        traffic_share:
          type: number
          description: Share of recent-window routed attempts (0–1).
        traffic_delta_pct:
          type: number
          nullable: true
          description: "Traffic change, last 7 trend days vs the 7 before (%); null without a base."
        is_new:
          type: boolean
          description: True when the runner has current-week traffic but no prior-week base.
        low_sample:
          type: boolean
          description: Qualitative small-sample hint (never the count itself).
        avg_latency_ms:
          type: number
          nullable: true
        p50_latency_ms:
          type: number
          nullable: true
        p95_latency_ms:
          type: number
          nullable: true
        avg_cost_usd:
          type: number
          nullable: true
        last_seen_at:
          type: string
          format: date-time
          nullable: true
        trend:
          type: array
          items:
            "$ref": "#/components/schemas/RankingTrendBucket"
    RankingTask:
      type: object
      properties:
        task:
          type: string
          description: "Task intent (extract, search, login_mfa, …)"
        share:
          type: number
          description: Share of recorded task outcomes.
        top_runners:
          type: array
          items:
            type: object
            properties:
              runner:
                type: string
              share:
                type: number
              success_rate:
                type: number
    Rankings:
      type: object
      properties:
        object:
          type: string
          const: list
        live:
          type: boolean
        group_by:
          type: string
          const: runner
        updated_at:
          type: string
          format: date-time
        trend_days:
          type: integer
        data:
          type: array
          items:
            "$ref": "#/components/schemas/RankingRow"
          description: Ordered by rank_score descending.
        tasks:
          type: array
          items:
            "$ref": "#/components/schemas/RankingTask"
        note:
          type: string
    CapabilityMatrix:
      type: object
      description: "Capability rows carry live `availability` (live | suspended | probation + machine-readable reason + `until`) from the runner-health circuit breaker, and `runner_counts` makes the advertised pool honest (live vs total)."
      properties:
        object:
          type: string
          const: list
        schema:
          type: string
          const: banana_peel.capabilities/v1
        all_capabilities:
          type: array
          items:
            type: string
            enum:
              - navigate
              - act
              - extract
              - cdp_connect
              - live_view
              - replay
              - screenshot
              - downloads
              - steps
              - cost
              - proxy
              - persist_profile
              - human_input
              - totp
        named_runners:
          type: array
          items:
            type: string
          example:
            - browserbase
            - steel
            - browser-use
            - skyvern
            - hyperbrowser
            - anchor
            - deck
            - playwright
            - auto
        smart_default_chain:
          type: array
          items:
            type: string
          example:
            - browserbase
            - steel
            - hyperbrowser
            - browser-use
            - skyvern
            - anchor
            - deck
            - playwright
        categories:
          type: array
          items:
            type: string
          example:
            - SOC2
            - HIPAA
            - EU
        runner_counts:
          "$ref": "#/components/schemas/RunnerPoolCounts"
        data:
          type: array
          items:
            type: object
            properties:
              slug:
                type: string
              mode:
                type: string
              capabilities:
                type: array
                items:
                  type: string
              notes:
                type: string
                nullable: true
              availability:
                "$ref": "#/components/schemas/RunnerAvailability"
          example:
            - slug: agentql
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: airtop
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - screenshot
                - downloads
                - steps
                - cost
                - persist_profile
                - live_view
              notes: null
            - slug: anchor
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - live_view
                - replay
                - screenshot
                - downloads
                - steps
                - cost
                - proxy
                - persist_profile
                - human_input
                - totp
              notes: null
            - slug: apify
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: asteroid
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - replay
                - screenshot
                - downloads
                - steps
                - cost
                - human_input
                - totp
              notes: null
            - slug: browser-use
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - replay
                - screenshot
                - downloads
                - steps
                - cost
                - human_input
                - totp
              notes: null
            - slug: browser-use-oss
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: browserbase
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - live_view
                - replay
                - screenshot
                - downloads
                - steps
                - cost
                - proxy
                - persist_profile
                - human_input
                - totp
              notes: Also backs the optional Browserbase sessions wrapper.
            - slug: browserbase-stealth
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - live_view
                - replay
                - screenshot
                - downloads
                - steps
                - cost
                - proxy
                - persist_profile
                - human_input
                - totp
              notes: null
            - slug: browserless
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: camoufox
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: chrome-devtools-mcp
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: claude-computer-use
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
                - human_input
                - totp
              notes: null
            - slug: cloudflare-browser
              mode: extract
              capabilities:
                - extract
                - cost
              notes: Read-oriented edge rendering.
            - slug: crawl4ai
              mode: oss
              capabilities:
                - navigate
                - extract
                - steps
                - cost
              notes: null
            - slug: crawlee
              mode: oss
              capabilities:
                - navigate
                - extract
                - screenshot
                - steps
                - cost
              notes: null
            - slug: deck
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - replay
                - screenshot
                - steps
                - cost
                - human_input
                - totp
              notes: Runner hosted agent — not the Banana Peel API spine.
            - slug: decodo
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: diffbot
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: exa
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: exa-research
              mode: agent
              capabilities:
                - extract
                - steps
                - cost
              notes: "Research agent — extract/search only; no page actions, no credential custody."
            - slug: firecrawl
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: google-gemini-agent
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
                - human_input
                - totp
              notes: null
            - slug: hyperagent
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: hyperbrowser
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - live_view
                - screenshot
                - downloads
                - steps
                - cost
                - persist_profile
                - human_input
                - totp
              notes: null
            - slug: jina-reader
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: kernel
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - screenshot
                - downloads
                - steps
                - cost
                - persist_profile
              notes: null
            - slug: lightpanda
              mode: infra
              capabilities:
                - navigate
                - extract
                - cdp_connect
                - steps
                - cost
              notes: No screenshots on this engine.
            - slug: llm-scraper
              mode: oss
              capabilities:
                - navigate
                - extract
                - screenshot
                - steps
                - cost
              notes: null
            - slug: magnitude
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: manus
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - steps
                - cost
              notes: null
            - slug: midscene
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: nodriver
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: notte
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: openai-chatgpt-agent
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
                - human_input
                - totp
              notes: null
            - slug: parallel
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: parallel-task
              mode: agent
              capabilities:
                - extract
                - steps
                - cost
              notes: "Research agent — extract/search only; no page actions, no credential custody."
            - slug: playwright
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: playwright-mcp
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: puppeteer
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: scrapegraph-ai
              mode: oss
              capabilities:
                - navigate
                - extract
                - steps
                - cost
              notes: null
            - slug: scraperapi
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: scrapfly
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: scrapingbee
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: selenium-grid
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: seleniumbase
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
              notes: null
            - slug: serpapi
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: serper
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: skyvern
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - replay
                - screenshot
                - downloads
                - steps
                - cost
                - human_input
                - totp
              notes: null
            - slug: stagehand
              mode: oss
              capabilities:
                - navigate
                - act
                - extract
                - screenshot
                - downloads
                - steps
                - cost
                - persist_profile
              notes: null
            - slug: steel
              mode: infra
              capabilities:
                - navigate
                - act
                - extract
                - cdp_connect
                - live_view
                - screenshot
                - downloads
                - steps
                - cost
                - proxy
                - persist_profile
                - human_input
                - totp
              notes: null
            - slug: tavily
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
            - slug: tinyfish
              mode: agent
              capabilities:
                - navigate
                - act
                - extract
                - steps
                - cost
              notes: null
            - slug: zenrows
              mode: extract
              capabilities:
                - extract
                - cost
              notes: null
    PlatformStatus:
      type: object
      description: "Public platform health snapshot — ratios and timestamps only, never absolute run/runner volume."
      properties:
        generatedAt:
          type: string
          format: date-time
        overall:
          type: string
          enum:
            - operational
            - degraded
            - outage
            - unknown
        components:
          type: array
          items:
            type: object
            properties:
              id:
                type: string
                enum:
                  - web
                  - api
                  - runners
              label:
                type: string
              level:
                type: string
                enum:
                  - operational
                  - degraded
                  - outage
                  - unknown
              detail:
                type: string
                description: Ratios and timestamps only — never absolute volume.
        history:
          type: array
          description: "Daily run-success history, oldest → newest."
          items:
            type: object
            properties:
              day:
                type: string
                description: "UTC day, YYYY-MM-DD."
              successRate:
                type: number
                nullable: true
                minimum: 0
                maximum: 1
        incidents:
          type: array
          items: {}
          description: Always empty today.
webhooks:
  "response.completed":
    post:
      tags:
        - Webhooks
      operationId: onResponseCompleted
      summary: response.completed
      description: "Banana Peel POSTs this event when a run succeeds. Verify X-Banana-Peel-Signature (`t=<unix>,v1=<hmac>` over `{t}.{raw_body}`; during rotation grace the header may contain two v1 values — accept ANY). Return 2xx within 10 seconds to acknowledge (verify → enqueue → 2xx; process async). Delivered at least once — dedupe on event.id. See /docs/api/webhooks."
      parameters:
        - name: X-Banana-Peel-Signature
          in: header
          required: true
          schema:
            type: string
          description: "t=<unix>,v1=<hmac_sha256_hex>[,v1=<previous hmac> during 24h rotation grace]"
        - name: X-Banana-Peel-Event-Id
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx within 10 seconds to acknowledge; otherwise Banana Peel retries. Process the event asynchronously after verify + enqueue.
  "response.failed":
    post:
      tags:
        - Webhooks
      operationId: onResponseFailed
      summary: response.failed
      description: "Run failed, blocked, or hard-TTL run_timeout. Same envelope and signature as response.completed."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
  "response.requires_action":
    post:
      tags:
        - Webhooks
      operationId: onResponseRequiresAction
      summary: response.requires_action
      description: "Time-sensitive owner-secret input (MFA/OTP only — never captcha). `data.required_action.submit_input` has kind + prompt plus live_view / screenshot / replay_url; POST the answer to /v1/responses/{id}/input. The secret the user will type is never included. May fire more than once per run (multiple MFA rounds). Bot challenges end blocked or re-route instead."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
  "response.cancelled":
    post:
      tags:
        - Webhooks
      operationId: onResponseCancelled
      summary: response.cancelled
      description: "Caller cancelled via POST /v1/responses/{id}/cancel."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
  "long_task.progress":
    post:
      tags:
        - Webhooks
      operationId: onLongTaskProgress
      summary: long_task.progress
      description: "A long-task entity settled (delivered / retry / unfetchable / exhausted) or the manifest verified. data is the long-task object (GET /v1/long-tasks/{id}): per-entity table, counts, gaps, budget. Repeating by design — one delivery per (entity, outcome). Same envelope and signature as response.* events."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
  "long_task.interaction_required":
    post:
      tags:
        - Webhooks
      operationId: onLongTaskInteractionRequired
      summary: long_task.interaction_required
      description: "A long-task sub-task hit MFA / human input and the task paused (status paused_interaction). data.interaction.run_id is the holding CHILD run — answer via POST /v1/responses/{run_id}/input; the task resumes automatically. Fires once per holding run. Short retry schedule (time-sensitive)."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
  "long_task.completed":
    post:
      tags:
        - Webhooks
      operationId: onLongTaskCompleted
      summary: long_task.completed
      description: "Terminal: every entity delivered or grader-verified unfetchable, none exhausted. Fires once per task."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
  "long_task.partial":
    post:
      tags:
        - Webhooks
      operationId: onLongTaskPartial
      summary: long_task.partial
      description: "Terminal, first-class: some entities delivered, some did not — data.gaps lists exactly which and why. Never reported as a silent failure. Fires once per task."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
  "long_task.failed":
    post:
      tags:
        - Webhooks
      operationId: onLongTaskFailed
      summary: long_task.failed
      description: "Terminal: no entities delivered (or enumeration failed). A CANCELLED long task also arrives as this event type with data.status: \"cancelled\" — read data.status to distinguish. Fires once per task."
      requestBody:
        required: true
        content:
          "application/json":
            schema:
              "$ref": "#/components/schemas/WebhookEvent"
      responses:
        "2XX":
          description: Return any 2xx to acknowledge.
x-banana-peel:
  canonical_schema: banana_peel.run/v1
  api_version: 2026-08-13
  base_url: "https://bananapeel.com"
  openapi_json: "https://bananapeel.com/openapi.json"
  openapi_yaml: "https://bananapeel.com/openapi.yaml"
  llms_txt: "https://bananapeel.com/llms.txt"
  llms_full_txt: "https://bananapeel.com/llms-full.txt"
  docs_agents: "https://bananapeel.com/docs/agents"
  docs_mcp: "https://bananapeel.com/docs/mcp"
  mcp_package: "npx -y @banana-peel/mcp"
  named_runners:
    - browserbase
    - steel
    - browser-use
    - skyvern
    - hyperbrowser
    - anchor
    - deck
    - playwright
    - auto
  smart_default_chain:
    - browserbase
    - steel
    - hyperbrowser
    - browser-use
    - skyvern
    - anchor
    - deck
    - playwright
  routing_modes:
    - smart
    - learning
    - learning-all
    - SOC2
    - HIPAA
    - EU
  learning:
    batch_size: 9
    concurrency: 8
    bills_every_attempt: true
    default_max_cost_usd: 20
    attempt_max_cost_usd: 2
    account_concurrency_default: 3
  learning_all:
    batch_size: 9
    concurrency: 8
    bills_every_attempt: true
    no_early_stop: true
    default_max_cost_usd: 20
    attempt_max_cost_usd: 2
    catalog: 250+
    account_concurrency_default: 3
  categories:
    - SOC2
    - HIPAA
    - EU
  capabilities:
    - navigate
    - act
    - extract
    - cdp_connect
    - live_view
    - replay
    - screenshot
    - downloads
    - steps
    - cost
    - proxy
    - persist_profile
    - human_input
    - totp
  capability_matrix:
    - slug: agentql
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: airtop
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - screenshot
        - downloads
        - steps
        - cost
        - persist_profile
        - live_view
      notes: null
    - slug: anchor
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - live_view
        - replay
        - screenshot
        - downloads
        - steps
        - cost
        - proxy
        - persist_profile
        - human_input
        - totp
      notes: null
    - slug: apify
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: asteroid
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - replay
        - screenshot
        - downloads
        - steps
        - cost
        - human_input
        - totp
      notes: null
    - slug: browser-use
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - replay
        - screenshot
        - downloads
        - steps
        - cost
        - human_input
        - totp
      notes: null
    - slug: browser-use-oss
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: browserbase
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - live_view
        - replay
        - screenshot
        - downloads
        - steps
        - cost
        - proxy
        - persist_profile
        - human_input
        - totp
      notes: Also backs the optional Browserbase sessions wrapper.
    - slug: browserbase-stealth
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - live_view
        - replay
        - screenshot
        - downloads
        - steps
        - cost
        - proxy
        - persist_profile
        - human_input
        - totp
      notes: null
    - slug: browserless
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: camoufox
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: chrome-devtools-mcp
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: claude-computer-use
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
        - human_input
        - totp
      notes: null
    - slug: cloudflare-browser
      mode: extract
      capabilities:
        - extract
        - cost
      notes: Read-oriented edge rendering.
    - slug: crawl4ai
      mode: oss
      capabilities:
        - navigate
        - extract
        - steps
        - cost
      notes: null
    - slug: crawlee
      mode: oss
      capabilities:
        - navigate
        - extract
        - screenshot
        - steps
        - cost
      notes: null
    - slug: deck
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - replay
        - screenshot
        - steps
        - cost
        - human_input
        - totp
      notes: Runner hosted agent — not the Banana Peel API spine.
    - slug: decodo
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: diffbot
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: exa
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: exa-research
      mode: agent
      capabilities:
        - extract
        - steps
        - cost
      notes: "Research agent — extract/search only; no page actions, no credential custody."
    - slug: firecrawl
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: google-gemini-agent
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
        - human_input
        - totp
      notes: null
    - slug: hyperagent
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: hyperbrowser
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - live_view
        - screenshot
        - downloads
        - steps
        - cost
        - persist_profile
        - human_input
        - totp
      notes: null
    - slug: jina-reader
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: kernel
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - screenshot
        - downloads
        - steps
        - cost
        - persist_profile
      notes: null
    - slug: lightpanda
      mode: infra
      capabilities:
        - navigate
        - extract
        - cdp_connect
        - steps
        - cost
      notes: No screenshots on this engine.
    - slug: llm-scraper
      mode: oss
      capabilities:
        - navigate
        - extract
        - screenshot
        - steps
        - cost
      notes: null
    - slug: magnitude
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: manus
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - steps
        - cost
      notes: null
    - slug: midscene
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: nodriver
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: notte
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: openai-chatgpt-agent
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
        - human_input
        - totp
      notes: null
    - slug: parallel
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: parallel-task
      mode: agent
      capabilities:
        - extract
        - steps
        - cost
      notes: "Research agent — extract/search only; no page actions, no credential custody."
    - slug: playwright
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: playwright-mcp
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: puppeteer
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: scrapegraph-ai
      mode: oss
      capabilities:
        - navigate
        - extract
        - steps
        - cost
      notes: null
    - slug: scraperapi
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: scrapfly
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: scrapingbee
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: selenium-grid
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: seleniumbase
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
      notes: null
    - slug: serpapi
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: serper
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: skyvern
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - replay
        - screenshot
        - downloads
        - steps
        - cost
        - human_input
        - totp
      notes: null
    - slug: stagehand
      mode: oss
      capabilities:
        - navigate
        - act
        - extract
        - screenshot
        - downloads
        - steps
        - cost
        - persist_profile
      notes: null
    - slug: steel
      mode: infra
      capabilities:
        - navigate
        - act
        - extract
        - cdp_connect
        - live_view
        - screenshot
        - downloads
        - steps
        - cost
        - proxy
        - persist_profile
        - human_input
        - totp
      notes: null
    - slug: tavily
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
    - slug: tinyfish
      mode: agent
      capabilities:
        - navigate
        - act
        - extract
        - steps
        - cost
      notes: null
    - slug: zenrows
      mode: extract
      capabilities:
        - extract
        - cost
      notes: null
  notes:
    - "Primary integration: POST /api/v1/responses (OpenAI Responses drop-in)"
    - "OpenAI SDK: baseURL {base}/api/v1, model banana-peel, Bearer bp_live_… — see /docs/api/openai"
    - "Anthropic thin adapter: POST /api/v1/messages — see /docs/api/anthropic"
    - "routing defaults to smart (production). Learning and learning-all teach the router the best path — calibrate the brain so later smart runs pick the winner. learning = live ~54-single-runner pool with early stop (paid calibration, $20); learning-all = full catalog (250+ options), no early stop (thorough paid calibration, $20 default ceiling; coming-soon skipped; do not use for every production job)"
    - "stream:true → OpenAI Responses SSE; background:true → 202 + poll or outbound webhooks"
    - "Outbound webhooks: POST/GET /api/v1/webhooks, GET/PATCH/DELETE /api/v1/webhooks/{id}, POST …/rotate (24h dual-signature grace), GET …/deliveries, POST …/deliveries/{id}/retry — at-least-once signed events response.completed|failed|requires_action|cancelled (event.id canonical dedupe; sequence per response). Terminal retries over 24h via /api/cron/webhooks; requires_action short-retries then stops when the MFA window closes. Sender timeout 10s. /docs/api/webhooks"
    - "Output normalization: normalize + output_schema (or text.format / response_format) → reshape (+$0.10, billed only when the pass runs; outcome echoed under banana_peel.normalization)"
    - "output_schema is a hard contract: terminal success populates exactly the schema keys or the run fails with error code schema_violation (nonconforming output kept for inspection; add-on not billed)"
    - "Guaranteed read path without a schema: banana_peel.answer (distilled string), answer_present (was the requested answer delivered — a verified empty result like \"0 orders\" counts as delivered), evidence (verdict reason + final URL + artifacts). Terminal status follows the delivery verdict, never raw runner exit codes; hollow completions (login narration without the answer) fail"
    - "MCP: /docs/mcp — stdio tools create_response, get_response, submit_input, …"
    - "MFA/OTP: When a target site asks for MFA, 2FA, or an OTP, the run pauses immediately and asks the user for the code. It does not keep trying other runners. Poll GET /api/v1/responses/:id until status is requires_action (or handle the response.requires_action webhook). MCP create_response waits until that pause and returns human_action_required plus required_action. Submit the code with POST /api/v1/responses/{id}/input  { \"code\": \"123456\" }  (or { \"answer\": \"…\" } for text/confirm). MCP: submit_input({ id, code }). Then keep polling until completed or failed. CAPTCHA and other bot-detection gates (reCAPTCHA, hCaptcha, Turnstile, DataDome, Cloudflare challenge, “prove you are human”) are solved, re-routed, or end blocked with reason bot_challenge. They never become a human text prompt. Cursor/MCP: ASK THE HUMAN IN CHAT NOW for the site MFA/OTP code. Call submit_input with their code. Do not retry the task or pick another runner. Docs: https://bananapeel.com/docs/guides/mfa"
    - "Cancel: POST /v1/responses/{id}/cancel → status cancelled; executed attempts bill, nothing new after"
    - "Delete: DELETE /v1/responses/{id} purges copied screenshots/artifacts immediately; later GET is 404 — /docs/security/retention"
    - "Identity: GET /v1/me — account_id + created_at + claim_url for the API key (CLI whoami); no secret returned"
    - "Retention: copied documents 7 days default; GET/PATCH /v1/workspace (Enterprise 24h|7d|30d; zero by contract)"
    - "In-flight visibility: GET /v1/responses/{id} exposes banana_peel.progress (attempts, Learning batch, spend) while in_progress"
    - "Routing transparency: brain-routed responses carry banana_peel.routing_summary (runner, strategy, reason code, redacted one-sentence summary, per-attempt fault attribution, policy_version, widening echo) in the envelope AND in response.completed/failed webhook payloads; full decision card at GET /v1/responses/{id}/explanation — /docs/api/routing-explanations"
    - "Validation fails free: structurally unusable requests → 400 (missing_input, missing_url for learning/learning-all, invalid_output_schema, invalid_custody, invalid_max_cost_usd, invalid_max_fallbacks, invalid_time_budget_ms, time_budget_unsupported) before any runner bills"
    - "Time budget: time_budget_ms (optional; smart/category/learning/learning-all) — set: the run time ceiling (reaped at budget + 10-min grace); OMITTED: the run is UNCAPPED (no run-level deadline; adapter leashes + per-attempt watchdogs + true-hang protection only, 24 h absolute safety ceiling; use background: true). Echoed as banana_peel.time_budget_ms (null + uncapped: true when uncapped). Spend caps are independent"
    - "Spend ceiling: max_cost_usd (any mode; Learning defaults to $20, learning-all to $20) — executed attempts reaching it stop NEW dispatch; run ends with error code budget_exhausted + partial results; ceiling echoed under banana_peel.budget and by POST /v1/estimate"
    - "Backpressure: 429 rate_limited when an account exceeds its concurrent Learning-family run limit (learning + learning-all, default 3); 429 learning_capacity when instance fan-out capacity is saturated — nothing created or billed. EVERY 429 (including via the /v1/messages adapter and stream:true creates) carries a Retry-After header (seconds); honor it and back off — /docs/api/rate-limits"
    - "Runner availability: GET /v1/capabilities rows carry availability (live | suspended | probation + machine-readable reason + until) from the runner-health circuit breaker, plus honest runner_counts (total vs live vs suspended vs probation); suspended runners are auto-skipped by routing — no client-side filtering needed"
    - "Custody enforced: vault credentials carry a policy (any | trusted_runners | deck_only | pinned:<runner>; create default trusted_runners, deck_only is opt-in); request custody covers inline credentials; hard pre-filter on the candidate set — conflict → 409 custody_unsatisfiable (nothing billed); echo under banana_peel.custody — /docs/security/custody"
    - "Vault management: POST/GET /api/v1/credentials, GET/PATCH/DELETE /api/v1/credentials/{id} — create returns id + metadata; secrets are write-only (never returned); use the id as credential_id on /v1/responses — injected at run time onto the runner routing selects (not a Deck source, not pinned to deck) — /docs/api/credentials"
    - "Hard TTL: pinned/chain runs default 20 min then failed with error code run_timeout (BP_RUN_HARD_TTL_MS); smart/learning-family runs are UNCAPPED unless time_budget_ms is set — reaped only on progress staleness (dead worker) or the 24 h safety ceiling"
    - "Browserbase wrapper: /api/v1/sessions (not /api/wrappers/browserbase)"
    - "Runs vs sessions: a run is a routed unit of work (POST /v1/responses, billed per attempt); a session is a live CDP browser you drive yourself (no task, not smart-routed) — /docs/concepts/sessions"
    - "Do not use /api/v2/* — mock playground, not the product API"
