openapi: 3.0.3
info:
  title: Floe Credit API
  description: |
    Working capital, x402 payments, and agent management for AI agents on Base.

    ## Authentication Methods

    - **Public endpoints** — no auth required (marked with `security: []`)
    - **Agent API key** — `Authorization: Bearer floe_...` (runtime credential; pays for calls)
    - **Developer API key** — `Authorization: Bearer floe_live_...` (operator credential)
    - **Wallet signature** — EIP-191 signed message via X-Wallet-Address + X-Signature + X-Timestamp headers
    - **Developer session** — the `floe_session` HttpOnly cookie set by POST /v1/developer/auth/verify (or `Authorization: Bearer <jwt>` as a legacy fallback)
    - **Admin API key** — `Authorization: Bearer <admin-key>`

    Every `/v1/developer/*` route (and the money-movement surfaces `/v1/transfers`,
    `/v1/onramp`, `/v1/offramp`) accepts any DEVELOPER credential interchangeably:
    a `floe_live_*` developer key, the session cookie/JWT, or wallet-signature
    headers. Agent keys (`floe_*` without `_live_`) are runtime credentials and
    are refused on those surfaces with 403 `developer_credential_required`.

    ## Webhooks

    Developer webhooks (`/v1/developer/webhooks*`) deliver platform events to
    your endpoint as JSON POSTs. Loan events (`loan.*`) keep their original
    envelope `{event, loanId, timestamp, data}`; every other event uses
    `{event, ...payload, firedAt}`. Each delivery carries three headers:

    - `X-Floe-Signature` — hex HMAC-SHA256 of `<timestamp>.<raw body>` keyed
      with the webhook's `whsec_*` secret
    - `X-Floe-Timestamp` — unix seconds; verify recency to reject replays
    - `X-Floe-Delivery-Id` — unique per delivery and STABLE across retries;
      receivers should dedupe on it

    Failed deliveries are retried automatically: up to 3 attempts total, the
    second +60s after the first failure and the third +300s after the second,
    then the delivery is marked `failed`. Test deliveries
    (`POST /v1/developer/webhooks/{id}/test`) are one-shot and never retried.
    Delivery log rows are retained for 30 days.

    Subscriptions accept the 30 catalog event names
    (`GET /v1/developer/webhooks/events`) plus wildcards: `*` or `<prefix>.*`
    (e.g. `call.*`). Scopes: `global` (every subscribed event), `wallet` /
    `agent` (events about one agent — scopeValue is the agent's 0x WALLET
    address, never the numeric agent id), `loan` (one numeric loan id).
    `marketplace.vendor.*` events are platform-wide broadcasts delivered to
    every subscribed webhook regardless of scope.
  version: 1.3.0
  contact:
    name: Floe Labs
    url: https://floelabs.xyz
    email: hello@floelabs.xyz

servers:
  - url: https://credit-api.floelabs.xyz
    description: Production (Base Mainnet)

security:
  - agentApiKey: []

tags:
  - name: Public
    description: No authentication required
  - name: Credit
    description: Lending operations (wallet signature auth)
  - name: x402 Proxy
    description: x402 payment proxy (agent API key auth)
  - name: x402 Estimate
    description: Cost preflight (agent API key auth)
  - name: Agent
    description: Agent self-service (agent API key auth)
  - name: Developer
    description: Dashboard multi-agent management (any developer credential — session cookie, floe_live_ key, or wallet signature)
  - name: Gateway
    description: OpenAI-compatible keyless inference gateway (agent API key auth; flag-gated — probe /v1/capabilities)
  - name: Transfers
    description: Bidirectional wallet transfers (developer credential; agent keys refused)
  - name: Onramp
    description: CDP fiat onramp (developer credential; agent keys refused)
  - name: Offramp
    description: CDP fiat offramp (developer credential; agent keys refused)
  - name: Admin
    description: Operations (admin API key)

paths:
  /.well-known/openapi.yaml:
    get:
      tags: [Public]
      summary: This OpenAPI spec
      operationId: getOpenApiSpec
      security: []
      responses:
        "200":
          description: OpenAPI 3.0 YAML
          content:
            text/yaml:
              schema:
                type: string

  /v1/health:
    get:
      tags: [Public]
      summary: Health check
      operationId: getHealth
      security: []
      responses:
        "200":
          description: Service healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [ok, degraded]
                  checks:
                    type: object
                    properties:
                      db:
                        $ref: "#/components/schemas/CheckResult"
                      privy:
                        $ref: "#/components/schemas/CheckResult"
                  timestamp:
                    type: string
                    format: date-time

  /v1/markets:
    get:
      tags: [Public]
      summary: List lending markets
      operationId: getMarkets
      security: []
      responses:
        "200":
          description: Active markets
          content:
            application/json:
              schema:
                type: object
                properties:
                  markets:
                    type: array
                    items:
                      $ref: "#/components/schemas/Market"

  /v1/credit/offers:
    get:
      tags: [Public]
      summary: Browse lend offers
      operationId: getCreditOffers
      security: []
      parameters:
        - name: marketId
          in: query
          schema:
            type: string
            pattern: "^0x[a-fA-F0-9]{64}$"
          description: bytes32 market ID. Omit for all markets.
        - name: minAmount
          in: query
          schema:
            type: string
        - name: maxRateBps
          in: query
          schema:
            type: string
        - name: maxResults
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: Available offers
          content:
            application/json:
              schema:
                type: object
                properties:
                  offers:
                    type: array
                    items:
                      $ref: "#/components/schemas/LendOffer"
        "400":
          description: Invalid marketId or param format

  /v1/credit/instant-borrow:
    post:
      tags: [Credit]
      summary: Build unsigned borrow transactions
      operationId: instantBorrow
      security:
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      parameters:
        - name: Idempotency-Key
          in: header
          schema:
            type: string
            maxLength: 255
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InstantBorrowRequest"
      responses:
        "200":
          description: Unsigned transactions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BorrowResult"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: No liquidity
        "409":
          description: Idempotency conflict

  /v1/credit/status/{loanId}:
    get:
      tags: [Credit]
      summary: Loan status and health
      operationId: getCreditStatus
      security:
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      parameters:
        - name: loanId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Loan status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoanStatus"
        "404":
          description: Loan not found

  /v1/credit/repay:
    post:
      tags: [Credit]
      summary: Build unsigned repay transaction
      operationId: repayCredit
      security:
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [loanId]
              properties:
                loanId:
                  type: string
                slippageBps:
                  type: string
      responses:
        "200":
          description: Unsigned repay transactions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransactionResult"

  /v1/credit/repay-and-reborrow:
    post:
      tags: [Credit]
      summary: Repay and reborrow atomically
      operationId: repayAndReborrow
      security:
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RenewRequest"
      responses:
        "200":
          description: Repay + reborrow transactions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransactionResult"

  /v1/credit/borrow-attempts/{attemptId}:
    get:
      tags: [Credit]
      summary: Recover borrow attempt status
      operationId: getBorrowAttempt
      security:
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      parameters:
        - name: attemptId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Attempt status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BorrowResult"
        "404":
          description: Not found
        "503":
          description: Service unavailable

  /v1/credit/borrow-attempts/{attemptId}/resume:
    post:
      tags: [Credit]
      summary: Resume stalled borrow attempt
      operationId: resumeBorrowAttempt
      security:
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      parameters:
        - name: attemptId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Resumed transactions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransactionResult"

  /v1/credit/borrow-attempts/{attemptId}/abandon:
    post:
      tags: [Credit]
      summary: Abandon borrow attempt
      operationId: abandonBorrowAttempt
      security:
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      parameters:
        - name: attemptId
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Cleanup transactions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransactionResult"

  /v1/reputation-score/agents/{agentId}:
    get:
      tags: [Credit Score]
      summary: Agent creditworthiness score
      description: >
        Composite creditworthiness score (0–100) for a Floe agent, keyed on the
        numeric agent id. Composited from Cred Protocol per-wallet signals,
        Floe-native on-chain repayment history (from the indexer), and x402
        payment reputation (from the agent's `proxy_requests` settlement record)
        behind a swappable provider interface. Any authenticated caller (developer key
        `floe_live_*` or agent key `floe_*`) may query any agent id. Serves the
        latest cached score when fresh (TTL ~1 day) else recomputes and
        persists; `refresh=true` forces a recompute (tighter rate limit).
      operationId: getAgentCreditScore
      security:
        - agentApiKey: []
        - walletAddress: []
          walletSignature: []
          walletTimestamp: []
      parameters:
        - name: agentId
          in: path
          required: true
          description: Numeric Floe agent id.
          schema:
            type: string
            pattern: '^\d+$'
        - name: refresh
          in: query
          required: false
          description: Force a recompute instead of serving the cached score.
          schema:
            type: boolean
      responses:
        "200":
          description: Agent credit score (cache HIT or freshly computed MISS).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentCreditScore"
        "401":
          description: Authentication required.
        "404":
          description: Unknown agent id.
        "422":
          description: >
            Insufficient data — the request was valid but no provider produced a
            usable signal. Body is a typed `insufficient_data` envelope, never a
            fabricated score.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InsufficientCreditData"
        "429":
          description: Rate limit exceeded (per API key; tighter on refresh).
        "503":
          description: Credit score service not configured (indexer unavailable).

  /v1/proxy/check:
    get:
      tags: [Public]
      summary: Check if URL requires x402 payment
      operationId: proxyCheck
      security: []
      parameters:
        - name: url
          in: query
          required: true
          schema:
            type: string
            format: uri
      responses:
        "200":
          description: Payment check result
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProxyCheckResult"
        "400":
          description: Invalid or blocked URL
        "429":
          description: Rate limit exceeded

  /v1/proxy/fetch:
    post:
      tags: [x402 Proxy]
      summary: Proxy request with automatic x402 payment
      operationId: proxyFetch
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ProxyFetchRequest"
      responses:
        "200":
          description: Target response (passthrough or paid)
          content:
            "*/*":
              schema:
                type: string
                format: binary
        "400":
          description: Invalid request or blocked URL
        "401":
          description: Missing auth, or wrong_credential_type (developer credential on an agent-only route — carries a `next` remediation block)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"
        "402":
          description: Insufficient balance, spend limit, or policy exceeded
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum: [insufficient_balance, spend_limit_exceeded, policy_exceeded]
                  available:
                    type: string
                  required:
                    type: string
        "403":
          description: Agent suspended / credit frozen / host_not_allowlisted / read_only_key
        "429":
          description: Rate limit exceeded
        "502":
          description: Target unreachable or ambiguous payment

  /v1/x402/estimate:
    post:
      tags: [x402 Estimate]
      summary: Preflight cost estimate with credit reflection
      operationId: estimateX402Cost
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                method:
                  type: string
                  pattern: "^[A-Z]{3,7}$"
      responses:
        "200":
          description: Cost estimate with reflection
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/X402Estimate"
        "429":
          description: Rate limit exceeded

  /v1/models:
    get:
      tags: [Gateway]
      summary: List gateway models (OpenAI-compatible discovery)
      description: |
        The enabled inference catalog with capability hints. Model IDs are
        `provider/model` (e.g. `openai/gpt-4o-mini`, `deepgram/nova-3`).
        Accepts any authenticated /v1 credential — the agent-key requirement
        applies only to the paid inference routes. Mounted only when the
        `gateway` capability is enabled (probe GET /v1/capabilities).
      operationId: gatewayListModels
      security:
        - agentApiKey: []
        - developerSession: []
      responses:
        "200":
          description: Model list (OpenAI `list` envelope)
          content:
            application/json:
              schema:
                type: object
                properties:
                  object:
                    type: string
                    enum: [list]
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        object:
                          type: string
                          enum: [model]
                        created:
                          type: integer
                          description: Unix seconds (0 when the catalog row has no createdAt).
                        owned_by:
                          type: string
                          enum: [floe]
                        modality:
                          type: string
                        context_window:
                          type: integer
                          nullable: true

  /v1/chat/completions:
    post:
      tags: [Gateway]
      summary: OpenAI-compatible chat completion (keyless, metered)
      description: |
        Point any OpenAI SDK at `<base>/v1` with a Floe AGENT key and it
        works unmodified. Requires an agent API key — a developer credential
        is refused with 401 `wrong_credential_type` (the agent is the payer).
        Optional headers: X-Floe-Provider-Key (per-request BYOK override —
        also overrides any stored /v1/developer/provider-keys key),
        X-Floe-Task-Id, X-Floe-Action-Id, X-Floe-Customer-Id.

        With `stream: true` the response is SSE (`text/event-stream`); the
        cost cannot be a response header (it is only known at the terminal
        usage chunk), so it rides the debit + the OpenAI usage chunk instead
        of X-Floe-Cost-USDC. X-Floe-Budget-Advisory (flag-gated) is sent
        BEFORE the first token — the only budget signal a stream gets.
      operationId: gatewayChatCompletion
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, messages]
              properties:
                model:
                  type: string
                  description: Catalog model id (`provider/model`).
                messages:
                  type: array
                  items:
                    type: object
                stream:
                  type: boolean
                  default: false
              additionalProperties: true
              description: Standard OpenAI chat-completion body — extra fields pass through to the provider.
      responses:
        "200":
          description: Completion (OpenAI shape) with billing headers, or SSE when stream=true
          headers:
            X-Floe-Cost-USDC:
              $ref: "#/components/headers/FloeCostUsdc"
            X-Floe-Payment-Amount:
              $ref: "#/components/headers/FloePaymentAmount"
            X-Floe-Payment:
              $ref: "#/components/headers/FloePayment"
            X-Floe-Model:
              $ref: "#/components/headers/FloeModel"
            X-Floe-Rail:
              $ref: "#/components/headers/FloeRail"
            X-Floe-Budget-Remaining-USDC:
              $ref: "#/components/headers/FloeBudgetRemaining"
            X-Floe-Attempts:
              $ref: "#/components/headers/FloeAttempts"
          content:
            application/json:
              schema:
                type: object
            text/event-stream:
              schema:
                type: string
        "400":
          description: Invalid JSON body / missing model (OpenAI error shape)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAiError"
        "401":
          description: Missing auth, or wrong_credential_type (developer credential on the agent-only gateway — carries an additive `next` remediation block alongside the OpenAI `error` object)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAiError"
        "402":
          description: Insufficient balance, spend limit, or policy exceeded
        "404":
          description: Unknown or disabled model
        "429":
          description: Per-agent rate limit exceeded (Retry-After set)
        "502":
          description: No source could serve the request

  /v1/embeddings:
    post:
      tags: [Gateway]
      summary: OpenAI-compatible embeddings (keyless, metered)
      description: |
        Same auth, BYOK, attribution headers, billing headers, and error
        contract as POST /v1/chat/completions (agent key required; developer
        credentials get 401 `wrong_credential_type`). No streaming.
      operationId: gatewayEmbeddings
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, input]
              properties:
                model:
                  type: string
                input:
                  description: String or array of strings/tokens (OpenAI shape).
              additionalProperties: true
      responses:
        "200":
          description: Embedding list (OpenAI shape) with billing headers
          headers:
            X-Floe-Cost-USDC:
              $ref: "#/components/headers/FloeCostUsdc"
            X-Floe-Payment-Amount:
              $ref: "#/components/headers/FloePaymentAmount"
            X-Floe-Budget-Remaining-USDC:
              $ref: "#/components/headers/FloeBudgetRemaining"
        "401":
          description: Missing auth or wrong_credential_type
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAiError"
        "402":
          description: Insufficient balance, spend limit, or policy exceeded
        "404":
          description: Unknown or disabled model
        "429":
          description: Rate limit exceeded

  /v1/audio/speech:
    post:
      tags: [Gateway]
      summary: Text-to-speech (OpenAI-compatible, binary out)
      description: |
        Voice gateway TTS. Agent key required (401 `wrong_credential_type`
        for developer credentials). OpenAI-shaped providers plus Google
        Gemini TTS; proprietary voice vendors (ElevenLabs/Cartesia/Deepgram)
        are marketplace vendors reached via /v1/proxy/fetch, not here.
      operationId: gatewayAudioSpeech
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model, input]
              properties:
                model:
                  type: string
                input:
                  type: string
                voice:
                  type: string
              additionalProperties: true
      responses:
        "200":
          description: Audio bytes with billing headers
          headers:
            X-Floe-Cost-USDC:
              $ref: "#/components/headers/FloeCostUsdc"
            X-Floe-Payment-Amount:
              $ref: "#/components/headers/FloePaymentAmount"
            X-Floe-Payment:
              $ref: "#/components/headers/FloePayment"
            X-Floe-Model:
              $ref: "#/components/headers/FloeModel"
            X-Floe-Rail:
              $ref: "#/components/headers/FloeRail"
          content:
            "*/*":
              schema:
                type: string
                format: binary
        "401":
          description: Missing auth or wrong_credential_type
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAiError"
        "402":
          description: Insufficient balance, spend limit, or policy exceeded
        "404":
          description: Unknown or disabled model
        "429":
          description: Rate limit exceeded

  /v1/audio/transcriptions:
    post:
      tags: [Gateway]
      summary: Batch speech-to-text (OpenAI-compatible multipart)
      description: |
        Voice gateway STT. Agent key required (401 `wrong_credential_type`
        for developer credentials). Multipart form; extra fields pass
        through to the provider. The streaming-STT WebSocket
        (`/v1/audio/transcriptions/stream`) is a separate WS surface that
        also accepts agent keys only.
      operationId: gatewayAudioTranscription
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [model, file]
              properties:
                model:
                  type: string
                file:
                  type: string
                  format: binary
      responses:
        "200":
          description: Transcription (OpenAI shape) with billing headers
          headers:
            X-Floe-Cost-USDC:
              $ref: "#/components/headers/FloeCostUsdc"
            X-Floe-Payment-Amount:
              $ref: "#/components/headers/FloePaymentAmount"
            X-Floe-Payment:
              $ref: "#/components/headers/FloePayment"
            X-Floe-Model:
              $ref: "#/components/headers/FloeModel"
            X-Floe-Rail:
              $ref: "#/components/headers/FloeRail"
          content:
            application/json:
              schema:
                type: object
        "400":
          description: Invalid multipart body, missing model or file
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAiError"
        "401":
          description: Missing auth or wrong_credential_type
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OpenAiError"
        "402":
          description: Insufficient balance, spend limit, or policy exceeded
        "404":
          description: Unknown or disabled model
        "429":
          description: Rate limit exceeded

  /v1/estimate:
    post:
      tags: [Gateway]
      summary: Gateway cost estimate for a usage vector
      description: |
        Prices a hypothetical usage vector against the catalog — no balance
        check, no upstream call, nothing is charged. Powers the MCP tool,
        the AgentKit action, and the dashboard estimator. Accepts any
        authenticated /v1 credential (not agent-key-gated).
      operationId: gatewayEstimate
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [model]
              properties:
                model:
                  type: string
                  description: Catalog model id (`provider/model`).
                input_tokens:
                  type: integer
                  minimum: 1
                output_tokens:
                  type: integer
                  minimum: 1
                cached_input_tokens:
                  type: integer
                  minimum: 1
                characters:
                  type: integer
                  minimum: 1
                audio_seconds:
                  type: integer
                  minimum: 1
                audio_input_tokens:
                  type: integer
                  minimum: 1
                audio_output_tokens:
                  type: integer
                  minimum: 1
      responses:
        "200":
          description: Estimate
          content:
            application/json:
              schema:
                type: object
                properties:
                  model:
                    type: string
                  rail:
                    type: string
                  provider:
                    type: string
                  margin_bps:
                    type: integer
                  usage:
                    type: object
                  upstream_cost_usdc:
                    type: string
                    description: Decimal USDC string.
                  cost_usdc:
                    type: string
                    description: Decimal USDC string (upstream + margin).
                  cost_raw:
                    type: string
                    description: Total charge, raw USDC (6 decimals).
        "400":
          description: Invalid JSON body / missing model
        "404":
          description: unknown_model
        "422":
          description: unpriceable_usage — no source can price the given usage for this model

  /v1/agents/balance:
    get:
      tags: [Agent]
      summary: Balance and credit facility info
      operationId: getAgentBalance
      responses:
        "200":
          description: Balance details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AgentBalance"

  /v1/agents/credit-remaining:
    get:
      tags: [Agent]
      summary: Credit headroom for decision gates
      operationId: getCreditRemaining
      responses:
        "200":
          description: Credit remaining
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditRemaining"

  /v1/agents/loan-state:
    get:
      tags: [Agent]
      summary: Coarse loan state machine
      operationId: getLoanState
      responses:
        "200":
          description: Current state
          content:
            application/json:
              schema:
                type: object
                properties:
                  state:
                    type: string
                    enum: [idle, borrowing, repaying, at_limit]
                  reason:
                    type: string
                  details:
                    type: object

  /v1/agents/transactions:
    get:
      tags: [Agent]
      summary: Paginated payment history
      operationId: getAgentTransactions
      parameters:
        - name: cursor
          in: query
          schema:
            type: integer
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        "200":
          description: Transaction list
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TransactionList"

  /v1/agents/spend-limit:
    get:
      tags: [Agent]
      summary: Get session spend limit
      operationId: getSpendLimit
      responses:
        "200":
          description: Spend limit state
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SpendLimit"
    put:
      tags: [Agent]
      summary: Set session spend limit (resets window)
      description: |
        Resets the spend window. Exception: while the agent is
        selfServiceLocked the cap may only be LOWERED and the running
        window is preserved (accrued spend keeps counting).
      operationId: setSpendLimit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [limitRaw]
              properties:
                limitRaw:
                  type: string
                  pattern: "^\\d+$"
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SpendLimit"
        "403":
          description: self_service_locked — the cap may only be lowered
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"
    delete:
      tags: [Agent]
      summary: Remove session spend limit
      operationId: deleteSpendLimit
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  active:
                    type: boolean
                    enum: [false]
        "403":
          description: self_service_locked — the cap cannot be removed with an agent key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"

  /v1/agents/credit-thresholds:
    get:
      tags: [Agent]
      summary: List credit threshold subscriptions
      operationId: getCreditThresholds
      responses:
        "200":
          description: Subscriptions
          content:
            application/json:
              schema:
                type: object
                properties:
                  subscriptions:
                    type: array
                    items:
                      $ref: "#/components/schemas/CreditThreshold"
    post:
      tags: [Agent]
      summary: Create credit threshold subscription
      operationId: createCreditThreshold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [thresholdBps]
              properties:
                thresholdBps:
                  type: integer
                  minimum: 1
                  maximum: 10000
                webhookId:
                  type: integer
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CreditThreshold"
        "409":
          description: Limit reached (max 20)

  /v1/agents/credit-thresholds/{id}:
    delete:
      tags: [Agent]
      summary: Remove credit threshold
      operationId: deleteCreditThreshold
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: integer

  /v1/agents/close:
    post:
      tags: [Agent]
      summary: Wind down agent
      operationId: closeAgent
      responses:
        "200":
          description: Winddown status
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CloseResult"

  /v1/developer/agents:
    get:
      tags: [Developer]
      summary: List managed agents
      operationId: devListAgents
      security:
        - developerSession: []
      responses:
        "200":
          description: Agent list
          content:
            application/json:
              schema:
                type: object
                properties:
                  agents:
                    type: array
                    items:
                      $ref: "#/components/schemas/ManagedAgent"
    post:
      tags: [Developer]
      summary: Create managed agent (Privy wallet + delegation + welcome credit)
      description: |
        Full provisioning: reserves the agents row, creates the Privy
        executor + payment-signer wallets, submits the on-chain operator
        delegation (sponsored), flips the agent to `active`, and disburses
        the welcome credit to the payment signer (pay-as-you-go default,
        `fundingMode: wallet`). Emits the `agent.created` webhook event on
        success. Does NOT return an API key — mint one via
        POST /v1/developer/agents/{agentId}/keys.
      operationId: devCreateAgent
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, maxRateBps, expirySeconds]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 64
                  pattern: "^[A-Za-z0-9 _-]+$"
                borrowLimitRaw:
                  type: string
                  pattern: "^[1-9]\\d*$"
                  description: Optional on-chain operator borrow limit (raw USDC, 6 decimals). Omitted → wallet-funded agent with the default delegation limit.
                maxRateBps:
                  type: integer
                  minimum: 1
                  maximum: 10000
                expirySeconds:
                  type: integer
                  minimum: 60
                  maximum: 31536000
      responses:
        "201":
          description: Agent provisioned and active
          content:
            application/json:
              schema:
                type: object
                properties:
                  agentId:
                    type: integer
                  status:
                    type: string
                    enum: [active]
                  privyWalletAddress:
                    type: string
                    description: The agent's deposit address (see GET /v1/developer/agents/{agentId}/funding).
                  delegationTxHash:
                    type: string
                  welcomeCreditTxHash:
                    type: string
                    description: Present only when the welcome credit was disbursed.
        "409":
          description: Max agents (5) or name conflict
        "502":
          description: privy_provisioning_failed | delegation_failed (agents row stays pending_delegation)
        "503":
          description: agent_creation_unavailable (Privy / delegation service not configured)

  /v1/developer/agents/{agentId}:
    get:
      tags: [Developer]
      summary: Get agent detail
      operationId: devGetAgent
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Agent detail with credit usage + recent activity snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  agent:
                    $ref: "#/components/schemas/ManagedAgent"
                  creditUsed:
                    type: string
                  recentTransactionCount24h:
                    type: integer
                  sessionSpend:
                    type: object
                    properties:
                      limitRaw:
                        type: string
                        nullable: true
                      startedAtUnix:
                        type: integer
                        nullable: true
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/close:
    post:
      tags: [Developer]
      summary: Wind an agent down (repay loans, sweep USDC, mark closed)
      description: |
        Triggers the wind-down service: repays all active facility loans for
        the agent via the facilitator, transfers any remaining USDC from the
        agent's Privy wallet back to the developer, and marks the agent
        `closed`. Idempotent — calling on an already-closed agent returns
        `{ status: 'closed', loansRepaid: 0, loansRemaining: 0 }`.

        This is the only path that fully retires an agent. `floe-agent revoke`
        only revokes the API key; the operator permission, active loans, and
        on-chain state are untouched.
      operationId: devCloseAgent
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Wind-down result
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [closed]
                  loansRepaid:
                    type: integer
                  loansRemaining:
                    type: integer
                  repayTxHashes:
                    type: array
                    items:
                      type: string
                  transferTxHash:
                    type: string
                    nullable: true
                  usdcTransferred:
                    type: string
                    nullable: true
        "404":
          description: Agent not found or not owned by caller
        "500":
          description: winddown_failed (inspect detail; the agent stays in its prior state)
        "503":
          description: winddown_unavailable (WinddownService not configured)

  /v1/developer/agents/{agentId}/keys:
    get:
      tags: [Developer]
      summary: List agent API keys (prefix only) with budgets
      operationId: devListAgentKeys
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Key list
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiKeySummary"
        "404":
          description: Agent not found or not owned by caller
    post:
      tags: [Developer]
      summary: Mint API key for agent (optionally with a spend budget)
      description: |
        Emits the `key.created` webhook event. When `budgetRaw` is provided
        the key is capped at creation time (fail-closed — if the budget
        write fails, no key is issued).
      operationId: devCreateKey
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 100
                permissions:
                  type: string
                  enum: [read, read_write]
                budgetRaw:
                  type: string
                  pattern: "^[1-9]\\d*$"
                  description: Optional per-key spend budget (raw USDC, 6 decimals).
                windowSeconds:
                  type: integer
                  minimum: 60
                  maximum: 31536000
      responses:
        "201":
          description: Key created (plaintext `key` shown once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKey"
        "409":
          description: Active-key cap per agent reached (default 5)

  /v1/developer/agents/{agentId}/keys/{keyId}:
    delete:
      tags: [Developer]
      summary: Revoke API key
      operationId: devRevokeKey
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: keyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked:
                    type: boolean

  /v1/developer/agents/{agentId}/keys/{keyId}/rotate:
    post:
      tags: [Developer]
      summary: Atomic key rotation (revoke + mint)
      operationId: devRotateKey
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: keyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "201":
          description: New key (fullKey shown once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKey"

  /v1/developer/agents/{agentId}/open-credit-line:
    post:
      tags: [Developer]
      summary: Open a managed agent's USDC/USDC credit line
      description: |
        Server-signs registerBorrowIntent from the agent's managed Privy wallet,
        creating an in-flight facility_loans row that the existing reconciler
        + solver advance to status='active' asynchronously. The agent's Privy
        wallet must hold at least `depositRaw` USDC before this is called.

        USDC/USDC same-token market only. Borrow amount is
        `depositRaw * maxLtvBps / 10000` (default 95%).

        The response shape returns the in-flight row's id + the on-chain
        registerTxHash. `borrowIntentHash` is null on initial return — the
        reconciler fills it in after the receipt confirms and parses
        LogBorrowerOfferPosted. Once the solver matches the intent against
        an open lend offer, status flips to 'active' and BalanceService
        starts including the loan in `creditIn` for paid /proxy/fetch calls.
      operationId: devOpenCreditLine
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [depositRaw]
              properties:
                depositRaw:
                  type: string
                  pattern: "^[1-9]\\d*$"
                  description: USDC deposit amount, raw 6-decimal units (e.g. "10000000000" = $10K).
                maxLtvBps:
                  type: integer
                  minimum: 1
                  maximum: 9500
                  description: Optional LTV cap in bps. Defaults to 9500 (95%).
                maxRateBps:
                  type: integer
                  minimum: 1
                  maximum: 10000
                  description: Optional max interest rate ceiling. Defaults to the agent's `maxRateBps`.
      responses:
        "201":
          description: Credit-line opening submitted; awaiting solver match
          content:
            application/json:
              schema:
                type: object
                properties:
                  loanId:
                    type: string
                    description: Synthetic placeholder `pending:<uuid>` until the on-chain loanId is recorded.
                  borrowIntentHash:
                    type: string
                    nullable: true
                  approveTxHash:
                    type: string
                    nullable: true
                    description: Null when the Privy wallet's existing allowance was already sufficient.
                  registerTxHash:
                    type: string
                  principalRaw:
                    type: string
                  collateralAmountRaw:
                    type: string
                  rateBps:
                    type: integer
                  status:
                    type: string
                    enum: [pending_on_chain]
        "400":
          description: invalid input | insufficient_privy_balance | agent_not_managed | invalid_max_ltv | invalid_deposit
        "404":
          description: Agent not found or not owned by caller
        "409":
          description: agent_not_active | delegation_expired | existing_active_credit_line
        "502":
          description: rpc_read_failed | market_not_created | privy_send_failed
        "503":
          description: ManagedCreditLineService not initialized (Privy missing)

  /v1/capabilities:
    get:
      tags: [Public]
      summary: Feature-flag capabilities probe
      description: |
        Reports which env-flag-mounted surfaces are live on this deployment
        so clients can distinguish "disabled" from "broken". Public.
      operationId: getCapabilities
      security: []
      responses:
        "200":
          description: Capability booleans + API version
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Capabilities"

  /v1/playground/vendors:
    get:
      tags: [Developer]
      summary: Verified-live vendor probe results (playground)
      description: |
        Cached results of the real x402 probe calls the scheduler makes
        against marketplace vendors, so the dashboard playground can render
        "verified live · Nh ago" proof + a health badge. Developer session
        required; `down`-path error excerpts can carry Floe operational
        detail, hence not public.
      operationId: getPlaygroundVendors
      security:
        - developerSession: []
      responses:
        "200":
          description: Latest cached probe per vendor + server `now` for freshness rendering
          content:
            application/json:
              schema:
                type: object
                properties:
                  now:
                    type: string
                    format: date-time
                  vendors:
                    type: array
                    items:
                      type: object
                      properties:
                        vendor:
                          type: string
                        name:
                          type: string
                        endpoint:
                          type: string
                        method:
                          type: string
                        priceUsdc:
                          type: string
                        status:
                          type: string
                        responseExcerpt:
                          type: string
                          nullable: true
                        costRaw:
                          type: string
                          nullable: true
                          description: Probe charge, raw USDC (6 decimals).
                        latencyMs:
                          type: integer
                          nullable: true
                        checkedAt:
                          type: string
                          format: date-time
        "401":
          description: Developer session required
        "429":
          description: IP rate limit exceeded

  /v1/developer/profile:
    get:
      tags: [Developer]
      summary: Developer profile + agents + credit usage
      operationId: devGetProfile
      security:
        - developerSession: []
      responses:
        "200":
          description: Profile (includes `aiToolConnectedAt`, the first floe-cli/ / floe-mcp/ User-Agent seen) + full agents list with per-agent creditUsed
          content:
            application/json:
              schema:
                type: object
                properties:
                  developer:
                    type: object
                    properties:
                      walletAddress:
                        type: string
                      displayName:
                        type: string
                        nullable: true
                      email:
                        type: string
                        nullable: true
                      accountId:
                        type: string
                        nullable: true
                      role:
                        type: string
                        nullable: true
                        enum: [owner, admin, member, viewer, null]
                      createdAt:
                        type: string
                        format: date-time
                      embeddedWalletAddress:
                        type: string
                        nullable: true
                      embeddedWalletExportedAt:
                        type: string
                        format: date-time
                        nullable: true
                      aiToolConnectedAt:
                        type: string
                        format: date-time
                        nullable: true
                  agents:
                    type: array
                    items:
                      allOf:
                        - $ref: "#/components/schemas/ManagedAgent"
                        - type: object
                          properties:
                            creditUsed:
                              type: string
        "404":
          description: Developer not registered

  /v1/developer/keys:
    get:
      tags: [Developer]
      summary: List developer API keys (prefix only)
      operationId: devListDeveloperKeys
      security:
        - developerSession: []
      responses:
        "200":
          description: Key list
          content:
            application/json:
              schema:
                type: object
                properties:
                  keys:
                    type: array
                    items:
                      $ref: "#/components/schemas/ApiKeySummary"
    post:
      tags: [Developer]
      summary: Mint a developer key (floe_live_*, plaintext shown once)
      description: Owner/admin role required. Emits the `key.created` webhook event.
      operationId: devCreateDeveloperKey
      security:
        - developerSession: []
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 100
                permissions:
                  type: string
                  enum: [read, read_write]
      responses:
        "201":
          description: Key created (plaintext `key` shown once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKey"
        "400":
          description: Max 5 API keys

  /v1/developer/keys/{keyId}:
    delete:
      tags: [Developer]
      summary: Revoke a developer key
      operationId: devRevokeDeveloperKey
      security:
        - developerSession: []
      parameters:
        - name: keyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Revoked
        "404":
          description: Key not found or already revoked

  /v1/developer/keys/{keyId}/rotate:
    post:
      tags: [Developer]
      summary: Atomic developer-key rotation (revoke + mint)
      description: Emits the `key.rotated` webhook event.
      operationId: devRotateDeveloperKey
      security:
        - developerSession: []
      parameters:
        - name: keyId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                label:
                  type: string
                  maxLength: 100
                permissions:
                  type: string
                  enum: [read, read_write]
      responses:
        "201":
          description: New key (plaintext `key` shown once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKey"
        "404":
          description: Key not found or already revoked

  /v1/developer/provider-keys:
    get:
      tags: [Developer]
      summary: List stored BYOK provider keys (masked — never key material)
      operationId: devListProviderKeys
      security:
        - developerSession: []
      responses:
        "200":
          description: Stored vendor keys + the vendors a key can be stored for
          content:
            application/json:
              schema:
                type: object
                properties:
                  providerKeys:
                    type: array
                    items:
                      $ref: "#/components/schemas/ProviderKeySummary"
                  supportedProviders:
                    type: array
                    items:
                      type: string

  /v1/developer/provider-keys/{provider}:
    put:
      tags: [Developer]
      summary: Save or replace the account's own vendor API key (BYOK)
      description: >
        Owner/admin role required. With a key stored, gateway calls from the
        account's agents route through it — the developer's own vendor credits
        pay for inference and Floe bills only the BYOK service fee; keyless
        rails remain as fallback. The per-request X-Floe-Provider-Key header
        still overrides. Emits the `provider_key.created` webhook event (covers
        replace). The plaintext key is never returned by any endpoint.
      operationId: devPutProviderKey
      security:
        - developerSession: []
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
          description: Catalog provider id (openai, anthropic, google, ...)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [key]
              properties:
                key:
                  type: string
                  minLength: 8
                  maxLength: 512
                  description: The vendor API key. Stored AES-256-GCM encrypted.
                label:
                  type: string
                  maxLength: 100
                  description: >
                    When OMITTED on a replace, the existing row's label is
                    preserved.
                enabled:
                  type: boolean
                  description: >
                    New rows default to true. When OMITTED on a replace, the
                    existing row's enabled state is preserved — rotating a key
                    never silently re-enables a deliberately disabled provider.
      responses:
        "201":
          description: Stored (masked summary — plaintext never echoed)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProviderKeySummary"
        "400":
          description: Unknown provider or invalid key shape
    patch:
      tags: [Developer]
      summary: Enable/disable a stored provider key without re-entering it
      description: Emits the `provider_key.updated` webhook event.
      operationId: devPatchProviderKey
      security:
        - developerSession: []
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled:
                  type: boolean
      responses:
        "200":
          description: Updated
        "404":
          description: No stored key for this provider
    delete:
      tags: [Developer]
      summary: Remove a stored provider key (hard delete of the ciphertext)
      description: Emits the `provider_key.deleted` webhook event.
      operationId: devDeleteProviderKey
      security:
        - developerSession: []
      parameters:
        - name: provider
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Removed
        "404":
          description: No stored key for this provider

  /v1/developer/agents/{agentId}/status:
    patch:
      tags: [Developer]
      summary: Pause / resume an agent (kill-switch)
      description: |
        Allowed transitions only: active → suspended (pause) and
        suspended → active (resume). A pause takes effect on the agent's
        next call (agent-key auth 403s suspended agents).
      operationId: devPatchAgentStatus
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [active, suspended]
      responses:
        "200":
          description: Updated (idempotent when already in the requested state)
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: integer
                  status:
                    type: string
                  suspendedReason:
                    type: string
                    nullable: true
        "404":
          description: Agent not found or not owned by caller
        "409":
          description: invalid_status_transition | status_conflict

  /v1/developer/agents/{agentId}/funding:
    get:
      tags: [Developer]
      summary: Machine-readable funding instructions
      operationId: devGetAgentFunding
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Funding card — deposit address, chain, token contract, forwarding status, spendable balance
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FundingInstructions"
        "404":
          description: Agent not found or not owned by caller
        "409":
          description: agent_not_provisioned (no deposit wallet yet)

  /v1/developer/agents/{agentId}/self-service-lock:
    get:
      tags: [Developer]
      summary: Read the agent's tighten-only lock
      operationId: devGetSelfServiceLock
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Lock state
          content:
            application/json:
              schema:
                type: object
                properties:
                  locked:
                    type: boolean
        "404":
          description: Agent not found or not owned by caller
    put:
      tags: [Developer]
      summary: Set the agent's tighten-only lock
      description: |
        When locked, the agent's OWN key may only tighten its guardrails —
        PUT /v1/agents/spend-limit must lower the cap, DELETE is refused,
        and policy writes may only add/tighten. Developer-authed surfaces
        are unaffected.
      operationId: devSetSelfServiceLock
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [locked]
              properties:
                locked:
                  type: boolean
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  locked:
                    type: boolean
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/credit-line-bounds:
    get:
      tags: [Developer]
      summary: Credit-line preview — LTV bounds, funded balances, in-flight/active loan
      operationId: devGetCreditLineBounds
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Bounds + balances
          content:
            application/json:
              schema:
                type: object
                properties:
                  minLtvBps:
                    type: integer
                  maxLtvBps:
                    type: integer
                  maxRateBpsCap:
                    type: integer
                  agentMaxRateBps:
                    type: integer
                    nullable: true
                  walletBalanceRaw:
                    type: string
                  spendableBalanceRaw:
                    type: string
                  paymentSignerWalletAddress:
                    type: string
                    nullable: true
                  fundingMode:
                    type: string
                    enum: [wallet, credit_line]
                  fundedSpendableRaw:
                    type: string
                    nullable: true
                  fundedPendingRaw:
                    type: string
                    nullable: true
                  availableRaw:
                    type: string
                    nullable: true
                    description: |
                      Ledger-derived spendable balance for EVERY funding mode (raw
                      6-dp USDC) — the exact number every enforcement path gates on
                      (x402 tools, phone rental, the limit chain, Test-a-call). This
                      is the "Spendable now" figure; walletBalanceRaw /
                      spendableBalanceRaw are on-chain holdings, which the ledger may
                      or may not count. Null only on a ledger derive failure.
                  ledgerSpentRaw:
                    type: string
                    nullable: true
                    description: Ledger creditOut (settled + in-flight spend, platform fee included) that pairs with availableRaw.
                  ledgerCreditInRaw:
                    type: string
                    nullable: true
                    description: Ledger creditIn (wallet_credits for wallet mode, active loan principal for credit_line). availableRaw = creditIn − ledgerSpentRaw − unspent task holds.
                  inFlightLoan:
                    type: object
                    nullable: true
                  activeLoan:
                    type: object
                    nullable: true
                  closePreview:
                    type: object
                    nullable: true
        "404":
          description: Agent not found or not owned by caller
        "409":
          description: agent_not_active
        "502":
          description: rpc_read_failed | market_not_created

  /v1/developer/agents/{agentId}/policies:
    get:
      tags: [Developer]
      summary: List an agent's spend policies
      operationId: devListAgentPolicies
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: includeRevoked
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Policy list
          content:
            application/json:
              schema:
                type: object
                properties:
                  policies:
                    type: array
                    items:
                      $ref: "#/components/schemas/AgentPolicy"
        "404":
          description: Agent not found or not owned by caller
    post:
      tags: [Developer]
      summary: Create a spend policy for an agent
      operationId: devCreateAgentPolicy
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePolicyRequest"
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  policy:
                    $ref: "#/components/schemas/AgentPolicy"
        "409":
          description: duplicate_active_policy

  /v1/developer/agents/{agentId}/policies/{policyId}:
    patch:
      tags: [Developer]
      summary: Update a spend policy
      operationId: devUpdateAgentPolicy
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: policyId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Partial update — same fields as CreatePolicyRequest minus kind/matchKey.
      responses:
        "200":
          description: Updated policy
        "404":
          description: Policy or agent not found
    delete:
      tags: [Developer]
      summary: Revoke a spend policy
      operationId: devDeleteAgentPolicy
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: policyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Revoked
        "404":
          description: Policy or agent not found

  /v1/developer/policies:
    get:
      tags: [Developer]
      summary: List team-wide (all-agents) policies
      operationId: devListTeamPolicies
      security:
        - developerSession: []
      responses:
        "200":
          description: Policy list
          content:
            application/json:
              schema:
                type: object
                properties:
                  policies:
                    type: array
                    items:
                      $ref: "#/components/schemas/AgentPolicy"
    post:
      tags: [Developer]
      summary: Create a team-wide policy (caps spend across every agent)
      operationId: devCreateTeamPolicy
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: "#/components/schemas/CreatePolicyRequest"
                - type: object
                  description: Team policies additionally allow kind 'session' (matchKey omitted).
      responses:
        "201":
          description: Created
        "409":
          description: duplicate_active_policy

  /v1/developer/webhooks:
    get:
      tags: [Developer]
      summary: List webhooks
      operationId: devListWebhooks
      security:
        - developerSession: []
      responses:
        "200":
          description: Webhook list (secrets omitted)
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: "#/components/schemas/Webhook"
    post:
      tags: [Developer]
      summary: Create a webhook
      operationId: devCreateWebhook
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events, scope]
              properties:
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  minItems: 1
                  items:
                    $ref: "#/components/schemas/SubscribableEvent"
                scope:
                  type: string
                  enum: [global, wallet, agent, loan]
                  description: >
                    wallet and agent are synonyms — both match events about one
                    agent wallet. agent is what the dashboard's agent-scoped
                    screen posts.
                scopeValue:
                  type: string
                  description: >
                    Required for non-global scopes. wallet/agent — a 0x wallet
                    address (for agent scope that is the agent's WALLET
                    address; the numeric agent id is rejected). loan — a
                    numeric loan id. Must be absent for global.
                description:
                  type: string
                  maxLength: 256
      responses:
        "201":
          description: Created (secret returned once)
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhook:
                    $ref: "#/components/schemas/Webhook"
        "400":
          description: >
            Invalid body / URL / unknown event, invalid_scope (carries a
            machine-readable `next`), or max 10 webhooks

  /v1/developer/webhooks/events:
    get:
      tags: [Developer]
      summary: Subscribable webhook event catalog
      description: >
        The 30 subscribable events with human titles/descriptions, dashboard
        category, and the scope dimension each routes on. Wildcards (`*`,
        `<prefix>.*`) are accepted in subscriptions alongside these names.
      operationId: devListWebhookEvents
      security:
        - developerSession: []
      responses:
        "200":
          description: Event catalog
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookCatalogEvent"

  /v1/developer/webhooks/{id}:
    get:
      tags: [Developer]
      summary: Get webhook + delivery stats
      operationId: devGetWebhook
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: >
            Webhook + deliveryStats ({pending, success, failed, retrying,
            total} counts over the retained 30-day delivery window)
        "404":
          description: Not found
    patch:
      tags: [Developer]
      summary: Update url / events / active / description
      operationId: devUpdateWebhook
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  minItems: 1
                  items:
                    $ref: "#/components/schemas/SubscribableEvent"
                active:
                  type: boolean
                description:
                  type: string
      responses:
        "200":
          description: Updated
        "404":
          description: Not found
    delete:
      tags: [Developer]
      summary: Delete a webhook
      operationId: devDeleteWebhook
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Deleted
        "404":
          description: Not found

  /v1/developer/webhooks/{id}/test:
    post:
      tags: [Developer]
      summary: Send a test delivery
      description: One-shot — a failed test delivery stays failed and is never retried.
      operationId: devTestWebhook
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Delivery result
          content:
            application/json:
              schema:
                type: object
                properties:
                  success:
                    type: boolean
                  statusCode:
                    type: integer
                  error:
                    type: string
                  deliveryId:
                    type: string
        "404":
          description: Not found

  /v1/developer/webhooks/{id}/rotate-secret:
    post:
      tags: [Developer]
      summary: Rotate the webhook signing secret (returned once)
      operationId: devRotateWebhookSecret
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: New secret
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret:
                    type: string
        "404":
          description: Not found

  /v1/developer/webhooks/{id}/deliveries:
    get:
      tags: [Developer]
      summary: Paginated delivery log (single webhook)
      description: >
        Offset-paginated deliveries for one webhook. Rows are retained 30
        days. For the filterable account-wide log (all webhooks, keyset
        cursor), use GET /v1/developer/webhook-deliveries.
      operationId: devListWebhookDeliveries
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        "200":
          description: Deliveries
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        deliveryId:
                          type: string
                        event:
                          type: string
                        statusCode:
                          type: integer
                          nullable: true
                        status:
                          type: string
                        attempt:
                          type: integer
                        error:
                          type: string
                          nullable: true
                        createdAt:
                          type: string
                          format: date-time
        "404":
          description: Not found

  /v1/developer/webhook-deliveries:
    get:
      tags: [Developer]
      summary: Account-wide webhook delivery log
      description: >
        Filterable, keyset-paginated delivery log across every webhook the
        account owns. Rows are poll-weight (no request/response bodies —
        fetch GET /v1/developer/webhook-deliveries/{deliveryId} for those)
        and are retained 30 days. Pass `nextCursor` back verbatim as
        `cursor` for the next page.
      operationId: devListAccountWebhookDeliveries
      security:
        - developerSession: []
      parameters:
        - name: endpoint
          in: query
          description: Filter to one webhook by its numeric id.
          schema:
            type: integer
        - name: event
          in: query
          description: Exact event name, e.g. call.ended.
          schema:
            type: string
            maxLength: 128
        - name: agent
          in: query
          description: Agent wallet the events are about (0x address, any case).
          schema:
            type: string
            pattern: "^0x[a-fA-F0-9]{40}$"
        - name: status
          in: query
          schema:
            type: string
            enum: [pending, success, failed, retrying]
        - name: from
          in: query
          description: Only deliveries created at/after this ISO 8601 timestamp.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Only deliveries created at/before this ISO 8601 timestamp.
          schema:
            type: string
            format: date-time
        - name: id
          in: query
          description: >
            ID search — matches either the correlationId (provider call id /
            Twilio CallSid / job id / loan id) or the deliveryId.
          schema:
            type: string
            minLength: 1
            maxLength: 128
        - name: cursor
          in: query
          description: Opaque keyset cursor — the nextCursor of a previous page, verbatim.
          schema:
            type: string
        - name: limit
          in: query
          description: Page size, clamped to [1, 100] (out-of-range values do not 400).
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        "200":
          description: Deliveries, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  deliveries:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookDeliveryLogRow"
                  nextCursor:
                    type: string
                    nullable: true
                    description: Pass back as `cursor` for the next page; null on the last page.
                  hasMore:
                    type: boolean
        "400":
          description: invalid_filter / invalid_cursor (carries a machine-readable `next`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"

  /v1/developer/webhook-deliveries/{deliveryId}:
    get:
      tags: [Developer]
      summary: Full sanitized delivery detail
      description: >
        The log row plus the delivered payload (exactly what was sent —
        allowlist-extracted at emit time), the receiver's response body
        (control-char stripped, capped at 1 KiB), and the next scheduled
        retry, if any. The dashboard drawer lazy-fetches this per row.
      operationId: devGetAccountWebhookDelivery
      security:
        - developerSession: []
      parameters:
        - name: deliveryId
          in: path
          required: true
          description: The 32-hex delivery id (equals the X-Floe-Delivery-Id header).
          schema:
            type: string
      responses:
        "200":
          description: Delivery detail
          content:
            application/json:
              schema:
                type: object
                properties:
                  delivery:
                    allOf:
                      - $ref: "#/components/schemas/WebhookDeliveryLogRow"
                      - type: object
                        properties:
                          payload:
                            type: object
                            description: The JSON body delivered to the webhook URL.
                          responseBody:
                            type: string
                            nullable: true
                          nextRetryAt:
                            type: string
                            format: date-time
                            nullable: true
        "404":
          description: delivery_not_found (carries a machine-readable `next`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"

  /v1/developer/balances:
    get:
      tags: [Developer]
      summary: Aggregate USDC balances (developer wallet + agent wallets + API credits)
      operationId: devGetBalances
      security:
        - developerSession: []
      responses:
        "200":
          description: Balance rollup
          content:
            application/json:
              schema:
                type: object
                properties:
                  developerWalletBalanceRaw:
                    type: string
                  agentWalletsBalanceRaw:
                    type: string
                  apiCreditsAvailableRaw:
                    type: string
                  currency:
                    type: string
                    enum: [USDC]
                  decimals:
                    type: integer
                    enum: [6]

  /v1/developer/agent/transactions:
    get:
      tags: [Developer]
      summary: Paginated x402 transaction history (deprecated)
      description: |
        DEPRECATED in favor of GET /v1/developer/activity?type=x402_call,
        which returns the same proxy_request data plus three more event
        sources behind a discriminated `type` union. This route keeps
        working unchanged (shape, params, errors are stable; no removal
        scheduled) and every response — including errors — carries
        `Deprecation: true` and a `Link: <...>; rel="successor-version"`
        header (RFC 8594). Default unions across all the caller's agents;
        `?agentId=` scopes to one owned agent (404 on cross-tenant).
      operationId: devGetAgentTransactions
      deprecated: true
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: query
          schema:
            type: integer
        - name: cursor
          in: query
          schema:
            type: integer
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
      responses:
        "200":
          description: Transaction rows (raw proxy_requests) with keyset pagination
          headers:
            Deprecation:
              schema:
                type: string
                enum: ["true"]
              description: RFC 8594 deprecation signal (set on every response).
            Link:
              schema:
                type: string
              description: '`</v1/developer/activity?type=x402_call>; rel="successor-version"`'
          content:
            application/json:
              schema:
                type: object
                properties:
                  transactions:
                    type: array
                    items:
                      type: object
                  nextCursor:
                    type: integer
                    nullable: true
                  hasMore:
                    type: boolean
        "404":
          description: No agents registered / agent not owned

  /v1/developer/activity:
    get:
      tags: [Developer]
      summary: Unified activity feed (x402 calls, onramp, transfers, loans)
      operationId: devGetActivity
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: query
          schema:
            type: integer
        - name: apiKeyId
          in: query
          schema:
            type: integer
          description: >
            Scope to one owned API key (404 on cross-tenant or unknown key).
            Implicitly narrows the feed to type=x402_call — the other sources
            carry no api_key_id — and intersects with any explicit ?type=
            filter (a non-x402 type combined with apiKeyId yields an empty set).
        - name: type
          in: query
          schema:
            type: string
          description: >
            CSV of event types to include. Allowed: x402_call, onramp_purchase,
            onramp_sweep, transfer_deposit, transfer_withdrawal,
            transfer_external, facility_loan_match, facility_loan_repay,
            facility_loan_rollover, facility_loan_failed.
        - name: since
          in: query
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          schema:
            type: string
            format: date-time
        - name: cursor
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: expand
          in: query
          schema:
            type: string
            enum: [details]
      responses:
        "200":
          description: Keyset-paginated events
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                        type:
                          type: string
                        agent:
                          type: object
                        timestamp:
                          type: string
                        status:
                          type: string
                        summary:
                          type: string
                        amountRaw:
                          type: string
                          nullable: true
                        txHash:
                          type: string
                          nullable: true
                        details:
                          type: object
                  nextCursor:
                    type: string
                    nullable: true
                  hasMore:
                    type: boolean
        "404":
          description: No agents registered / agent not owned

  /v1/developer/analytics/summary:
    get:
      tags: [Developer]
      summary: KPI rollup — totals, time series, top endpoints
      operationId: devGetAnalyticsSummary
      security:
        - developerSession: []
      parameters:
        - name: window
          in: query
          schema:
            type: string
            enum: [24h, 7d, 30d, all]
            default: 7d
        - name: agentId
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: Summary (see AnalyticsSummaryResponse in routes/developer/analytics.ts)
          content:
            application/json:
              schema:
                type: object
                properties:
                  window:
                    type: object
                  agents:
                    type: array
                    items:
                      type: object
                  totals:
                    type: object
                  timeSeries:
                    type: object
                  topEndpoints:
                    type: array
                    items:
                      type: object

  /v1/developer/spend-series:
    get:
      tags: [Developer]
      summary: Daily spend series for the dashboard chart
      description: |
        Aggregates proxy_requests for the developer's agents (or one owned
        agent via ?agentId=) over the last `days` UTC calendar days. Spend
        inclusion mirrors PolicyService.getSpend exactly (status
        success|pending AND payment_amount_raw set, valued as payment +
        platform fee) so the chart and policy enforcement can never
        disagree. A developer with no agents gets an empty zero-filled
        series, not a 404.
      operationId: devGetSpendSeries
      security:
        - developerSession: []
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 30
        - name: agentId
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: Dense zero-filled daily series + top-8 vendor rollup (+ 'other') + window totals
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: integer
                  series:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                          description: UTC calendar day, YYYY-MM-DD.
                        totalRaw:
                          type: string
                          description: Spend that day, raw USDC (6 decimals).
                  byVendor:
                    type: array
                    items:
                      type: object
                      properties:
                        host:
                          type: string
                        totalRaw:
                          type: string
                  totals:
                    type: object
                    properties:
                      requests:
                        type: integer
                        description: Every row in the window regardless of status.
                      declined:
                        type: integer
                        description: status IN (failed, rejected).
                      totalRaw:
                        type: string
        "400":
          description: Invalid days / agentId
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/ledger:
    get:
      tags: [Developer]
      summary: Cross-source ledger rollup (Floe rails + reconciled orchestrators)
      description: |
        One money view across every execution path — gateway rails, x402
        proxy, Floe Phone, and orchestrator-reconciled spend (Vapi/Retell/
        Bland call-end ingests) — rolled up by the requested dimension.
        Untagged rows bucket under 'untagged' (tagged=false) for the
        customer/campaign dimensions; top 50 rows + an 'other' remainder.
      operationId: devGetLedger
      security:
        - developerSession: []
      parameters:
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 30
        - name: groupBy
          in: query
          schema:
            type: string
            enum: [source, customer, campaign, agent]
            default: source
        - name: agentId
          in: query
          schema:
            type: integer
      responses:
        "200":
          description: Rollup rows sorted by spend
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: integer
                  groupBy:
                    type: string
                  totalRaw:
                    type: string
                    description: Window total, raw USDC (6 decimals).
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        key:
                          type: string
                        tagged:
                          type: boolean
                          description: False = rows missing the tag (filter on this, never the label).
                        calls:
                          type: integer
                        costRaw:
                          type: string
                        reconciledRaw:
                          type: string
                          description: Portion of the bucket that was orchestrator-reconciled, raw USDC (6 decimals).
        "400":
          description: Invalid days / groupBy / agentId
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/coverage:
    get:
      tags: [Developer]
      summary: Governance coverage score (pre-call enforceable vs reconciled)
      description: |
        What share of the agent's KNOWN spend was pre-call enforceable
        (Floe was in the path) vs post-call reconciled (ingested from an
        orchestrator webhook). Spend on platforms never wired to Floe is
        definitionally invisible — framed as `dark: "unknown"`, never faked
        with a number. Out-of-range `days` values are clamped to 1–90, not
        rejected.
      operationId: devGetAgentCoverage
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 30
      responses:
        "200":
          description: Coverage totals + per-source split + daily series
          content:
            application/json:
              schema:
                type: object
                properties:
                  days:
                    type: integer
                  totals:
                    type: object
                    properties:
                      knownRaw:
                        type: string
                      enforceableRaw:
                        type: string
                      reconciledRaw:
                        type: string
                      coverageBps:
                        type: integer
                        nullable: true
                        description: Share of known spend that was pre-call enforceable, in bps. null = no spend in the window (NOT "100% covered").
                  bySource:
                    type: array
                    items:
                      type: object
                      properties:
                        source:
                          type: string
                          description: vapi | retell | bland | orchestrator | floe-phone | x402-proxy | floe-gateway
                        class:
                          type: string
                          enum: [enforceable, reconciled]
                        calls:
                          type: integer
                        costRaw:
                          type: string
                  series:
                    type: array
                    items:
                      type: object
                      properties:
                        date:
                          type: string
                        enforceableRaw:
                          type: string
                        reconciledRaw:
                          type: string
                  calls:
                    type: object
                    description: Call counts over the same rows `totals` sums. A top-level sibling of `totals` (which is a frozen money contract). `byok` is the subset of `enforceable` served on the caller's own vendor key (rail byok, fee-only rows) — the signal the dashboard uses to tell a keyless-only agent from a BYOK agent whose calls landed at $0.
                    properties:
                      total:
                        type: integer
                      enforceable:
                        type: integer
                      reconciled:
                        type: integer
                      byok:
                        type: integer
                  dark:
                    type: string
                    enum: [unknown]
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/spend-limit:
    get:
      tags: [Developer]
      summary: Read an agent's session spend limit (dashboard mirror)
      description: |
        Developer-session mirror of GET /v1/agents/spend-limit — same
        response shape, agent addressed by path instead of key.
      operationId: devGetAgentSpendLimit
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Spend limit state (active=false → limitRaw/sessionStartedAt null)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SpendLimit"
        "404":
          description: Agent not found or not owned by caller
    put:
      tags: [Developer]
      summary: Set an agent's session spend limit (resets window)
      description: |
        Dual-write: the canonical store is the kind='session' policy row,
        mirrored into the legacy agents column. RESTARTS the session window
        (anything spent before the PUT no longer counts). Unlike the
        agent-key surface, this developer route is NOT subject to the
        tighten-only self-service lock.
      operationId: devSetAgentSpendLimit
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [limitRaw]
              properties:
                limitRaw:
                  type: string
                  pattern: "^\\d+$"
                  description: Session cap, raw USDC (6 decimals). Must be > 0.
      responses:
        "200":
          description: Updated (active, limitRaw, sessionStartedAt)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SpendLimit"
        "400":
          description: Invalid body or non-positive limitRaw
        "404":
          description: Agent not found or not owned by caller
    delete:
      tags: [Developer]
      summary: Remove an agent's session spend limit
      description: Clears both the policy row and the legacy column; enforcement stops at the next paid call.
      operationId: devDeleteAgentSpendLimit
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  active:
                    type: boolean
                    enum: [false]
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/keys/{keyId}/budget:
    put:
      tags: [Developer]
      summary: Set or replace a per-API-key spend budget
      description: |
        A per-key budget is enforced on the same pay path as session/task/
        vendor caps — tightest applicable cap wins. The keyId must name an
        ACTIVE agent key of THIS agent (revoked or cross-tenant keys 404).
        A 0 budget is invalid — DELETE the budget to remove the cap instead.
      operationId: devPutKeyBudget
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: keyId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [budgetRaw]
              properties:
                budgetRaw:
                  type: string
                  pattern: "^[1-9]\\d*$"
                  description: Per-key spend budget, raw USDC (6 decimals).
                windowSeconds:
                  type: integer
                  minimum: 60
                  maximum: 31536000
                  description: Rolling-budget period. Omitted → service default.
      responses:
        "200":
          description: The stored budget + derived spend
          content:
            application/json:
              schema:
                type: object
                properties:
                  budget:
                    $ref: "#/components/schemas/KeyBudget"
        "400":
          description: Invalid body or invalid_budget
        "404":
          description: Agent/key not found, not owned, or key revoked
    delete:
      tags: [Developer]
      summary: Clear a per-API-key spend budget
      operationId: devDeleteKeyBudget
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: keyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Cleared
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [cleared]
        "404":
          description: Agent/key/budget not found or not owned

  /v1/developer/agents/{agentId}/limit-chain:
    get:
      tags: [Developer]
      summary: Every live cap constraining an agent, with derived spend
      description: |
        One row per live policy across both scopes (agent + developer/team),
        each with the same derived spend the enforcement path uses, PLUS a
        final scope='balance' row (spendable balance, netting in-flight
        reservations + task holds). Read-only; powers the dashboard's
        Limits panel.
      operationId: devGetAgentLimitChain
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: The chain, balance row last
          content:
            application/json:
              schema:
                type: object
                properties:
                  agentId:
                    type: integer
                  asOf:
                    type: string
                    format: date-time
                  chain:
                    type: array
                    items:
                      $ref: "#/components/schemas/LimitChainRow"
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/resolve:
    post:
      tags: [Developer]
      summary: Dry-run a call against the live decision gates ("Test a call")
      description: |
        Replicates the proxy's decision gates in their live order (allowlist
        → balance → policy evaluate) WITHOUT reserving, inserting, or paying
        anything — evaluate() is side-effect-free, so the verdict is the
        real enforcement decision. Decline payloads mirror the real proxy
        error bodies. `amountRaw` is the TOTAL charge (vendor amount + Floe
        platform fee) — the number every gate checks. Omitting `recipient`
        makes vendor-dimension policies fail closed, exactly like the live
        path.
      operationId: devResolveAgentCall
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [host, amountRaw]
              properties:
                host:
                  type: string
                  minLength: 1
                  maxLength: 255
                recipient:
                  type: string
                  pattern: "^0x[a-fA-F0-9]{40}$"
                  description: The 402's payee wallet, when known.
                amountRaw:
                  type: string
                  pattern: "^\\d+$"
                  description: Total charge to test, raw USDC (6 decimals).
                taskId:
                  type: string
                  minLength: 1
                  maxLength: 128
                keyId:
                  type: integer
                  minimum: 1
                  description: Simulate the call arriving on this key (404 unless an active key of this agent).
      responses:
        "200":
          description: >
            Verdict. approve → { decision, effectiveRemainingRaw, binding }
            where binding is the applicable cap (or balance row) with the
            least headroom. decline → { decision, decline } where decline
            mirrors the live proxy error body (host_not_allowlisted,
            vendor_not_allowlisted, insufficient_balance,
            spend_limit_exceeded, policy_exceeded, unresolvable_host,
            unresolvable_recipient).
          content:
            application/json:
              schema:
                type: object
                properties:
                  decision:
                    type: string
                    enum: [approve, decline]
                  effectiveRemainingRaw:
                    type: string
                    description: Present on approve — headroom on the binding cap, raw USDC (6 decimals).
                  binding:
                    $ref: "#/components/schemas/LimitChainRow"
                  decline:
                    type: object
                    description: Present on decline — the live-path error body.
        "400":
          description: Invalid body
        "404":
          description: Agent (or keyId) not found or not owned by caller

  /v1/developer/phone/numbers:
    get:
      tags: [Developer]
      summary: Fleet phone numbers with 7d calls + MTD spend
      description: |
        Live (non-released) numbers across every agent the developer owns,
        each with a 7-day distinct-call count and month-to-date spend
        (rental + calls).
      operationId: devListFleetPhoneNumbers
      security:
        - developerSession: []
      responses:
        "200":
          description: Fleet numbers
          content:
            application/json:
              schema:
                type: object
                properties:
                  numbers:
                    type: array
                    items:
                      type: object
                      properties:
                        number:
                          type: string
                          description: E.164.
                        agentId:
                          type: integer
                        agentName:
                          type: string
                        status:
                          type: string
                        calls7d:
                          type: integer
                        spendMtdRaw:
                          type: string
                          description: Month-to-date spend, raw USDC (6 decimals).

  /v1/developer/agents/rollup:
    get:
      tags: [Developer]
      summary: Per-agent console rollup (balance, 30d spend, phone, key count)
      description: Live fleet only — closed agents are excluded.
      operationId: devGetAgentsRollup
      security:
        - developerSession: []
      responses:
        "200":
          description: Rollup rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  agents:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        name:
                          type: string
                        status:
                          type: string
                        balanceRaw:
                          type: string
                          description: Raw USDC (6 decimals).
                        spend30dRaw:
                          type: string
                          description: 30-day billable spend, raw USDC (6 decimals).
                        phone:
                          type: string
                          nullable: true
                          description: Live E.164, if any.
                        keysCount:
                          type: integer

  /v1/developer/billing/mtd:
    get:
      tags: [Developer]
      summary: Month-to-date bill by vendor and by agent
      operationId: devGetBillingMtd
      security:
        - developerSession: []
      responses:
        "200":
          description: MTD rollup
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MtdRollup"

  /v1/developer/billing/invoice:
    get:
      tags: [Developer]
      summary: Current-period line-itemized invoice (JSON)
      description: Same rollup as /billing/mtd wrapped in a period envelope.
      operationId: devGetBillingInvoice
      security:
        - developerSession: []
      responses:
        "200":
          description: Invoice
          content:
            application/json:
              schema:
                allOf:
                  - type: object
                    properties:
                      period:
                        type: object
                        properties:
                          start:
                            type: string
                            format: date-time
                          end:
                            type: string
                            format: date-time
                      currency:
                        type: string
                        enum: [USDC]
                      decimals:
                        type: integer
                        enum: [6]
                  - $ref: "#/components/schemas/MtdRollup"

  /v1/developer/billing/export.csv:
    get:
      tags: [Developer]
      summary: Per-charge CSV export for the current month
      description: |
        Columns: time, agent, vendor_endpoint, amount_raw. Fields are
        RFC-4180 quoted with spreadsheet-formula-injection defense. Refused
        (413) rather than silently truncated when the month exceeds the
        10,000-row cap.
      operationId: devExportBillingCsv
      security:
        - developerSession: []
      responses:
        "200":
          description: CSV attachment (Content-Disposition set)
          content:
            text/csv:
              schema:
                type: string
        "413":
          description: export_too_large — month exceeds the 10,000-row CSV limit
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                    enum: [export_too_large]
                  message:
                    type: string
                  limit:
                    type: integer

  /v1/developer/charges/recent:
    get:
      tags: [Developer]
      summary: Cross-agent recent charge line items
      operationId: devGetRecentCharges
      security:
        - developerSession: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
      responses:
        "200":
          description: Newest charges first
          content:
            application/json:
              schema:
                type: object
                properties:
                  charges:
                    type: array
                    items:
                      type: object
                      properties:
                        time:
                          type: string
                          format: date-time
                        agentId:
                          type: integer
                        agentName:
                          type: string
                        vendor:
                          type: string
                          description: Machine vendor label (model provider prefix, target host, or floe-phone).
                        endpoint:
                          type: string
                        amountRaw:
                          type: string
                          description: Vendor payment + platform fee, raw USDC (6 decimals).
                  limit:
                    type: integer
        "400":
          description: Invalid limit

  /v1/developer/policies/account-cap:
    get:
      tags: [Developer]
      summary: Account-wide spend-cap rollup
      description: |
        The single scope='developer' kind='session' policy the dashboard
        provisions per account (at most one active), with derived spend
        across every owned agent over the policy's window.
      operationId: devGetAccountCap
      security:
        - developerSession: []
      responses:
        "200":
          description: Cap state (configured=false → limitRaw/windowKind/windowResetsAt null)
          content:
            application/json:
              schema:
                type: object
                properties:
                  configured:
                    type: boolean
                  limitRaw:
                    type: string
                    nullable: true
                    description: Raw USDC (6 decimals).
                  spentRaw:
                    type: string
                    description: Raw USDC (6 decimals).
                  windowKind:
                    type: string
                    nullable: true
                  windowResetsAt:
                    type: string
                    format: date-time
                    nullable: true
                    description: Rolling windows only; null otherwise.

  /v1/developer/policies/defaults:
    get:
      tags: [Developer]
      summary: Account defaults applied to new agents
      description: |
        Maps onto the closest real state — sessionLimitRaw is the account
        cap's limit, autoPauseEnabled is whether its breach action is
        suspend_agent, and allowlistMode is the hardcoded 'off' system
        default (per-agent column; no account-level default exists today).
      operationId: devGetPolicyDefaults
      security:
        - developerSession: []
      responses:
        "200":
          description: Defaults
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionLimitRaw:
                    type: string
                    nullable: true
                    description: Raw USDC (6 decimals).
                  autoPauseEnabled:
                    type: boolean
                  allowlistMode:
                    type: string
                    enum: [off]

  /v1/developer/usage/summary:
    get:
      tags: [Developer]
      summary: Headline usage KPIs across the account
      description: |
        One scan of proxy_requests across every owned agent. errorRatePct
        counts status='failed' only; policy declines (status='rejected')
        are their own tile (policiesTripped) so the two never double-count.
      operationId: devGetUsageSummary
      security:
        - developerSession: []
      parameters:
        - name: window
          in: query
          schema:
            type: string
            enum: [7d, 30d]
            default: 7d
      responses:
        "200":
          description: KPI tiles
          content:
            application/json:
              schema:
                type: object
                properties:
                  window:
                    type: string
                  calls:
                    type: integer
                  errorRatePct:
                    type: number
                  p50LatencyMs:
                    type: number
                    nullable: true
                  policiesTripped:
                    type: integer
        "400":
          description: Invalid window

  /v1/developer/team/members:
    get:
      tags: [Developer]
      summary: List account members (any role)
      operationId: devListTeamMembers
      security:
        - developerSession: []
      responses:
        "200":
          description: Members + the caller's own role
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items:
                      type: object
                      properties:
                        memberWallet:
                          type: string
                        role:
                          type: string
                          enum: [owner, admin, member, viewer]
                        displayName:
                          type: string
                          nullable: true
                        email:
                          type: string
                          nullable: true
                        invitedBy:
                          type: string
                          nullable: true
                        createdAt:
                          type: string
                          format: date-time
                        isSelf:
                          type: boolean
                  role:
                    type: string
                    nullable: true

  /v1/developer/team/members/{wallet}:
    patch:
      tags: [Developer]
      summary: Change a member's role (owner only)
      operationId: devSetTeamMemberRole
      security:
        - developerSession: []
      parameters:
        - name: wallet
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [role]
              properties:
                role:
                  type: string
                  enum: [admin, member, viewer]
      responses:
        "200":
          description: Updated
        "400":
          description: Invalid wallet or body
        "404":
          description: Member not found in this account
        "409":
          description: Cannot demote the last owner
    delete:
      tags: [Developer]
      summary: Remove a member (admin+; only an owner can remove an admin/owner)
      description: |
        Also revokes every API key the removed member minted — keys are
        account-scoped, so without this a removed teammate's key would keep
        authenticating with full account power. Self-removal is allowed.
      operationId: devRemoveTeamMember
      security:
        - developerSession: []
      parameters:
        - name: wallet
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Removed (revokedKeys = count of keys revoked)
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  memberWallet:
                    type: string
                  revokedKeys:
                    type: integer
        "403":
          description: Only an owner can remove an admin or owner
        "404":
          description: Member not found in this account
        "409":
          description: Cannot remove the last owner

  /v1/developer/team/invites:
    get:
      tags: [Developer]
      summary: List pending invites (admin+)
      operationId: devListTeamInvites
      security:
        - developerSession: []
      responses:
        "200":
          description: Pending invites
          content:
            application/json:
              schema:
                type: object
                properties:
                  invites:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        email:
                          type: string
                        role:
                          type: string
                        invitedBy:
                          type: string
                        expiresAt:
                          type: string
                          format: date-time
                        createdAt:
                          type: string
                          format: date-time
    post:
      tags: [Developer]
      summary: Create an invite (admin+; only an owner may grant admin)
      description: |
        The invite email is sent fire-and-forget; the token is never
        returned by any read API. 'owner' is not an invitable role. Invites
        expire after 7 days; seats are capped at 10 (members + pending
        invites).
      operationId: devCreateTeamInvite
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, role]
              properties:
                email:
                  type: string
                  format: email
                  maxLength: 256
                role:
                  type: string
                  enum: [admin, member, viewer]
      responses:
        "200":
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  invite:
                    type: object
                    properties:
                      id:
                        type: integer
                      email:
                        type: string
                      role:
                        type: string
                      expiresAt:
                        type: string
                        format: date-time
                      createdAt:
                        type: string
                        format: date-time
        "403":
          description: Only an owner can invite an admin
        "409":
          description: Duplicate pending invite or seat limit (10) reached

  /v1/developer/team/invites/{id}:
    delete:
      tags: [Developer]
      summary: Revoke a pending invite (admin+)
      operationId: devRevokeTeamInvite
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Revoked
        "400":
          description: Invalid invite id
        "404":
          description: No pending invite with that id

  /v1/developer/team/invites/accept:
    post:
      tags: [Developer]
      summary: Accept an invite (any authed session)
      description: |
        Not role-gated — the accepting session belongs to the invitee, who
        is by definition not yet a member. Membership is granted to the
        SERVER-derived session wallet, never a body-supplied one.
      operationId: devAcceptTeamInvite
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  minLength: 8
                  maxLength: 256
                  description: The finv_* token from the invite email.
      responses:
        "200":
          description: Joined
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  accountWallet:
                    type: string
                  accountId:
                    type: string
                    nullable: true
                  role:
                    type: string
        "404":
          description: Invite not found or already used
        "409":
          description: already_used | seat_limit
        "410":
          description: Invite expired

  /v1/developer/orchestrators:
    get:
      tags: [Developer]
      summary: List orchestrator webhook connections
      description: Webhook/pre-call URLs (capability tokens) are returned; sealed secrets never are.
      operationId: devListOrchestrators
      security:
        - developerSession: []
      responses:
        "200":
          description: Connections
          content:
            application/json:
              schema:
                type: object
                properties:
                  connections:
                    type: array
                    items:
                      $ref: "#/components/schemas/OrchestratorConnection"
    post:
      tags: [Developer]
      summary: Connect an agent to Vapi/Retell/Bland (admin+)
      description: |
        vapi — Floe MINTS the shared secret (whsec_*) and returns it ONCE
        in the response; paste it into the Vapi server/webhook credential.
        retell/bland — `secret` is REQUIRED (the provider-side credential:
        Retell API key / Bland webhook signing secret). Secrets are stored
        AES-256-GCM sealed and never returned by any read API. One
        connection per (agent, provider) — re-keying is an explicit rotate.
      operationId: devCreateOrchestrator
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [agentId, provider]
              properties:
                agentId:
                  description: Owned agent id (integer or digit string).
                  oneOf:
                    - type: integer
                    - type: string
                      pattern: "^\\d+$"
                provider:
                  type: string
                  enum: [vapi, retell, bland]
                secret:
                  type: string
                  minLength: 8
                  maxLength: 512
                  description: Required for retell/bland; forbidden-to-omit enforced in the handler. Optional for vapi (Floe mints one).
                label:
                  type: string
                  maxLength: 100
      responses:
        "201":
          description: Connection (+ `secret` ONCE, vapi-minted only)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OrchestratorConnection"
                  - type: object
                    properties:
                      secret:
                        type: string
                        description: Present only when Floe minted it (vapi) — shown once.
        "400":
          description: secret_required or invalid body
        "404":
          description: Unknown agent or not owned by caller
        "409":
          description: already_connected — rotate instead

  /v1/developer/orchestrators/{id}/rotate:
    post:
      tags: [Developer]
      summary: Rotate a connection's webhook token + secret (admin+)
      description: |
        Mints a new capability token (the old webhook URLs stop working).
        vapi — omit `secret` and Floe mints a new one (returned once);
        retell/bland — the new provider credential is required.
      operationId: devRotateOrchestrator
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                secret:
                  type: string
                  minLength: 8
                  maxLength: 512
      responses:
        "200":
          description: Rotated connection (+ `secret` once when vapi-minted)
          content:
            application/json:
              schema:
                allOf:
                  - $ref: "#/components/schemas/OrchestratorConnection"
                  - type: object
                    properties:
                      secret:
                        type: string
        "400":
          description: secret_required (retell/bland rotate without a new credential)
        "404":
          description: Not found or not owned by caller

  /v1/developer/orchestrators/{id}:
    patch:
      tags: [Developer]
      summary: Enable/disable a connection (admin+)
      operationId: devUpdateOrchestrator
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [active]
              properties:
                active:
                  type: boolean
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  active:
                    type: boolean
        "404":
          description: Not found or not owned by caller
    delete:
      tags: [Developer]
      summary: Remove a connection (admin+; ledger history survives)
      operationId: devDeleteOrchestrator
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Removed
        "404":
          description: Not found or not owned by caller

  /v1/developer/agents/{agentId}/numbers:
    get:
      tags: [Developer]
      summary: List the agent's phone numbers (history included)
      operationId: devListAgentNumbers
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Numbers, newest first (released rows included)
          content:
            application/json:
              schema:
                type: object
                properties:
                  numbers:
                    type: array
                    items:
                      $ref: "#/components/schemas/PhoneNumber"
                  rentalRaw:
                    type: string
                    nullable: true
                    description: First-month rental (raw 6-dp USDC, margin included) — the exact amount a purchase reserves. Null when the phone catalog is not seeded.
        "404":
          description: Agent not found or not owned by caller
    post:
      tags: [Developer]
      summary: Buy a US local number and bind it 1:1 to the agent
      description: |
        The first month's rental is reserved from the AGENT's balance
        (atomic balance + session/policy gate) BEFORE the carrier-side buy
        and settled to the exact rental once it succeeds — any carrier
        failure releases the reserve, so the developer is never charged for
        a number they didn't get. The rental price comes from the
        'floe/phone' catalog entry and is snapshot on the number row.
        At least one of `areaCode` / `phoneNumber` is required — the carrier
        picks a number by area code or exact E.164, never "any US number";
        a body with neither is refused with 400 area_code_required before
        anything is reserved. `phoneNumber` (from a prior /numbers/search)
        takes precedence over `areaCode`. One live number per agent.
        The agent-key surface `POST /v1/numbers` (floe_ key; agent identity
        from the key, no :agentId) runs the same purchase core and returns
        the same bodies and error codes.
      operationId: devBuyAgentNumber
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        content:
          application/json:
            schema:
              type: object
              anyOf:
                - required: [areaCode]
                - required: [phoneNumber]
              properties:
                areaCode:
                  type: string
                  pattern: "^[2-9]\\d{2}$"
                  description: 3-digit US area code (2xx–9xx). Required unless phoneNumber is supplied; ignored (not validated or stored) when phoneNumber is present.
                phoneNumber:
                  type: string
                  pattern: "^\\+1\\d{10}$"
                  description: Exact E.164 from a prior search (see-then-buy). Satisfies the area-code requirement on its own and takes precedence over areaCode.
      responses:
        "201":
          description: Purchased (X-Floe-Cost-USDC carries the rental debit)
          headers:
            X-Floe-Cost-USDC:
              $ref: "#/components/headers/FloeCostUsdc"
          content:
            application/json:
              schema:
                type: object
                properties:
                  number:
                    $ref: "#/components/schemas/PhoneNumber"
        "400":
          description: area_code_required (neither areaCode nor phoneNumber sent) | invalid_area_code | invalid body. Error bodies carry a human-readable `detail`.
        "402":
          description: insufficient_balance | spend_limit_exceeded | policy_exceeded (agent balance pays the rental)
        "403":
          description: telephony_suspended
        "404":
          description: Agent not found or not owned by caller
        "409":
          description: number_exists | no_numbers_available | agent_unavailable | agent_not_ready
        "502":
          description: provisioning_failed (nothing was charged)
        "503":
          description: telephony_unavailable (Floe Phone not configured / rates not seeded)

  /v1/developer/agents/{agentId}/numbers/search:
    get:
      tags: [Developer]
      summary: Preview purchasable US local numbers (see-then-buy)
      description: Free; no side effects beyond lazily creating the developer's carrier subaccount. Pass a result's phoneNumber to POST .../numbers to buy that exact one.
      operationId: devSearchAgentNumbers
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: areaCode
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Available numbers
          content:
            application/json:
              schema:
                type: object
                properties:
                  numbers:
                    type: array
                    items:
                      type: object
        "400":
          description: invalid_area_code
        "403":
          description: telephony_suspended
        "404":
          description: Agent not found or not owned by caller
        "502":
          description: search_failed
        "503":
          description: telephony_unavailable

  /v1/developer/agents/{agentId}/numbers/{numberId}:
    get:
      tags: [Developer]
      summary: Number detail
      operationId: devGetAgentNumber
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: numberId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Number
          content:
            application/json:
              schema:
                type: object
                properties:
                  number:
                    $ref: "#/components/schemas/PhoneNumber"
        "404":
          description: Agent/number not found or not owned by caller
    delete:
      tags: [Developer]
      summary: Release a number (irreversible; idempotent)
      description: Carrier release first, then the row flips to 'released'. Releasing an already-released number is a no-op success.
      operationId: devReleaseAgentNumber
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: numberId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Released
          content:
            application/json:
              schema:
                type: object
                properties:
                  number:
                    $ref: "#/components/schemas/PhoneNumber"
        "404":
          description: Agent/number not found or not owned by caller
        "502":
          description: release_failed — retry (carrier-side 404 converges)

  /v1/developer/agents/{agentId}/numbers/{numberId}/test-call:
    post:
      tags: [Developer]
      summary: One-click test call (the agent calls the developer)
      description: |
        Dashboard-session variant of the agent-key call surface — same
        carrier path, no agent key has to leave the vault. Billing is
        identical: the reserve/meter/settle happens when the media stream
        opens, debiting the AGENT's balance.
      operationId: devTestCallAgentNumber
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: numberId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [toNumber]
              properties:
                toNumber:
                  type: string
                  pattern: "^\\+[1-9]\\d{6,14}$"
                  description: E.164 destination (e.g. +14155550123).
      responses:
        "201":
          description: Call queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  callId:
                    type: string
                  from:
                    type: string
                  to:
                    type: string
                  status:
                    type: string
                    enum: [queued]
        "400":
          description: invalid_to_number
        "404":
          description: Agent/number not found, not owned, or released
        "502":
          description: call_failed
        "503":
          description: telephony_unavailable (live calling not enabled)

  /v1/developer/agents/{agentId}/numbers/{numberId}/calls:
    get:
      tags: [Developer]
      summary: Carrier call history for a number
      operationId: devListAgentNumberCalls
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: numberId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Calls
          content:
            application/json:
              schema:
                type: object
                properties:
                  calls:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: Carrier SID — opaque but stable.
                        direction:
                          type: string
                          enum: [inbound, outbound]
                        from:
                          type: string
                        to:
                          type: string
                        status:
                          type: string
                        durationSeconds:
                          type: integer
                          nullable: true
                        startedAt:
                          type: string
                          nullable: true
                        endedAt:
                          type: string
                          nullable: true
        "404":
          description: Agent/number not found or not owned by caller
        "502":
          description: history_unavailable

  /v1/developer/agents/{agentId}/numbers/{numberId}/usage:
    get:
      tags: [Developer]
      summary: Ledger spend time-series for a number
      description: Per-number spend from the settled telephony ledger rows (phone://{E164}/ prefix — rental + calls).
      operationId: devGetAgentNumberUsage
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: numberId
          in: path
          required: true
          schema:
            type: integer
        - name: days
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
      responses:
        "200":
          description: Daily spend
          content:
            application/json:
              schema:
                type: object
                properties:
                  number:
                    type: object
                    properties:
                      id:
                        type: integer
                      phoneNumber:
                        type: string
                  days:
                    type: integer
                  totalRaw:
                    type: string
                    description: Raw USDC (6 decimals).
                  daily:
                    type: array
                    items:
                      type: object
                      properties:
                        day:
                          type: string
                        totalRaw:
                          type: string
                        requests:
                          type: integer
        "400":
          description: invalid_days
        "404":
          description: Agent/number not found or not owned by caller

  /v1/developer/agents/{agentId}/voice:
    get:
      tags: [Developer]
      summary: Read voice settings (hosted vs webhook mode)
      operationId: devGetAgentVoice
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Voice mode + config
          content:
            application/json:
              schema:
                type: object
                properties:
                  voiceMode:
                    type: string
                    enum: [hosted, webhook]
                  voiceConfig:
                    type: object
        "404":
          description: Agent not found or not owned by caller
    patch:
      tags: [Developer]
      summary: Update voice settings (takes effect on the next call)
      description: |
        Partial update merged over the existing config; an empty string
        clears a field. hosted — Floe runs the LLM leg through the keyless
        gateway; webhook — Floe streams caller turns to
        voiceConfig.webhookUrl and the builder's backend answers with
        NDJSON {"text": ...} chunks. Switching to webhook mode without a
        webhookUrl is refused.
      operationId: devUpdateAgentVoice
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                voiceMode:
                  type: string
                  enum: [hosted, webhook]
                systemPrompt:
                  type: string
                  maxLength: 4000
                beginMessage:
                  type: string
                  maxLength: 500
                voice:
                  type: string
                  maxLength: 120
                model:
                  type: string
                  maxLength: 120
                webhookUrl:
                  type: string
                  maxLength: 500
                  description: https:// URL, or "" to clear.
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  voiceMode:
                    type: string
                  voiceConfig:
                    type: object
        "400":
          description: webhook_url_required or invalid body
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/actions:
    get:
      tags: [Developer]
      summary: Cost-per-action rollup joined with reported outcomes
      description: |
        The eval view — what each tagged action (X-Floe-Action-Id) cost,
        and did it work. Spend aggregates settled proxy_requests rows only;
        `calls` counts every tagged row regardless of status. Outcome-only
        actions (reported before/without tagged spend) appear with zero
        cost. Single recency order across both sources before the cap.
      operationId: devListAgentActions
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
      responses:
        "200":
          description: Rollup entries, most recent first
          content:
            application/json:
              schema:
                type: object
                properties:
                  actions:
                    type: array
                    items:
                      type: object
                      properties:
                        actionId:
                          type: string
                        calls:
                          type: integer
                        spentRaw:
                          type: string
                          description: Σ settled cost (payment + platform fee), raw USDC (6 decimals).
                        firstSeen:
                          type: string
                          nullable: true
                        lastSeen:
                          type: string
                          nullable: true
                        outcome:
                          nullable: true
                          allOf:
                            - $ref: "#/components/schemas/ActionOutcome"
        "404":
          description: Agent not found or not owned by caller

  /v1/developer/agents/{agentId}/actions/{actionId}/outcome:
    post:
      tags: [Developer]
      summary: Report (upsert) the outcome for one action
      description: |
        Floe never judges quality — status/score are caller-supplied
        verbatim. The same report is available agent-key-authed at
        POST /v1/agents/actions/{actionId}/outcome for runtimes that
        self-report.
      operationId: devReportActionOutcome
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: path
          required: true
          schema:
            type: integer
        - name: actionId
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [success, failure, partial, unknown]
                scoreBps:
                  type: integer
                  minimum: 0
                  maximum: 10000
                  nullable: true
                note:
                  type: string
                  maxLength: 500
                  nullable: true
      responses:
        "200":
          description: Upserted
          content:
            application/json:
              schema:
                type: object
                properties:
                  actionId:
                    type: string
                  outcome:
                    $ref: "#/components/schemas/ActionOutcome"
        "400":
          description: invalid_action_id or invalid body
        "404":
          description: Agent not found or not owned by caller

  /v1/agents/policies:
    get:
      tags: [Agent]
      summary: List own spend policies
      operationId: agentListPolicies
      parameters:
        - name: includeRevoked
          in: query
          schema:
            type: boolean
            default: false
      responses:
        "200":
          description: Policy list
          content:
            application/json:
              schema:
                type: object
                properties:
                  policies:
                    type: array
                    items:
                      $ref: "#/components/schemas/AgentPolicy"
    post:
      tags: [Agent]
      summary: Create a spend policy (adding tightens — refused only when it would widen an enforced allowlist)
      description: |
        kind='api'/'vendor' rows double as the merchant allowlist entries.
        While selfServiceLocked AND the matching allowlistMode dimension is
        enforced, creating one would GRANT a new allowed host/payee, so it
        is refused; every other create still tightens and is allowed.
      operationId: agentCreatePolicy
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePolicyRequest"
      responses:
        "201":
          description: Created
        "403":
          description: self_service_locked — would widen an enforced allowlist dimension
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"
        "409":
          description: duplicate_active_policy

  /v1/agents/policies/{policyId}:
    patch:
      tags: [Agent]
      summary: Update own policy (tighten-only when selfServiceLocked)
      operationId: agentUpdatePolicy
      parameters:
        - name: policyId
          in: path
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
      responses:
        "200":
          description: Updated
        "403":
          description: self_service_locked — only a pure limitRaw reduction is allowed while locked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"
        "404":
          description: Not found
    delete:
      tags: [Agent]
      summary: Revoke own policy (refused when selfServiceLocked)
      operationId: agentDeletePolicy
      parameters:
        - name: policyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Revoked
        "403":
          description: self_service_locked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"
        "404":
          description: Not found

  /v1/agents/policies/{policyId}/reset:
    post:
      tags: [Agent]
      summary: Reset a policy's spend window (refused when selfServiceLocked)
      operationId: agentResetPolicy
      parameters:
        - name: policyId
          in: path
          required: true
          schema:
            type: integer
      responses:
        "200":
          description: Window reset
        "403":
          description: self_service_locked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"
        "404":
          description: Not found

  /v1/agents/allowlist-mode:
    get:
      tags: [Agent]
      summary: Read the merchant allowlist enforcement mode
      operationId: agentGetAllowlistMode
      responses:
        "200":
          description: Mode
          content:
            application/json:
              schema:
                type: object
                properties:
                  mode:
                    type: string
                    enum: [off, host, vendor, both]
    put:
      tags: [Agent]
      summary: Set the merchant allowlist enforcement mode
      description: |
        While selfServiceLocked the mode may only GAIN enforcement
        dimensions (off ⊂ host|vendor ⊂ both); dropping or swapping one
        turns a gate off and is operator-only.
      operationId: agentSetAllowlistMode
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [mode]
              properties:
                mode:
                  type: string
                  enum: [off, host, vendor, both]
      responses:
        "200":
          description: Updated (may carry a no_active_entries lockout warning)
        "403":
          description: self_service_locked — the mode may only gain enforcement dimensions
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorNext"

  /v1/agents/spend-summary:
    get:
      tags: [Agent]
      summary: Rolling-window spend total
      operationId: agentGetSpendSummary
      parameters:
        - name: period
          in: query
          schema:
            type: string
            enum: [day, week, month]
            default: day
      responses:
        "200":
          description: Aggregate spend
          content:
            application/json:
              schema:
                type: object
                properties:
                  period:
                    type: string
                  since:
                    type: string
                  until:
                    type: string
                  totalSpendRaw:
                    type: string
                  totalSpendUsdc:
                    type: string
                  successCount:
                    type: integer
                  failedCount:
                    type: integer

  /v1/x402/forecast:
    post:
      tags: [x402 Estimate]
      summary: Batch cost forecast + policy preflight
      operationId: forecastX402Costs
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [items]
              properties:
                items:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items:
                    type: object
                    required: [url]
                    properties:
                      url:
                        type: string
                        format: uri
                      method:
                        type: string
                        pattern: "^[A-Z]{3,7}$"
                      count:
                        type: integer
                        minimum: 1
                        maximum: 10000
                      taskId:
                        type: string
                        maxLength: 128
      responses:
        "200":
          description: Forecast + policyPreflight (ok flag + per-policy breaches)
          content:
            application/json:
              schema:
                type: object
                properties:
                  forecast:
                    type: object
                  policyPreflight:
                    type: object
                    properties:
                      ok:
                        type: boolean
                      breaches:
                        type: array
                        items:
                          type: object
                          properties:
                            itemIndex:
                              type: integer
                            policyId:
                              type: integer
                            kind:
                              type: string
                            matchKey:
                              type: string
                              nullable: true
                            label:
                              type: string
                              nullable: true
                            limitRaw:
                              type: string
                            projectedSpentRaw:
                              type: string
        "400":
          description: blocked_destination or invalid input
        "429":
          description: Rate limit exceeded
        "502":
          description: forecast_failed

  /v1/transfers/prepare:
    post:
      tags: [Transfers]
      summary: Reserve a transfer and return calldata or an execute handle
      description: |
        Directions: to_agent (deposit; client signs the returned calldata,
        then POST /:id/confirm), from_agent (withdrawal; server-signed —
        POST /:id/execute next), agent_to_agent (move between two owned
        agents; server-signed), embedded_to_external (embedded wallet →
        arbitrary address; client-signed). Pre-flight checks: on-chain
        balance, the non-withdrawable welcome credit
        (withdrawal_exceeds_user_balance), and — for wallet-mode agents —
        the spendable balance net of in-flight x402 reservations
        (withdrawal_exceeds_available).
      operationId: prepareTransfer
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [direction, amountRaw]
              properties:
                direction:
                  type: string
                  enum: [to_agent, from_agent, agent_to_agent, embedded_to_external]
                agentId:
                  type: integer
                  description: Required for to_agent / from_agent / agent_to_agent (the SOURCE agent).
                toAgentId:
                  type: integer
                  description: Required for agent_to_agent (the DESTINATION agent; must differ from agentId).
                toAddress:
                  type: string
                  pattern: "^0x[a-fA-F0-9]{40}$"
                  description: Required for embedded_to_external.
                onrampId:
                  type: integer
                  description: Optional (to_agent only) — binds the transfer source to that onramp row's non-custodial wallet (Leg-2 sweep).
                amountRaw:
                  type: string
                  pattern: "^[1-9]\\d{0,18}$"
                  description: Amount, raw USDC (6 decimals). No leading zero; capped at 1e18 raw units.
      responses:
        "200":
          description: >
            Prepared. Client-signed directions return the unsigned calldata
            (chainId, to, data, value); server-signed directions return
            `nextAction: "execute"` instead. Both carry transferId,
            direction, signerKind, fromAddress, toAddress, amountRaw, and
            availableRaw (raw USDC, 6 decimals).
          content:
            application/json:
              schema:
                type: object
                properties:
                  transferId:
                    type: string
                    format: uuid
                  direction:
                    type: string
                  signerKind:
                    type: string
                    enum: [external_eoa, embedded_wallet, privy_server]
                  chainId:
                    type: integer
                    description: Client-signed directions only.
                  to:
                    type: string
                    description: USDC contract (client-signed directions only).
                  data:
                    type: string
                    description: ERC-20 transfer calldata (client-signed directions only).
                  value:
                    type: string
                  fromAddress:
                    type: string
                  toAddress:
                    type: string
                  amountRaw:
                    type: string
                  availableRaw:
                    type: string
                  nextAction:
                    type: string
                    enum: [execute]
                    description: Server-signed directions only.
        "400":
          description: Invalid body | withdrawal_exceeds_user_balance | withdrawal_exceeds_available
        "403":
          description: Onramp row belongs to a different developer, or agent-key credential (developer_credential_required)
        "404":
          description: Unknown developer / agent not found or not yours / onramp row not found
        "409":
          description: Insufficient balance | agent has no Privy wallet | onramp mismatch
        "502":
          description: RPC error reading balance

  /v1/transfers/{id}/execute:
    post:
      tags: [Transfers]
      summary: Server-sign and broadcast a from_agent / agent_to_agent transfer
      description: |
        Wallet-mode withdrawals are signed EIP-3009 by the payment-signer
        wallet and broadcast by the Privy executor; legacy/credit-line rows
        use the sponsored ERC20.transfer path. Idempotent — re-running on a
        row already past 'prepared' returns the stored tx hash (a CAS
        guards the broadcast against parallel calls).
      operationId: executeTransfer
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Broadcast (or the prior outcome on an idempotent re-run)
          content:
            application/json:
              schema:
                type: object
                properties:
                  transferId:
                    type: string
                  status:
                    type: string
                  txHash:
                    type: string
                    nullable: true
        "400":
          description: Wrong direction (only from_agent / agent_to_agent are server-executed) | withdrawal_exceeds_user_balance
        "403":
          description: Transfer belongs to a different developer
        "404":
          description: Not found
        "409":
          description: Agent missing Privy credentials
        "502":
          description: Broadcast failed (row marked failed)
        "503":
          description: Privy not configured

  /v1/transfers/{id}/confirm:
    post:
      tags: [Transfers]
      summary: Report the client-signed tx hash (to_agent / embedded_to_external)
      description: Flips the row to 'pending'; the watcher resolves the receipt. Idempotent for the same hash; a different hash after another confirm won 409s with the stored hash.
      operationId: confirmTransfer
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [txHash]
              properties:
                txHash:
                  type: string
                  pattern: "^0x[a-fA-F0-9]{64}$"
      responses:
        "200":
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  transferId:
                    type: string
                  status:
                    type: string
                  txHash:
                    type: string
                    nullable: true
        "400":
          description: Invalid txHash or wrong direction (server-signed rows record via /execute)
        "403":
          description: Transfer belongs to a different developer
        "404":
          description: Not found
        "409":
          description: Already confirmed with a different transaction hash

  /v1/transfers/{id}/link-onramp:
    post:
      tags: [Transfers]
      summary: Link a to_agent transfer to its Leg-1 onramp row
      description: |
        Marks this transfer as the Leg-2 sweep of a specific onramp row —
        flips the onramp's sweep_status not_started → prompted. The
        transfer's endpoints must match the onramp's non-custodial → agent
        pair (409 Mismatch otherwise). Idempotent for the same pair; one
        transfer per onramp.
      operationId: linkTransferOnramp
      security:
        - developerSession: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [onrampId]
              properties:
                onrampId:
                  type: integer
                  minimum: 1
      responses:
        "200":
          description: Linked
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
        "400":
          description: Invalid onrampId or wrong direction (to_agent only)
        "403":
          description: Transfer or onramp belongs to a different developer
        "404":
          description: Transfer or onramp row not found
        "409":
          description: Endpoint mismatch, or already linked to a different transfer/onramp

  /v1/transfers:
    get:
      tags: [Transfers]
      summary: Transfer history
      description: Internal agent_to_signer forwarding rows are hidden. agent_to_agent rows surface in BOTH agents' histories when filtering by agentId.
      operationId: listTransfers
      security:
        - developerSession: []
      parameters:
        - name: agentId
          in: query
          schema:
            type: string
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        "200":
          description: Newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  transfers:
                    type: array
                    items:
                      type: object
                      description: wallet_transfers row (id, direction, fromAddress, toAddress, amountRaw, status, txHash, ...).

  /v1/onramp/geo:
    get:
      tags: [Onramp]
      summary: Resolve the onramp flow for the caller's region
      description: hosted → use /session-token (Coinbase popup); headless → use /headless/create-order (US-only inline iframe). Authed so unauthenticated probes can't map geo behavior.
      operationId: getOnrampGeo
      security:
        - developerSession: []
      responses:
        "200":
          description: Region + flow
          content:
            application/json:
              schema:
                type: object
                properties:
                  country:
                    type: string
                    nullable: true
                  mode:
                    type: string
                    enum: [hosted, headless]

  /v1/onramp/session-token:
    post:
      tags: [Onramp]
      summary: Mint a CDP onramp session (hosted popup flow)
      description: |
        Leg 1 of the two-leg funding design — fiat lands in a NON-CUSTODIAL
        wallet (the developer's EOA or Privy embedded wallet; custodial
        agent wallets are rejected 403), then Leg 2 sweeps it to the agent
        via /v1/transfers. Persists a pending audit row first and returns
        its id (`onrampId`) plus a `correlationId` the client must pass as
        `partnerUserRef` on the Coinbase popup URL. destinationScope=
        'account' skips the agent requirement entirely (funds stay in the
        non-custodial wallet).
      operationId: createOnrampSessionToken
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [destinationKind, destinationAddress]
              properties:
                destinationKind:
                  type: string
                  enum: [embedded, external]
                destinationAddress:
                  type: string
                  pattern: "^0x[a-fA-F0-9]{40}$"
                destinationScope:
                  type: string
                  enum: [agent, account]
                  default: agent
                  description: account cannot be combined with agentId.
                agentId:
                  type: integer
                  description: Which agent Leg-2 targets. Omitted → the single legacy agent.
                presetFiatAmount:
                  type: number
                  minimum: 0
                  exclusiveMinimum: true
                  maximum: 100000
      responses:
        "200":
          description: Session minted
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessionToken:
                    type: string
                  channelId:
                    type: string
                    nullable: true
                  correlationId:
                    type: string
                    format: uuid
                  onrampId:
                    type: integer
                    nullable: true
                  mode:
                    type: string
                    enum: [hosted]
                  nonCustodialAddress:
                    type: string
                  agentWalletAddress:
                    type: string
                    nullable: true
        "400":
          description: Invalid body / client IP undeterminable
        "403":
          description: destinationAddress not yours, or a custodial agent wallet
        "404":
          description: Unknown developer / agent not found or not yours
        "409":
          description: No embedded wallet | no agent wallet | Wrong flow (headless region — use /headless/create-order)
        "502":
          description: CDP session-token request failed
        "503":
          description: CDP Onramp not configured

  /v1/onramp/sessions:
    get:
      tags: [Onramp]
      summary: List onramp rows for the caller
      operationId: listOnrampSessions
      security:
        - developerSession: []
      parameters:
        - name: recoveryOnly
          in: query
          schema:
            type: boolean
          description: >
            Only rows where Leg-1 succeeded but Leg-2 hasn't completed
            (sweep_status not_started|failed) — powers the "funds waiting"
            recovery banner. 'prompted' rows are intentionally excluded (a
            Leg-2 broadcast is already in flight).
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 50
            default: 20
      responses:
        "200":
          description: Rows, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items:
                      type: object
                      description: onramp_transactions row (status, sweepStatus, fiatAmount, nonCustodialAddress, agentWalletAddress, ...).

  /v1/onramp/headless/create-order:
    post:
      tags: [Onramp]
      summary: Create a CDP headless onramp order (US-only inline flow)
      description: |
        Returns a paymentLink for the inline iframe instead of a session
        token. Requires partner-verified email + phone OTP tokens (mint via
        /verify-email/* and /verify-phone/*). Geo-gated: non-headless
        regions get 403 — use /session-token. Destination rules identical
        to /session-token. One of paymentAmount / purchaseAmount is
        required (decimal USD strings, ≤ 100000).
      operationId: createOnrampHeadlessOrder
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [destinationKind, destinationAddress, emailToken, phoneToken, agreementAcceptedAt]
              properties:
                destinationKind:
                  type: string
                  enum: [embedded, external]
                destinationAddress:
                  type: string
                  pattern: "^0x[a-fA-F0-9]{40}$"
                destinationScope:
                  type: string
                  enum: [agent, account]
                  default: agent
                agentId:
                  type: integer
                paymentAmount:
                  type: string
                  description: Fiat charge, decimal USD string (≤ 6 dp).
                purchaseAmount:
                  type: string
                  description: Crypto purchase, decimal string (≤ 6 dp).
                emailToken:
                  type: string
                phoneToken:
                  type: string
                agreementAcceptedAt:
                  type: string
                  format: date-time
                domain:
                  type: string
                  maxLength: 255
      responses:
        "200":
          description: Order created
          content:
            application/json:
              schema:
                type: object
                properties:
                  correlationId:
                    type: string
                    format: uuid
                  orderId:
                    type: string
                  onrampId:
                    type: integer
                    nullable: true
                  paymentLink:
                    type: string
                  status:
                    type: string
                  mode:
                    type: string
                    enum: [headless]
                  nonCustodialAddress:
                    type: string
                  agentWalletAddress:
                    type: string
                    nullable: true
        "400":
          description: Invalid body / client IP undeterminable
        "401":
          description: OTP required — email or phone verification token missing, expired, or invalid
        "403":
          description: Headless unavailable in this region, or destination not yours / custodial
        "404":
          description: Unknown developer / agent
        "409":
          description: No embedded wallet | no agent wallet
        "502":
          description: CDP createOrder failed
        "503":
          description: CDP Onramp not configured

  /v1/onramp/verify-email/send:
    post:
      tags: [Onramp]
      summary: Send an email OTP (headless onramp verification)
      operationId: sendOnrampEmailOtp
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
      responses:
        "200":
          description: Sent
        "400":
          description: Invalid email
        "429":
          description: Resend rate limit — retry after the indicated wait
        "502":
          description: Email delivery failed
        "503":
          description: Email OTP not configured

  /v1/onramp/verify-email/check:
    post:
      tags: [Onramp]
      summary: Check an email OTP and mint a verification token
      description: A recently-verified email can re-mint the token without a new code (60-day re-verification window).
      operationId: checkOnrampEmailOtp
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, code]
              properties:
                email:
                  type: string
                  format: email
                code:
                  type: string
                  pattern: "^\\d{6}$"
      responses:
        "200":
          description: Verified — pass `token` as emailToken to /headless/create-order
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                  verifiedAt:
                    type: string
                    format: date-time
        "400":
          description: Invalid email/code, wrong code, or too many attempts
        "404":
          description: No verification in flight

  /v1/onramp/verify-phone/send:
    post:
      tags: [Onramp]
      summary: Send a phone OTP (US mobile only)
      description: CDP requires a real US cell number — VoIP, non-US, and landline numbers are rejected when carrier lookup is available.
      operationId: sendOnrampPhoneOtp
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumber]
              properties:
                phoneNumber:
                  type: string
                  description: E.164 (e.g. +14155551234).
      responses:
        "200":
          description: Sent
        "400":
          description: Invalid, VoIP, non-US, or non-mobile number
        "429":
          description: Resend rate limit
        "502":
          description: SMS delivery failed
        "503":
          description: Phone OTP not configured

  /v1/onramp/verify-phone/check:
    post:
      tags: [Onramp]
      summary: Check a phone OTP and mint a verification token
      operationId: checkOnrampPhoneOtp
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumber, code]
              properties:
                phoneNumber:
                  type: string
                code:
                  type: string
                  pattern: "^\\d{4,8}$"
      responses:
        "200":
          description: Verified — pass `token` as phoneToken to /headless/create-order
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                  verifiedAt:
                    type: string
                    format: date-time
        "400":
          description: Invalid number/code or wrong code
        "404":
          description: No verification in flight

  /v1/onramp/webhook:
    post:
      tags: [Onramp]
      summary: CDP onramp status webhook (public)
      description: Server-to-server from Coinbase. Trust boundary is the CDP webhook signature verified inside the handler; matched to rows by partnerUserRef (correlationId) with a status-direction guard so delayed retries can't revert terminal states.
      operationId: onrampWebhook
      security: []
      responses:
        "200":
          description: Acknowledged

  /v1/offramp/start:
    post:
      tags: [Offramp]
      summary: Start an offramp order (USDC → fiat)
      description: |
        embedded_wallet — returns sessionToken + payUrl inline.
        agent_wallet — kicks off Leg-1 (server-signed agent → developer
        wallet) and returns the order in leg1_pending; the session token is
        minted later by GET /orders/{ref} once Leg-1 confirms.
      operationId: startOfframp
      security:
        - developerSession: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sourceKind, amountRaw]
              properties:
                sourceKind:
                  type: string
                  enum: [embedded_wallet, agent_wallet]
                agentId:
                  type: integer
                  description: Required for sourceKind=agent_wallet.
                amountRaw:
                  type: string
                  pattern: "^\\d+$"
                  description: Amount, raw USDC (6 decimals).
      responses:
        "200":
          description: Order (+ sessionToken/payUrl on the embedded path)
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    $ref: "#/components/schemas/OfframpOrder"
                  sessionToken:
                    type: string
                    nullable: true
                  payUrl:
                    type: string
                    nullable: true
        "400":
          description: Invalid body / client IP undeterminable / orchestrator-reported error
        "503":
          description: Offramp not configured

  /v1/offramp/orders:
    get:
      tags: [Offramp]
      summary: Paginated offramp order history
      operationId: listOfframpOrders
      security:
        - developerSession: []
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: cursor
          in: query
          schema:
            type: integer
            minimum: 0
      responses:
        "200":
          description: Orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: array
                    items:
                      $ref: "#/components/schemas/OfframpOrder"
                  nextCursor:
                    type: integer
                    nullable: true
        "400":
          description: Invalid limit / cursor

  /v1/offramp/orders/{ref}:
    get:
      tags: [Offramp]
      summary: Order detail (mints a fresh session token when awaiting_form)
      description: Single-use CDP tokens expire in ~5 minutes, so they are minted on demand rather than persisted.
      operationId: getOfframpOrder
      security:
        - developerSession: []
      parameters:
        - name: ref
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Order (+ sessionToken/payUrl when applicable)
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    $ref: "#/components/schemas/OfframpOrder"
                  sessionToken:
                    type: string
                    nullable: true
                  payUrl:
                    type: string
                    nullable: true
        "403":
          description: Order belongs to a different developer
        "404":
          description: Not found

  /v1/offramp/orders/{ref}/tx:
    post:
      tags: [Offramp]
      summary: Record the Leg-2 broadcast (USDC transfer to the CDP deposit address)
      description: Transitions awaiting_send → sent.
      operationId: recordOfframpTx
      security:
        - developerSession: []
      parameters:
        - name: ref
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [txHash]
              properties:
                txHash:
                  type: string
                  pattern: "^0x[a-fA-F0-9]{64}$"
      responses:
        "200":
          description: Recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    $ref: "#/components/schemas/OfframpOrder"
        "400":
          description: Invalid txHash
        "403":
          description: Order belongs to a different developer
        "404":
          description: Not found
        "409":
          description: Wrong state for a Leg-2 broadcast

  /v1/offramp/orders/{ref}/cancel:
    post:
      tags: [Offramp]
      summary: Cancel an order (non-terminal pre-Leg-2 states only)
      operationId: cancelOfframpOrder
      security:
        - developerSession: []
      parameters:
        - name: ref
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  order:
                    $ref: "#/components/schemas/OfframpOrder"
        "403":
          description: Order belongs to a different developer
        "404":
          description: Not found
        "409":
          description: Not cancellable in the current state

  /v1/offramp/webhook:
    post:
      tags: [Offramp]
      summary: CDP offramp status webhook (public)
      description: Trust boundary is the Hook0 signature verified inside the handler. Recognized-but-rejected events still 200 so CDP stops retrying; only signature failures 401.
      operationId: offrampWebhook
      security: []
      responses:
        "200":
          description: Acknowledged (or benign drop)
        "401":
          description: Invalid signature

  /v1/admin/agents:
    get:
      tags: [Admin]
      summary: List all agents with balances
      operationId: adminListAgents
      security:
        - adminApiKey: []
      responses:
        "200":
          description: Agent list
          content:
            application/json:
              schema:
                type: object
                properties:
                  agents:
                    type: array
                    items:
                      $ref: "#/components/schemas/AdminAgent"

  /v1/admin/agents/{id}:
    patch:
      tags: [Admin]
      summary: Suspend or activate agent
      operationId: adminPatchAgent
      security:
        - adminApiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [active, suspended]
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                  status:
                    type: string

components:
  securitySchemes:
    walletAddress:
      type: apiKey
      in: header
      name: X-Wallet-Address
      description: Agent wallet address (0x...)
    walletSignature:
      type: apiKey
      in: header
      name: X-Signature
      description: EIP-191 signature of "Floe Credit API\nTimestamp:{ts}"
    walletTimestamp:
      type: apiKey
      in: header
      name: X-Timestamp
      description: Unix timestamp (within 5 min of server time)
    agentApiKey:
      type: http
      scheme: bearer
      description: >
        Agent API key (floe_..., without _live_). A RUNTIME credential — it
        pays for calls and drives agent self-service, and is the only
        credential the inference gateway accepts. Refused (403
        developer_credential_required) on every /v1/developer route and the
        money-movement surfaces /v1/transfers, /v1/onramp, /v1/offramp.
    developerApiKey:
      type: http
      scheme: bearer
      description: >
        Developer API key (floe_live_...), minted via POST /v1/developer/keys.
        Accepted everywhere `developerSession` is — the two are
        interchangeable operator credentials.
    adminApiKey:
      type: http
      scheme: bearer
      description: Admin API key
    developerSession:
      type: http
      scheme: bearer
      description: >
        Developer (operator) credential. Despite the bearer-JWT declaration,
        every route marked with this scheme accepts ANY developer credential
        interchangeably: the `floe_session` HttpOnly cookie set by
        POST /v1/developer/auth/verify (the dashboard's normal path), the
        same JWT as `Authorization: Bearer <jwt>` (legacy fallback), a
        `floe_live_*` developer API key (Bearer), or the wallet-signature
        headers (X-Wallet-Address + X-Signature + X-Timestamp). Agent keys
        (`floe_*` without `_live_`) are refused with 403
        developer_credential_required.
    sessionCookie:
      type: apiKey
      in: cookie
      name: floe_session
      description: >
        HttpOnly dashboard session cookie (HMAC-signed JWT, 7-day sliding
        window) set by POST /v1/developer/auth/verify. Equivalent to
        `developerSession`.

  headers:
    FloeCostUsdc:
      description: Total charge for this call, raw 6-decimal USDC integer string (e.g. "12500" = $0.0125). "0" on passthrough (provider error — no charge).
      schema:
        type: string
    FloePaymentAmount:
      description: Decimal-USDC alias of X-Floe-Cost-USDC (e.g. "0.0125").
      schema:
        type: string
    FloePayment:
      description: How the call was paid — gateway (keyless rails), byok (developer's own vendor key; Floe bills the service fee only), or passthrough (no charge).
      schema:
        type: string
        enum: [gateway, byok, passthrough]
    FloeModel:
      description: The catalog model that served the call.
      schema:
        type: string
    FloeRail:
      description: The execution rail that served the call.
      schema:
        type: string
    FloeBudgetRemaining:
      description: Remaining spendable after this call, DECIMAL USDC string (pre-call ceiling minus this call's cost, floored at zero) — lets an agent self-gate the next call without a round-trip. Best-effort.
      schema:
        type: string
    FloeAttempts:
      description: Number of sources tried (present only when > 1 — failover happened).
      schema:
        type: string

  schemas:
    CheckResult:
      type: object
      properties:
        status:
          type: string
          enum: [ok, degraded, down]

    AgentCreditScoreFactor:
      type: object
      properties:
        key:
          type: string
          enum: [cred, repaymentRate, onTimeRate, liquidationPenalty, badDebtPenalty, tenure, x402SuccessRate, x402Maturity]
        value:
          type: number
          description: Normalized factor value in [0,1] (higher = better).
        weight:
          type: number
          description: Effective (renormalized) weight applied to this factor.
        contribution:
          type: number
          description: weight * value — additive contribution to score/100.

    AgentCreditScore:
      type: object
      properties:
        agentId:
          type: integer
        score:
          type: number
          description: Composite score, 0–100.
        band:
          type: string
          enum: [A, B, C, D, E]
        confidence:
          type: number
          description: 0–1, share of model weight backed by a real signal.
        factors:
          type: array
          items:
            $ref: "#/components/schemas/AgentCreditScoreFactor"
        inputs:
          type: object
          properties:
            wallets:
              type: array
              items:
                type: string
            providersUsed:
              type: array
              items:
                type: string
                enum: [cred, floe-native, x402]
            providersFailed:
              type: array
              items:
                type: string
                enum: [cred, floe-native, x402]
        modelVersion:
          type: string
        computedAt:
          type: string
          format: date-time
        cache:
          type: string
          enum: [HIT, MISS]

    InsufficientCreditData:
      type: object
      properties:
        error:
          type: string
          enum: [insufficient_data]
        message:
          type: string
        agentId:
          type: integer
        insufficientData:
          type: boolean
        inputs:
          type: object
          properties:
            wallets:
              type: array
              items:
                type: string
            providersUsed:
              type: array
              items:
                type: string
            providersFailed:
              type: array
              items:
                type: string
        modelVersion:
          type: string
        computedAt:
          type: string
          format: date-time

    Market:
      type: object
      properties:
        marketId:
          type: string
        loanToken:
          type: object
          properties:
            address:
              type: string
            symbol:
              type: string
            decimals:
              type: integer
        collateralToken:
          type: object
          properties:
            address:
              type: string
            symbol:
              type: string
            decimals:
              type: integer
        isActive:
          type: boolean

    LendOffer:
      type: object
      properties:
        lender:
          type: string
        amount:
          type: string
        filledAmount:
          type: string
        minInterestRateBps:
          type: string
        maxLtvBps:
          type: string
        minDuration:
          type: string
        maxDuration:
          type: string
        expiry:
          type: string
        marketId:
          type: string

    InstantBorrowRequest:
      type: object
      required: [marketId, borrowAmount, collateralAmount, maxInterestRateBps, duration]
      properties:
        marketId:
          type: string
          pattern: "^0x[a-fA-F0-9]{64}$"
        borrowAmount:
          type: string
        collateralAmount:
          type: string
        maxInterestRateBps:
          type: string
        duration:
          type: string
        minLtvBps:
          type: string
          default: "8000"
        maxLtvBps:
          type: string
          default: "8500"
        matcherCommissionBps:
          type: string

    BorrowResult:
      type: object
      properties:
        attemptId:
          type: string
        reused:
          type: boolean
        status:
          type: string
        transactions:
          type: array
          items:
            $ref: "#/components/schemas/UnsignedTransaction"
        selectedOffer:
          type: object

    UnsignedTransaction:
      type: object
      properties:
        to:
          type: string
        data:
          type: string
        value:
          type: string

    LoanStatus:
      type: object
      description: Loan status and health metrics
      properties:
        loanId:
          type: string
        borrower:
          type: string
        lender:
          type: string
        principal:
          type: string
        collateralAmount:
          type: string
        interestRateBps:
          type: string
        ltvBps:
          type: string
        liquidationLtvBps:
          type: string
        startTime:
          type: integer
        duration:
          type: integer
        repaid:
          type: boolean
        accruedInterest:
          type: string
        totalDebt:
          type: string
        currentLtvBps:
          type: string
        isHealthy:
          type: boolean
        isOverdue:
          type: boolean
        earlyRepaymentTerms:
          type: object
          properties:
            gracePeriod:
              type: string
            minInterestBps:
              type: string
            fullTermInterest:
              type: string
            earlyRepaymentPenalty:
              type: string
            totalRepaymentIfRepaidNow:
              type: string

    TransactionResult:
      type: object
      properties:
        transactions:
          type: array
          items:
            $ref: "#/components/schemas/UnsignedTransaction"

    RenewRequest:
      type: object
      required: [loanId]
      properties:
        loanId:
          type: string
        newBorrowAmount:
          type: string
        newCollateralAmount:
          type: string
        maxInterestRateBps:
          type: string
        duration:
          type: string
        minLtvBps:
          type: string
        repaySlippageBps:
          type: string

    ProxyCheckResult:
      type: object
      properties:
        x402:
          type: boolean
        status:
          type: integer
        message:
          type: string
        payment:
          type: object
          properties:
            amount:
              type: string
            asset:
              type: string
            payTo:
              type: string
            network:
              type: string

    ProxyFetchRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
        method:
          type: string
          default: GET
          enum: [GET, POST, PUT, PATCH, DELETE, HEAD]
        headers:
          type: object
          additionalProperties:
            type: string
        body:
          type: string

    X402Estimate:
      type: object
      properties:
        maxAmountRequired:
          type: string
        asset:
          type: string
        payTo:
          type: string
        network:
          type: string
        reflection:
          type: object
          properties:
            creditLimit:
              type: string
              nullable: true
            creditOut:
              type: string
            available:
              type: string
            headroomToAutoBorrow:
              type: string
            utilizationBps:
              type: number

    AgentBalance:
      type: object
      properties:
        status:
          type: string
          description: Only present if agent is closed
        balance:
          type: string
        privyWalletAddress:
          type: string
        creditLimit:
          type: string
        creditUsed:
          type: string
        creditAvailable:
          type: string
        activeLoans:
          type: array
          items:
            type: object
            properties:
              loanId:
                type: string
              borrowAmount:
                type: string
              status:
                type: string
        delegationActive:
          type: boolean
        operatorExpiry:
          type: string
          nullable: true
          format: date-time
        remainingLoans:
          type: array
          description: Only present when status=closed
          items:
            type: object
            properties:
              loanId:
                type: string
              principalRaw:
                type: string

    CreditRemaining:
      type: object
      properties:
        available:
          type: string
        creditIn:
          type: string
        creditOut:
          type: string
        creditLimit:
          type: string
          nullable: true
        headroomToAutoBorrow:
          type: string
        utilizationBps:
          type: number
        sessionSpendLimit:
          type: string
          nullable: true
        sessionSpent:
          type: string
        sessionSpendRemaining:
          type: string
          nullable: true
        asOf:
          type: string
          format: date-time

    SpendLimit:
      type: object
      properties:
        active:
          type: boolean
        limitRaw:
          type: string
          nullable: true
        sessionStartedAt:
          type: string
          format: date-time
          description: Only present when active=true
        sessionSpentRaw:
          type: string
        sessionRemainingRaw:
          type: string

    CreditThreshold:
      type: object
      properties:
        id:
          type: integer
        thresholdBps:
          type: integer
        lastState:
          type: string
          nullable: true
        lastFiredAt:
          type: string
          nullable: true
          format: date-time
        webhookId:
          type: integer
          nullable: true

    CloseResult:
      type: object
      properties:
        status:
          type: string
          enum: [closed, winding_down]
        loansRepaid:
          type: integer
        loansRemaining:
          type: integer
        repayTxHashes:
          type: array
          items:
            type: string
        transferTxHash:
          type: string
        usdcTransferred:
          type: string
        remainingLoans:
          type: array
          items:
            type: object
            properties:
              loanId:
                type: string
              principalRaw:
                type: string

    TransactionList:
      type: object
      properties:
        transactions:
          type: array
          items:
            type: object
            properties:
              id:
                type: integer
              agentId:
                type: string
              targetUrl:
                type: string
              method:
                type: string
              status:
                type: string
                enum: [pending, success, failed]
              paymentAmountRaw:
                type: string
                nullable: true
              paymentRecipient:
                type: string
                nullable: true
              responseStatusCode:
                type: integer
                nullable: true
              latencyMs:
                type: integer
                nullable: true
              x402TxHash:
                type: string
                nullable: true
              createdAt:
                type: string
                format: date-time
              updatedAt:
                type: string
                format: date-time
        nextCursor:
          type: integer
        hasMore:
          type: boolean

    ManagedAgent:
      type: object
      description: Serialized agent row (see routes/developer/agent-serializer.ts).
      properties:
        id:
          type: integer
        name:
          type: string
        mode:
          type: string
          enum: [legacy, managed]
        fundingMode:
          type: string
          enum: [wallet, credit_line]
        status:
          type: string
          enum: [pending_delegation, active, suspended, rollover_failed, credit_frozen, closed]
        suspendedReason:
          type: string
          nullable: true
        agentWalletAddress:
          type: string
        privyWalletAddress:
          type: string
          nullable: true
        creditLimit:
          type: string
          nullable: true
        maxRateBps:
          type: integer
          nullable: true
        operatorExpiry:
          type: string
          nullable: true
        delegationActive:
          type: boolean
        sessionSpendLimitRaw:
          type: string
          nullable: true
        selfServiceLocked:
          type: boolean
          description: Tighten-only mode for agent self-service (WS1 security).
        createdAt:
          type: string
          format: date-time
          nullable: true
        closedAt:
          type: string
          format: date-time
          nullable: true

    ApiKey:
      type: object
      properties:
        id:
          type: integer
        keyPrefix:
          type: string
          description: First 8 chars + "..."
        key:
          type: string
          description: Full plaintext key (shown ONCE at creation/rotation)
        label:
          type: string
          nullable: true
        permissions:
          type: string
          enum: [read, read_write]
        createdAt:
          type: string
          format: date-time
        budget:
          type: object
          nullable: true
          description: Per-key spend budget snapshot (agent keys only; null when uncapped).

    ApiKeySummary:
      type: object
      description: Key listing entry — never contains the plaintext key.
      properties:
        id:
          type: integer
        keyPrefix:
          type: string
        label:
          type: string
          nullable: true
        permissions:
          type: string
          enum: [read, read_write]
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
        budget:
          type: object
          nullable: true
          description: Present on agent-key listings only.

    ProviderKeySummary:
      type: object
      description: Stored BYOK vendor-key listing entry — never contains key material.
      properties:
        provider:
          type: string
          description: Catalog provider id (openai, anthropic, google, ...)
        keyPrefix:
          type: string
          description: First characters of the stored key, for recognition only.
        label:
          type: string
          nullable: true
        enabled:
          type: boolean
        createdBy:
          type: string
          nullable: true
          description: Account member wallet that saved the key (attribution).
        lastUsedAt:
          type: string
          format: date-time
          nullable: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    AgentPolicy:
      type: object
      description: One spend-control policy row (agent_policies).
      properties:
        id:
          type: integer
        scope:
          type: string
          enum: [agent, developer]
        kind:
          type: string
          enum: [session, task, api, vendor, key]
        matchKey:
          type: string
          nullable: true
        matchKind:
          type: string
          nullable: true
          enum: [host_exact, host_suffix, recipient, null]
        limitRaw:
          type: string
        limitFloorRaw:
          type: string
          nullable: true
        limitCeilingRaw:
          type: string
          nullable: true
        windowKind:
          type: string
          enum: [once, rolling, session]
        windowSeconds:
          type: integer
          nullable: true
        expiresAt:
          type: integer
          nullable: true
        label:
          type: string
          nullable: true
        action:
          type: string
          nullable: true
          enum: [block, suspend_agent, null]

    CreatePolicyRequest:
      type: object
      required: [kind, matchKey, limitRaw]
      properties:
        kind:
          type: string
          enum: [task, api, vendor]
        matchKey:
          type: string
          maxLength: 255
        matchKind:
          type: string
          enum: [host_exact, host_suffix, recipient]
        limitRaw:
          type: string
          pattern: "^\\d+$"
        windowKind:
          type: string
          enum: [once, rolling]
          default: rolling
        windowSeconds:
          type: integer
        expiresAt:
          type: integer
        label:
          type: string
          maxLength: 255
        action:
          type: string
          enum: [block, suspend_agent]
        limitFloorRaw:
          type: string
        limitCeilingRaw:
          type: string
        qualityThrottleFloorBps:
          type: integer
        qualityWindowSeconds:
          type: integer

    SubscribableEvent:
      description: >
        A subscribable event value: a catalog event name
        (WebhookCatalogEventName) or a wildcard
        (WebhookSubscriptionWildcard — the global `*` or a `<prefix>.*`
        covering at least one catalog event). Wildcards are accepted in
        subscriptions and echoed back verbatim in Webhook.events. Full
        catalog with titles, descriptions, categories, and scope dimensions:
        GET /v1/developer/webhooks/events.
      oneOf:
        - $ref: "#/components/schemas/WebhookCatalogEventName"
        - $ref: "#/components/schemas/WebhookSubscriptionWildcard"
      example: call.ended

    WebhookSubscriptionWildcard:
      type: string
      description: >
        A wildcard subscription value: `*` (every event) or a dot-prefix
        wildcard. Only prefixes that cover at least one catalog event are
        valid (any dot level), so this enum is derived from the catalog —
        a typo like `lone.*` is rejected server-side.
      enum: ["*", "loan.*", "agent.*", "key.*", "x402.*", "provider_key.*", "credit.*", "call.*", "call.report.*", "call.recording.*", "phone.*", "phone.number.*", "marketplace.*", "marketplace.job.*", "marketplace.payment.*", "marketplace.spend_cap.*", "marketplace.tripwire.*", "marketplace.vendor.*"]

    WebhookCatalogEventName:
      type: string
      description: A catalog event name (no wildcards).
      enum: [loan.health_warning, loan.expiry_warning, loan.overdue, loan.liquidated, loan.repaid, agent.created, agent.suspended, key.created, key.rotated, x402.first_settlement, provider_key.created, provider_key.updated, provider_key.deleted, credit.warning, credit.at_limit, credit.recovered, call.started, call.ended, call.report.ready, call.recording.ready, call.analyzed, call.rejected, phone.number.grace, phone.number.released, marketplace.job.completed, marketplace.payment.settled, marketplace.spend_cap.hit, marketplace.tripwire.triggered, marketplace.vendor.degraded, marketplace.vendor.recovered]

    WebhookCatalogEvent:
      type: object
      description: One entry of GET /v1/developer/webhooks/events.
      properties:
        name:
          $ref: "#/components/schemas/WebhookCatalogEventName"
        title:
          type: string
          description: Short human label for the dashboard event picker.
        description:
          type: string
        category:
          type: string
          description: Dashboard picker grouping.
          enum: [loan, agent, credit, call, phone, marketplace]
        scope:
          type: string
          description: >
            The scope dimension the event routes on — loan (matches
            loan-scoped webhooks on loanId), agent (matches wallet/agent-
            scoped webhooks on the agent wallet address), platform
            (broadcast to every subscribed webhook, scope ignored).
          enum: [loan, agent, platform]

    Webhook:
      type: object
      properties:
        id:
          type: integer
        url:
          type: string
        secret:
          type: string
          description: whsec_* — returned only at creation / rotate-secret.
        events:
          type: array
          items:
            $ref: "#/components/schemas/SubscribableEvent"
        scope:
          type: string
          enum: [global, wallet, agent, loan]
        scopeValue:
          type: string
          nullable: true
          description: >
            0x wallet address for wallet/agent scope (agent scopeValue is
            the agent's WALLET address, never the numeric agent id);
            numeric loan id for loan scope; null for global.
        active:
          type: boolean
        description:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time

    WebhookDeliveryLogRow:
      type: object
      description: >
        Poll-weight account-wide delivery log row — no request/response
        bodies (the detail endpoint carries those). Retained 30 days.
      properties:
        id:
          type: integer
          description: Numeric row id (keyset tiebreaker).
        deliveryId:
          type: string
          description: >
            32-hex delivery id — equals the X-Floe-Delivery-Id header and
            stays STABLE across retries; dedupe on it.
        webhookId:
          type: integer
        webhookUrl:
          type: string
        event:
          type: string
        status:
          type: string
          enum: [pending, success, failed, retrying]
        statusCode:
          type: integer
          nullable: true
        attempt:
          type: integer
          description: 1-3 — the retry ladder is +60s then +300s, then failed.
        error:
          type: string
          nullable: true
        agentWallet:
          type: string
          nullable: true
          description: >
            Agent wallet the event is about (lowercased); null for loan and
            platform events.
        correlationId:
          type: string
          nullable: true
          description: >
            Provider/domain correlation id — voice-provider call id or
            Twilio CallSid for call.* events, job id for
            marketplace.job.completed, loan id for loan.* events.
        createdAt:
          type: string
          format: date-time

    FundingInstructions:
      type: object
      description: Machine-readable "how to fund this agent" card.
      properties:
        agentId:
          type: integer
        depositAddress:
          type: string
          description: The agent's Privy executor wallet — send USDC on Base here.
        network:
          type: string
          enum: [base]
        chainId:
          type: integer
          description: Chain the deposit address lives on — the deployment's configured chain (8453 on Base Mainnet).
          example: 8453
        token:
          type: string
          enum: [USDC]
        tokenContract:
          type: string
        forwardingEnabled:
          type: boolean
          description: Whether confirmed deposits are auto-forwarded to the spendable payment-signer wallet.
        spendableBalance:
          type: string
          description: Current spendable balance (raw USDC, 6 decimals).
        warnings:
          type: array
          items:
            type: string
        dashboardUrl:
          type: string

    Capabilities:
      type: object
      properties:
        version:
          type: string
          description: API package version.
        capabilities:
          type: object
          properties:
            gateway:
              type: boolean
            veniceProxy:
              type: boolean
            llmProxy:
              type: boolean
            telephony:
              type: boolean
            onrampHeadless:
              type: boolean
            x402Facilitator:
              type: boolean

    ErrorNext:
      type: object
      description: Machine-readable next-step contract carried by onboarding-relevant errors.
      properties:
        error:
          type: string
        message:
          type: string
        next:
          type: object
          properties:
            hint:
              type: string
            method:
              type: string
              enum: [GET, POST, PUT, PATCH, DELETE]
            path:
              type: string

    AdminAgent:
      type: object
      properties:
        id:
          type: string
        privyWalletAddress:
          type: string
        status:
          type: string
        balance:
          type: string
        creditLimit:
          type: string
          nullable: true
        creditUsed:
          type: string
        activeLoanCount:
          type: integer
        delegationActive:
          type: boolean
        createdAt:
          type: string
          format: date-time

    OpenAiError:
      type: object
      description: >
        OpenAI-shaped gateway error. On wrong_credential_type the
        machine-readable `next` remediation rides ALONGSIDE the `error`
        object (additive top-level key) so OpenAI-compatible clients keep
        parsing unchanged.
      properties:
        error:
          type: object
          properties:
            message:
              type: string
            type:
              type: string
            code:
              type: string
        next:
          type: object
          properties:
            hint:
              type: string
            method:
              type: string
            path:
              type: string

    KeyBudget:
      type: object
      description: A key's current spend budget + derived spend (see PolicyService KeyBudgetView).
      properties:
        policyId:
          type: integer
        limitRaw:
          type: string
          description: Budget cap, raw USDC (6 decimals).
        spentRaw:
          type: string
          description: Derived spend in the current window, raw USDC (6 decimals).
        remainingRaw:
          type: string
          description: max(0, limit - spent), raw USDC (6 decimals).
        windowKind:
          type: string
          enum: [once, rolling, session]
        windowResetsAt:
          type: string
          format: date-time
          nullable: true
          description: Refill time for rolling windows; null otherwise.

    LimitChainRow:
      type: object
      description: >
        One link in an agent's spend-limit chain — a live policy's effective
        cap plus the same derived spend the enforcement path uses. The route
        layer appends a final scope='balance' row (spendable balance). FIXED
        CONTRACT — the dev-dashboard is coded against this shape.
      properties:
        scope:
          type: string
          enum: [agent, developer, balance]
        kind:
          type: string
          nullable: true
          enum: [session, task, api, vendor, key, null]
          description: null only for the balance row.
        label:
          type: string
          nullable: true
        policyId:
          type: integer
          nullable: true
          description: null for the balance row and the synthesized legacy session (id -1).
        matchKey:
          type: string
          nullable: true
        limitRaw:
          type: string
          description: Effective limit, raw USDC (6 decimals).
        spentRaw:
          type: string
          description: Raw USDC (6 decimals).
        remainingRaw:
          type: string
          description: max(0, limit - spent), raw USDC (6 decimals).
        windowKind:
          type: string
          nullable: true
        windowResetsAt:
          type: string
          format: date-time
          nullable: true

    MtdRollup:
      type: object
      description: Month-to-date bill rollup shared by /billing/mtd and /billing/invoice.
      properties:
        totalRaw:
          type: string
          description: Month-to-date total, raw USDC (6 decimals).
        byVendor:
          type: array
          items:
            type: object
            properties:
              vendor:
                type: string
                description: Machine vendor label (model provider prefix, target host, or floe-phone).
              costRaw:
                type: string
                description: Raw USDC (6 decimals).
        byAgent:
          type: array
          items:
            type: object
            properties:
              agentId:
                type: integer
              agentName:
                type: string
              costRaw:
                type: string
                description: Raw USDC (6 decimals).
              calls:
                type: integer

    PhoneNumber:
      type: object
      description: Public Floe Phone number shape — carrier SIDs/subaccounts are Floe-internal and never exposed.
      properties:
        id:
          type: integer
        phoneNumber:
          type: string
          description: E.164.
        status:
          type: string
        areaCode:
          type: string
          nullable: true
        monthlyRentalRaw:
          type: string
          description: Snapshot rental price, raw USDC (6 decimals). Renewals debit this snapshot.
        nextRenewalAt:
          type: string
          format: date-time
          nullable: true
        graceUntil:
          type: string
          format: date-time
          nullable: true
        releasedAt:
          type: string
          format: date-time
          nullable: true
        releaseReason:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time

    ActionOutcome:
      type: object
      description: Caller-reported outcome for one action (Floe never judges quality).
      properties:
        status:
          type: string
          enum: [success, failure, partial, unknown]
        scoreBps:
          type: integer
          nullable: true
        note:
          type: string
          nullable: true
        reportCount:
          type: integer
        reportedAt:
          type: string
          format: date-time

    OrchestratorConnection:
      type: object
      description: >
        An orchestrator webhook connection. The webhook/pre-call URLs carry
        the capability token and ARE returned; the sealed verification
        secret never is (the token identifies, the secret authenticates).
      required: [id, provider, active, webhookUrl, preCallUrl]
      properties:
        id:
          type: integer
        provider:
          type: string
          enum: [vapi, retell, bland, pipecat, livekit]
        agentWallet:
          type: string
        label:
          type: string
          nullable: true
        active:
          type: boolean
        lastEventAt:
          type: string
          format: date-time
          nullable: true
        webhookUrl:
          type: string
          description: Call-end delivery URL to paste into the provider.
        preCallUrl:
          type: string
          description: >-
            Pre-call admission URL for all providers. Vapi callers append
            ?assistantId=<id>. Bland has no native pre-call webhook, so it wires
            this into a Pathway Webhook node that branches on the HTTP status:
            Floe returns 200 {"allowed":true} to admit or 402 {"allowed":false}
            to deny. AUTH EXCEPTION: unlike every other webhook (which the parent
            `secret` signs), the Bland pre-call authenticates on its capability
            token ALONE — a static Pathway node cannot compute an HMAC and the
            check is read-only. Treat the URL as a secret and rotate to revoke.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    OfframpOrder:
      type: object
      description: Serialized offramp order (failureReason only surfaces when status is terminal-failed).
      properties:
        ref:
          type: string
          description: partnerUserRef — the order's public id.
        sourceKind:
          type: string
          enum: [embedded_wallet, agent_wallet]
        agentId:
          type: integer
          nullable: true
        status:
          type: string
        devEoaAddress:
          type: string
          nullable: true
        agentWalletAddress:
          type: string
          nullable: true
        requestedAmountRaw:
          type: string
          description: Raw USDC (6 decimals).
        finalAmountRaw:
          type: string
          nullable: true
          description: Raw USDC (6 decimals).
        fiatEstimate:
          type: string
          nullable: true
        cdpDepositAddress:
          type: string
          nullable: true
        cdpTransactionId:
          type: string
          nullable: true
        leg1TransferId:
          type: string
          nullable: true
        leg1TxHash:
          type: string
          nullable: true
        leg2TxHash:
          type: string
          nullable: true
        failureReason:
          type: string
          nullable: true
        asset:
          type: string
        chain:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    Error:
      type: object
      properties:
        error:
          type: string
        message:
          type: string
        details:
          type: array
          items: {}

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Unauthorized:
      description: Missing or invalid authentication
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
