openapi: 3.1.0
info:
  title: Weft API
  version: 0.7.0
  description: |
    The Weft API is the buyer-runtime surface that powers the `weft` CLI,
    the hosted MCP server (`weft.network/mcp`), and any third-party agent
    that wants to discover and pay for paid resources on Weft. v1 covers
    five buyer concerns:

      1. Account onboarding (`/api/v1/auth/*`, `/api/v1/me`)
      2. CLI authentication (`/api/v1/api_keys`)
      3. Wallet visibility (`/api/v1/balance`)
      4. Discovery (`/api/v1/search`)
      5. Paid execution (`/api/v1/fetch`)
      6. Purchase history (`/api/v1/payments`)

    Seller-side concerns (agent management, payout analytics, webhook
    delivery, the public storefront for `data_api` resources) live in the
    dashboard and are intentionally not documented here. They will be
    split out into a separate, dashboard-scoped spec when they need to be
    SDK-consumable.

    All errors share the envelope defined by `ErrorResponse`, except the
    buyer-runtime endpoints (`/search`, `/fetch`) which use bespoke
    envelopes carrying additional context — see `SearchErrorResponse` and
    `FetchErrorResponse`.
tags:
  - name: Auth
    description: Account creation and authentication flows.
  - name: Account
    description: Current user account information.
  - name: API Keys
    description: Manage API keys used by the CLI and any direct HTTP caller.
  - name: Balance
    description: |
      Read-only snapshot of the buyer's wallet, spending policy, and
      current-window spend (today / this week).
  - name: Search
    description: |
      Semantic search across the Weft index of paid agent resources.
      Returns A2A-shaped AgentCards with pricing, skills, ranking, and
      endpoints. The mock backend (default while the search-platform is
      unshipped) reads YAML fixtures from `db/seeds/mock-search/`; the
      platform backend proxies to the upstream search service. Both share
      the same response envelope.
  - name: Fetch
    description: |
      The universal x402 fetch proxy. POST any URL the agent wants to
      reach, with a `max_cost_usd` ceiling; Weft handles the `402 Payment
      Required` challenge, signs the EIP-3009 payment from the buyer's
      wallet, replays the request, and streams the upstream artifact
      back base64-encoded. Errors always carry the buyer's current
      policy + balance + dashboard URL so the caller can surface "what
      changed?" without a second round-trip.
  - name: Payments
    description: Payment history and individual purchase details.
  - name: Resources
    description: |
      Public, unauthenticated ghost-resource enrollment. Lets an agent
      self-register so its human controller can later claim it from the
      dashboard.
servers:
  - url: https://weft.network
    description: Production
  - url: https://staging.weft.network
    description: Staging
  - url: http://localhost:3000
    description: Development
paths:
  /docs/openapi.yaml:
    get:
      summary: Fetch this OpenAPI document
      operationId: getOpenApiDocument
      responses:
        "200":
          description: OpenAPI 3.1 document (YAML)
          content:
            application/yaml:
              schema:
                type: string
  /api/v1/resources/enroll:
    post:
      summary: Self-enroll a ghost resource (public, no auth)
      description: |
        Lets an unauthenticated agent (or its operator) self-enroll a
        Resource as a "ghost" (no owning provider). The server generates the
        `slug` and a single-use `claim_token`; the response also carries a
        `claim_url` the agent hands to its human controller to claim the
        resource from the dashboard.

        Client-supplied `slug` and `category` are silently ignored: slugs are
        server-generated from `name` (so callers cannot squat on reserved
        names), and `category` is a post-claim concern set by a real human.

        Rate-limited to 10 enrollments/hour/IP.
      operationId: enrollResource
      tags: [Resources]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResourceEnrollmentRequest"
      responses:
        "201":
          description: Ghost resource created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ResourceEnrollmentResponse"
        "422":
          description: Validation error (missing/blank fields, or unknown `kind`)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: Rate limit exceeded (10 enrollments/hour/IP)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/sign_up:
    post:
      summary: Create an account
      operationId: signUp
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SignUpRequest"
      responses:
        "200":
          description: Account created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthResponse"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/confirm:
    post:
      summary: Confirm an account
      operationId: confirmAccount
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ConfirmRequest"
      responses:
        "200":
          description: Account confirmed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthResponse"
        "422":
          description: Invalid token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/resend-confirmation:
    post:
      summary: Resend confirmation email
      operationId: resendConfirmation
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ResendConfirmationRequest"
      responses:
        "200":
          description: Confirmation sent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
  /api/v1/auth/sign_in:
    post:
      summary: Sign in with email and password
      operationId: signIn
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SignInRequest"
      responses:
        "200":
          description: Signed in
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthResponse"
        "401":
          description: Invalid credentials
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/auth/password/reset:
    post:
      summary: Request password reset
      operationId: requestPasswordReset
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PasswordResetRequest"
      responses:
        "200":
          description: Reset email sent
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
  /api/v1/auth/password/update:
    post:
      summary: Update password with reset token
      operationId: updatePassword
      tags: [Auth]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PasswordUpdateRequest"
      responses:
        "200":
          description: Password updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "422":
          description: Invalid or expired reset token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/me:
    get:
      summary: Get current account
      operationId: getMe
      tags: [Account]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Current user account
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MeResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: |
            The credential authenticated but has no organization context
            (`ORGANIZATION_REQUIRED`). Account-scoped (buyer) keys cannot read
            this org-scoped endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/api_keys:
    get:
      summary: List API keys
      operationId: listApiKeys
      tags: [API Keys]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: List of API keys
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyListResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: |
            The credential authenticated but has no organization context
            (`ORGANIZATION_REQUIRED`). Account-scoped (buyer) keys cannot read
            this org-scoped endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    post:
      summary: Create an API key
      operationId: createApiKey
      tags: [API Keys]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateApiKeyRequest"
      responses:
        "201":
          description: API key created (includes raw key, shown only once)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiKeyCreatedResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: Validation error
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/api_keys/{id}:
    delete:
      summary: Revoke an API key
      operationId: revokeApiKey
      tags: [API Keys]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: API key ID
      responses:
        "200":
          description: API key revoked
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: API key not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/balance:
    get:
      summary: Get wallet, spending policy, and current-window spend
      description: |
        Read-only snapshot for the buyer behind the bearer token. The
        response always includes a `promo` block — values are zero in v1
        and fill in once the freemium promo ledger ships, without a
        shape change. `wallet.balance_usdc` is fetched live from Privy;
        if Privy is unreachable the field returns `"0.00"` rather than
        erroring the whole call.

        Account-scoped: the bearer must be a buyer-scoped API key.
      operationId: getBalance
      tags: [Balance]
      security:
        - bearerAuth: []
      responses:
        "200":
          description: Wallet + policy + spend snapshot
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BalanceResponse"
        "401":
          description: Unauthorized — missing or non-buyer-scoped API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: |
            The OAuth access token authenticated but lacks the `balance`
            scope (RFC 6750 `insufficient_scope`). Carries a
            `WWW-Authenticate: Bearer error="insufficient_scope", scope="balance"`
            header. `wk_` API keys are unscoped and never see this.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InsufficientScopeResponse"
  /api/v1/search:
    post:
      summary: Search the Weft index
      description: |
        Semantic search over the Weft index of paid agent resources. The
        request body carries a free-text `query`, an optional `limit`, and
        an optional `filters` object that narrows by price band, payment
        protocol, agent protocol, or domain tag. Filters are applied as a
        post-filter after the embedding score is computed.

        Backend selection is server-side via the `SEARCH_BACKEND` env var:
        `mock` (default, reads YAML fixtures and sets `_mock: true` in the
        response) or `platform` (proxies to the upstream search service).
        Both backends return the same envelope.

        Account-scoped: the bearer token must be a buyer-scoped API key.
        Free for authenticated buyers in v1; billing is planned for a later
        release.

        Response negotiation: `Accept: application/json` (default) returns
        the structured envelope; `Accept: text/markdown` returns a rendered
        Markdown digest of the same results — useful for piping into a chat
        UI or LLM prompt.
      operationId: search
      tags: [Search]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SearchRequest"
      responses:
        "200":
          description: Search results
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchResponse"
            text/markdown:
              schema:
                type: string
                description: Rendered Markdown digest of the same results.
        "401":
          description: Unauthorized — missing or non-buyer-scoped API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: |
            The OAuth access token authenticated but lacks the `search`
            scope (RFC 6750 `insufficient_scope`). Carries a
            `WWW-Authenticate: Bearer error="insufficient_scope", scope="search"`
            header. `wk_` API keys are unscoped and never see this.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InsufficientScopeResponse"
        "422":
          description: Invalid query (empty/missing)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchErrorResponse"
        "502":
          description: Upstream search backend error (platform backend only)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchErrorResponse"
        "500":
          description: Backend misconfigured (`SEARCH_BACKEND` unset or unknown)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SearchErrorResponse"
  /api/v1/fetch:
    post:
      summary: Pay-and-fetch any URL (x402 proxy)
      description: |
        Universal x402 fetch proxy. The caller provides a target `url`,
        a hard `max_cost_usd` ceiling, and optional `method` / `body` /
        `headers`. Weft:

          1. Issues the request.
          2. On `402 Payment Required`, parses the merchant's challenge.
          3. Compares the asking price to `max_cost_usd` and the
             buyer's policy (`max_tx_usd`, daily/weekly limits).
          4. Signs an EIP-3009 transfer from the buyer's wallet.
          5. Replays the request with the `X-Payment` header.
          6. Streams the upstream artifact back, base64-encoded under
             `body_base64`, with `paid_usd`, `tx_hash`, and the
             merchant's reputation snapshot.

        Errors are structured with a stable `error` code, and each error
        response carries the buyer's `policy`, `balance`, and a
        `dashboard_url` so a CLI can render an actionable message without
        a second round-trip.

        Account-scoped: the bearer must be a buyer-scoped API key.

        **Forwarded headers:** the caller's `headers` are passed through
        to the upstream, except a denylist of hop-by-hop and
        Weft-internal headers (`host`, `authorization`, `cookie`,
        `proxy-authorization`, `x-forwarded-*`, `x-real-ip`,
        `x-payment`, `connection`, `upgrade`). Up to 32 headers, 4 KB of
        combined value bytes.
      operationId: fetch
      tags: [Fetch]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/FetchRequest"
      responses:
        "200":
          description: Fetch succeeded (or upstream was free); artifact streamed back base64-encoded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FetchResponse"
        "401":
          description: Unauthorized — missing or non-buyer-scoped API key
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "402":
          description: |
            Payment refused. `EXCEEDED_MAX_COST` (over the caller cap) or
            `INSUFFICIENT_BALANCE` (the buyer is genuinely short). Also
            `CONVERSION_UNAVAILABLE` — a cross-chain provision route is down
            while the buyer's funds are intact (`details.venue`,
            `details.retryable: true`); retry later, do NOT top up. All use
            `FetchErrorResponse`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FetchErrorResponse"
        "403":
          description: |
            Policy violation, denylisted recipient, or a merchant challenge on a
            chain outside the wallet's own environment — a testnet wallet may
            not pay a mainnet challenge, nor the reverse
            (`WALLET_ENVIRONMENT_MISMATCH`; terminal, not retryable). All three
            use `FetchErrorResponse`. Alternatively an OAuth access token
            lacking the `fetch` scope (`InsufficientScopeResponse`, RFC 6750
            `insufficient_scope`, with a `WWW-Authenticate` challenge). The two
            envelopes are disjoint; branch on the `error` value.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/FetchErrorResponse"
                  - $ref: "#/components/schemas/InsufficientScopeResponse"
        "413":
          description: Upstream artifact exceeded the proxy's size cap.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FetchErrorResponse"
        "422":
          description: Invalid request URL, or the merchant advertised a payment method Weft cannot sign.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FetchErrorResponse"
        "424":
          description: |
            `MERCHANT_RETURNED_NON_402` — the upstream merchant is at fault: it
            did not return a 402, or its 402 challenge was invalid. A 4xx (not
            5xx) because Weft behaved correctly and the caller should act on
            `details.reason` (e.g. pick another merchant); it also keeps the
            error envelope intact through CDNs that replace 5xx bodies.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FetchErrorResponse"
        "502":
          description: Settlement signing failed on Weft's side (`SETTLEMENT_FAILED`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FetchErrorResponse"
  /api/v1/payments:
    get:
      summary: List payments
      operationId: listPayments
      tags: [Payments]
      security:
        - bearerAuth: []
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
          description: Page number
        - name: per_page
          in: query
          schema:
            type: integer
            default: 25
            maximum: 100
          description: Items per page
      responses:
        "200":
          description: List of payments
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentListResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: |
            The credential authenticated but has no organization context
            (`ORGANIZATION_REQUIRED`). Account-scoped (buyer) keys cannot read
            this org-scoped endpoint.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /api/v1/payments/{id}:
    get:
      summary: Get payment details
      operationId: getPayment
      tags: [Payments]
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
          description: Payment ID
      responses:
        "200":
          description: Payment details
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentResponse"
        "401":
          description: Unauthorized
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: Payment not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: APIKey
  schemas:
    ErrorResponse:
      type: object
      description: |
        Standard error envelope returned by all `/api/v1` endpoints except
        `/search` and `/fetch` (which use bespoke envelopes carrying
        additional context). `error.code` is stable across releases and
        safe to branch on; `error.message` is human-readable and may change.
      properties:
        error:
          $ref: "#/components/schemas/Error"
      required: [error]
      example:
        error:
          code: "unauthorized"
          message: "Invalid or expired API key"
          request_id: "req_01HX9F3K7B2N4P8Q1R6T8Y2Z5"
    Error:
      type: object
      description: |
        A single error inside an `ErrorResponse` envelope. `code` is the
        stable machine-readable identifier (snake_case), `message` is
        human-readable, `details` carries optional structured context
        (e.g. validation field breakdown), and `request_id` correlates
        with server logs for debugging.
      properties:
        code:
          type: string
          description: Stable machine-readable error code (snake_case).
          example: "validation_failed"
        message:
          type: string
          description: Human-readable error description.
          example: "Email has already been taken"
        details:
          type: object
          nullable: true
          description: Optional structured context about the failure.
        request_id:
          type: string
          description: Correlates with server logs; include when reporting bugs.
          example: "req_01HX9F3K7B2N4P8Q1R6T8Y2Z5"
      required: [code, message]
    InsufficientScopeResponse:
      type: object
      required: [error, error_description, scope]
      description: |
        RFC 6750 §3.1 `insufficient_scope` error. Returned with HTTP 403 and
        a `WWW-Authenticate: Bearer error="insufficient_scope", scope="..."`
        header when an OAuth access token authenticates successfully but does
        not carry the scope the endpoint requires. Only OAuth bearer tokens
        are scope-gated; `wk_`-prefixed API keys are unscoped and never see
        this response.
      properties:
        error:
          type: string
          enum: [insufficient_scope]
          example: insufficient_scope
        error_description:
          type: string
          example: The access token does not include the required scope.
        scope:
          type: string
          description: Space-delimited list of scopes the endpoint requires.
          example: balance
    MessageResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            message:
              type: string
          required: [message]
      required: [data]
    ResourceEnrollmentRequest:
      type: object
      required: [kind, name]
      description: |
        `slug` and `category` are accepted but silently ignored — the server
        generates the slug from `name`, and category is a post-claim concern.
      properties:
        kind:
          type: string
          description: |
            Resource kind. Must be one of the supported kinds (currently
            `agent`); an unknown value is rejected with a 422.
          example: agent
        name:
          type: string
          description: Human-readable name; the server derives the slug from it.
          example: Self-Enrolled Bot
        description:
          type: string
        wallet_address:
          type: string
          description: |
            Declared EVM wallet address for the ghost resource. Stored as a
            claim (unverified) until the resource is claimed.
    ResourceEnrollmentResponse:
      type: object
      required: [data]
      properties:
        data:
          $ref: "#/components/schemas/EnrolledResource"
    EnrolledResource:
      type: object
      description: |
        The `:created` view of a ghost resource. Adds `stats`, the single-use
        `claim_token`, and the `claim_url` to the base resource shape. The
        claim fields appear ONLY on this enrollment response — never in list
        or read views.
      required:
        - id
        - name
        - slug
        - kind
        - visibility
        - category
        - verified
        - claimed
        - created_at
        - stats
        - claim_token
        - claim_url
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        description:
          type: string
          nullable: true
        kind:
          type: string
          example: agent
        visibility:
          type: string
          example: public_profile
        wallet_address:
          type: string
          nullable: true
          description: Declared wallet address, or `null` if none was provided.
        category:
          type: string
          nullable: true
          description: |
            Legacy v3 taxonomy field. Returns a string only for `agent`-kind
            resources that carry a `category` in their metadata; `null` for
            all other kinds and for agents without one. Always `null` on
            enrollment (category is a post-claim concern).
        verified:
          type: boolean
        claimed:
          type: boolean
          description: True once the resource has an owning provider. Always false at enrollment.
        created_at:
          type: string
          format: date-time
        stats:
          $ref: "#/components/schemas/ResourceStats"
        claim_token:
          type: string
          description: Single-use token for the magic claim URL. Surfaced only on enrollment.
        claim_url:
          type: string
          nullable: true
          description: Dashboard path the agent hands to its human to claim the resource.
    ResourceStats:
      type: object
      description: Aggregate public payment stats for the resource.
      required:
        - total_paid
        - total_received
        - payments_made
        - payments_received
        - unique_counterparties
        - first_seen
        - last_active
      properties:
        total_paid:
          type: integer
          description: Total atomic units paid out by this resource.
        total_received:
          type: integer
          description: Total atomic units received by this resource.
        payments_made:
          type: integer
        payments_received:
          type: integer
        unique_counterparties:
          type: integer
        first_seen:
          type: string
          format: date-time
          nullable: true
        last_active:
          type: string
          format: date-time
          nullable: true
    Pagination:
      type: object
      properties:
        page:
          type: integer
        per_page:
          type: integer
        total:
          type: integer
        total_pages:
          type: integer
      required: [page, per_page, total, total_pages]
    SignUpRequest:
      type: object
      properties:
        email:
          type: string
          format: email
        password:
          type: string
        password_confirmation:
          type: string
      required: [email, password, password_confirmation]
    SignInRequest:
      type: object
      properties:
        email:
          type: string
          format: email
        password:
          type: string
      required: [email, password]
    ConfirmRequest:
      type: object
      properties:
        confirmation_token:
          type: string
      required: [confirmation_token]
    ResendConfirmationRequest:
      type: object
      properties:
        email:
          type: string
          format: email
      required: [email]
    PasswordResetRequest:
      type: object
      properties:
        email:
          type: string
          format: email
      required: [email]
    PasswordUpdateRequest:
      type: object
      properties:
        reset_password_token:
          type: string
        password:
          type: string
        password_confirmation:
          type: string
      required: [reset_password_token, password, password_confirmation]
    AuthResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            user:
              $ref: "#/components/schemas/User"
            token:
              type: string
              nullable: true
            confirmation_required:
              type: boolean
          required: [user, confirmation_required]
      required: [data]
    User:
      type: object
      properties:
        id:
          type: integer
        email:
          type: string
          format: email
        status:
          type: string
      required: [id, email]
    MeResponse:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/AccountDetails"
      required: [data]
    AccountDetails:
      type: object
      description: |
        The Organization that owns the authenticated API key — the principal
        in API v1 (the key represents an Org, not a person). `api_key` carries
        audit info about the key itself, including the user who minted it
        (`created_by`, which may be `null` if that user has left the Org).
      properties:
        id:
          type: integer
        name:
          type: string
        slug:
          type: string
        kind:
          type: string
        api_key:
          $ref: "#/components/schemas/MeApiKey"
      required: [id, name, slug, kind, api_key]
    MeApiKey:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        created_at:
          type: string
          format: date-time
        last_used_at:
          type: string
          format: date-time
          nullable: true
        created_by:
          $ref: "#/components/schemas/MeApiKeyCreator"
      required: [id, created_at]
    MeApiKeyCreator:
      type: object
      nullable: true
      description: |
        The user who minted this API key, surfaced for audit rendering only.
        `null` if that user has since left the Organization. NEVER use for
        authorization.
      properties:
        id:
          type: integer
        email:
          type: string
          format: email
        display_name:
          type: string
          nullable: true
      required: [id, email]
    CreateApiKeyRequest:
      type: object
      properties:
        name:
          type: string
          description: A friendly name for the API key
    ApiKey:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        key_prefix:
          type: string
          description: First 16 characters of the key for identification
        last_used_at:
          type: string
          format: date-time
          nullable: true
        revoked_at:
          type: string
          format: date-time
          nullable: true
        created_at:
          type: string
          format: date-time
      required: [id, key_prefix, created_at]
    ApiKeyCreated:
      type: object
      properties:
        id:
          type: integer
        name:
          type: string
          nullable: true
        key_prefix:
          type: string
        raw_key:
          type: string
          description: Full API key (shown only once at creation)
        created_at:
          type: string
          format: date-time
      required: [id, key_prefix, raw_key, created_at]
    ApiKeyListResponse:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/ApiKey"
        pagination:
          $ref: "#/components/schemas/Pagination"
      required: [data, pagination]
    ApiKeyCreatedResponse:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/ApiKeyCreated"
      required: [data]
    Payment:
      type: object
      properties:
        id:
          type: integer
        tx_hash:
          type: string
        payer_address:
          type: string
        recipient_address:
          type: string
        amount:
          type: integer
          description: Amount in atomic units (1 USDC = 1,000,000)
        amount_formatted:
          type: string
          description: Human-readable amount (e.g. "1.00 USDC")
        currency:
          type: string
        network:
          type: string
          description: CAIP-2 chain identifier
        resource_url:
          type: string
          nullable: true
        resource_host:
          type: string
          nullable: true
        fee_amount:
          type: integer
        settlement_latency_ms:
          type: integer
          nullable: true
        settled_at:
          type: string
          format: date-time
        api_key_name:
          type: string
      required: [id, tx_hash, payer_address, recipient_address, amount, amount_formatted, currency, network, settled_at, api_key_name]
    PaymentListResponse:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Payment"
        pagination:
          $ref: "#/components/schemas/Pagination"
      required: [data, pagination]
    PaymentResponse:
      type: object
      properties:
        data:
          $ref: "#/components/schemas/Payment"
      required: [data]
    BalanceResponse:
      type: object
      description: |
        Buyer's wallet snapshot. Note: unlike `/me` / `/payments` / `/api_keys`,
        this endpoint does NOT use a `data:` envelope — the response object
        is the snapshot directly.
      required: [promo, wallet, spent_today_usd, spent_week_usd, policy]
      properties:
        promo:
          $ref: "#/components/schemas/PromoBalance"
        wallet:
          $ref: "#/components/schemas/Wallet"
        spent_today_usd:
          type: string
          description: USD spent in the current calendar day (UTC), 2dp.
          example: "0.42"
        spent_week_usd:
          type: string
          description: USD spent in the current calendar week (UTC, Monday start), 2dp.
          example: "3.10"
        policy:
          $ref: "#/components/schemas/SpendingPolicy"
    PromoBalance:
      type: object
      description: |
        Freemium promo credit. v1 returns zero values; once the promo
        ledger ships (deferred from spec 10), real balances appear here
        without a shape change.
      required: [balance_usd, scope, expires_at]
      properties:
        balance_usd:
          type: string
          example: "0.00"
        scope:
          type: string
          description: Where the promo can be spent.
          example: "weft-search"
        expires_at:
          type: string
          format: date-time
          nullable: true
    Wallet:
      type: object
      required: [address, balance_usdc, tempo_usd, total_usd, network]
      properties:
        address:
          type: string
          nullable: true
          description: EVM address of the buyer's Privy-managed wallet. Null if no wallet provisioned.
        balance_usdc:
          type: string
          description: Live Base USDC balance, 2dp. Returns "0.00" if the upstream wallet provider is unreachable.
          example: "12.34"
        tempo_usd:
          type: string
          nullable: true
          description: >
            Aggregated USD value of the allowlisted Tempo TIP-20 dollar tokens on
            the wallet's paired Tempo chain, 2dp. `null` when the value is
            UNKNOWN — the Tempo RPC read failed, or no dollar token is
            allowlisted for that chain yet (e.g. Tempo mainnet pre-launch). A
            null here is never "0.00"; it means "we couldn't determine it", and
            `total_usd` then reflects the Base component only.
          example: "3.00"
        total_usd:
          type: string
          nullable: true
          description: >
            Single aggregated USD balance = Base USDC + Tempo dollar tokens, 2dp.
            When `tempo_usd` is null (unavailable/unallowlisted) this equals
            `balance_usdc` alone. Null when the Base USDC provider is
            unreachable, because the surface never claims zero for a component it
            could not read.
          example: "15.34"
        network:
          type: string
          nullable: true
          description: Wallet network (e.g. `base-sepolia`).
    SpendingPolicy:
      type: object
      required: [max_tx_usd, daily_limit_usd, weekly_limit_usd]
      properties:
        max_tx_usd:
          type: string
          description: Maximum USD per single transaction.
          example: "1.00"
        daily_limit_usd:
          type: string
          description: Maximum USD spent in a 24-hour window.
          example: "10.00"
        weekly_limit_usd:
          type: string
          description: Maximum USD spent in a 7-day window.
          example: "50.00"
    SearchRequest:
      type: object
      required: [query]
      properties:
        query:
          type: string
          minLength: 1
          description: Free-text query. Required and non-empty.
          example: "send email from an agent"
        limit:
          type: integer
          minimum: 1
          maximum: 50
          default: 10
          description: Max number of hits to return. Clamped to [1, 50].
        filters:
          $ref: "#/components/schemas/SearchFilters"
    SearchFilters:
      type: object
      description: |
        Optional narrowing applied after the embedding score is computed.
        Unknown keys are rejected. Price filters use the agent's cheapest
        skill for `max_price_usd` and the most-expensive skill for
        `min_price_usd`, so an agent stays in results as long as any one
        of its skills satisfies the band.
      additionalProperties: false
      properties:
        min_price_usd:
          type: string
          pattern: '^\d+(\.\d{1,6})?$'
          description: Decimal USD floor; agent must have at least one skill priced at or above this.
          example: "0.01"
        max_price_usd:
          type: string
          pattern: '^\d+(\.\d{1,6})?$'
          description: Decimal USD ceiling; agent must have at least one skill priced at or below this.
          example: "0.10"
        payment_protocol:
          type: string
          enum: [x402, mpp, free]
          description: Payment protocol the agent settles on.
        agent_protocol:
          type: string
          enum: [a2a, mcp, openapi, AgentNet]
          description: Agent protocol surface (Agent-to-Agent, MCP, raw OpenAPI, or AgentNet).
        domain:
          type: string
          description: |
            Substring match against any of the agent's declared domain tags
            (e.g. `email`, `sales`, `enrichment`).
    SearchResponse:
      type: object
      required: [results]
      description: |
        Spec-11 search envelope. `paid_usd`, `tx_hash`, and `artifact_id`
        are reserved for a later release that adds per-call billing and
        artifact persistence; they are always `null` in v1. `_mock: true`
        is set only by the mock backend.

        Result rows: the mock backend (`SEARCH_BACKEND=mock`, the default
        while the real index is unshipped) emits the rich, SDK-facing
        `SearchResult` shape. The legacy `platform` backend proxies the
        upstream search service and passes its result rows through verbatim —
        Weft does not own or reshape that payload, so those rows are typed as
        a free-form object. SDK clients on v1 should treat unknown row shapes
        defensively until the platform backend is retrofitted to the
        `SearchResult` contract (specs 07 + 10).

        Because the `PlatformSearchResult` branch is intentionally permissive
        (free-form, to admit the un-owned platform rows), the `anyOf` is
        satisfied by any object — so the committee response-validation gate does
        NOT strictly validate result-row shapes; the rich `SearchResult`
        contract is instead guarded by the `/api/v1/search` request spec.
      properties:
        results:
          type: array
          items:
            anyOf:
              - $ref: "#/components/schemas/SearchResult"
              - $ref: "#/components/schemas/PlatformSearchResult"
        paid_usd:
          type: string
          nullable: true
          description: Always `null` in v1.
        tx_hash:
          type: string
          nullable: true
          description: Always `null` in v1.
        artifact_id:
          type: string
          nullable: true
          description: Always `null` in v1.
        _mock:
          type: boolean
          description: Present and `true` only when served by the mock backend.
    SearchResult:
      type: object
      required: [id, score, protocol, domain, agent_card, pricing, ranking, endpoints]
      properties:
        id:
          type: string
          description: Stable agent identifier (e.g. `weft:agent:agentmail`).
        score:
          type: number
          format: double
          minimum: 0
          maximum: 1
          description: Cosine similarity score, clipped to [0, 1].
        protocol:
          type: string
          enum: [a2a, mcp, openapi, AgentNet]
          description: Agent protocol surface.
        domain:
          type: array
          items:
            type: string
          description: Domain tags declared by the agent.
        reseller:
          type: string
          nullable: true
          description: Reseller slug if this agent is fronted by an aggregator (e.g. `locus`).
        upstream:
          type: string
          nullable: true
          description: Upstream provider hostname when fronted by a reseller.
        agent_card:
          $ref: "#/components/schemas/SearchAgentCard"
        pricing:
          $ref: "#/components/schemas/SearchPricing"
        ranking:
          $ref: "#/components/schemas/SearchRanking"
        endpoints:
          $ref: "#/components/schemas/SearchEndpoints"
    PlatformSearchResult:
      type: object
      description: |
        A result row from the legacy `platform` search backend. The
        `/api/v1/search` controller proxies the upstream search service and
        passes its rows through verbatim, so Weft does not own or constrain
        this shape. Documented as a free-form object purely so the canonical
        spec stays truthful about both backends. SDK clients should prefer the
        rich `SearchResult` shape and treat platform rows defensively.
      additionalProperties: true
    SearchAgentCard:
      type: object
      description: |
        A2A-spec-shaped AgentCard. Free-form `capabilities` and per-skill
        metadata follow the upstream A2A schema; documented here only at
        the surface level Weft's index actually populates.
      additionalProperties: true
      properties:
        name:
          type: string
        description:
          type: string
        url:
          type: string
          format: uri
        version:
          type: string
        protocolVersion:
          type: string
        capabilities:
          type: object
          additionalProperties: true
        defaultInputModes:
          type: array
          items:
            type: string
        defaultOutputModes:
          type: array
          items:
            type: string
        skills:
          type: array
          items:
            $ref: "#/components/schemas/SearchSkill"
    SearchSkill:
      type: object
      additionalProperties: true
      properties:
        id:
          type: string
        name:
          type: string
        description:
          type: string
        tags:
          type: array
          items:
            type: string
        examples:
          type: array
          items:
            type: string
        inputModes:
          type: array
          items:
            type: string
        outputModes:
          type: array
          items:
            type: string
        streaming:
          type: boolean
          description: |
            True for skills that require streaming transport (e.g. websockets).
            Streaming skills are filtered out of results by default so
            non-streaming clients only see callable skills.
        endpoint:
          type: object
          additionalProperties: true
          properties:
            method:
              type: string
              description: HTTP method or `WEBSOCKET` for streaming skills.
            path:
              type: string
        price_usd:
          type: string
          pattern: '^\d+(\.\d{1,6})?$'
          description: Per-call price in USD for this individual skill.
    SearchPricing:
      type: object
      description: |
        Aggregate pricing at the agent level. `recipient_address` is
        intentionally absent from the index — under x402 it is delivered
        per-request in the `402 Payment Required` challenge from the agent.
      additionalProperties: true
      properties:
        protocol:
          type: string
          enum: [x402, mpp, free]
        scheme:
          type: string
          example: per_call
        amount_usd:
          type: string
          pattern: '^\d+(\.\d{1,6})?$'
          description: Default per-call amount; individual skills may override via `price_usd`.
        network:
          type: string
          description: Settlement network (e.g. `base-sepolia`).
    SearchRanking:
      type: object
      additionalProperties: true
      properties:
        popularity_score:
          type: number
          format: double
          minimum: 0
          maximum: 1
        settlement_count_30d:
          type: integer
          minimum: 0
        success_rate_30d:
          type: number
          format: double
          minimum: 0
          maximum: 1
        p50_latency_ms:
          type: integer
          minimum: 0
        first_seen_at:
          type: string
          format: date-time
    SearchEndpoints:
      type: object
      description: |
        Per-protocol entry points for the agent. Any field may be `null`
        if the agent does not expose that protocol surface.
      additionalProperties: true
      properties:
        a2a:
          type: string
          format: uri
          nullable: true
        mcp:
          type: string
          format: uri
          nullable: true
        openapi:
          type: string
          format: uri
          nullable: true
          description: URL to the agent's OpenAPI document.
        weft_fetch_target:
          type: string
          format: uri
          nullable: true
          description: Base URL the `weft_fetch` MCP tool should target for this agent.
    SearchErrorResponse:
      type: object
      required: [error]
      description: |
        Bespoke error envelope returned by `/api/v1/search` for `422`, `502`,
        and `500` responses. It differs from the standard `ErrorResponse`
        envelope (no nested `code`/`message`/`request_id`); SDK clients
        should handle both shapes for this endpoint until the controller
        is migrated to the standard envelope.
      properties:
        error:
          type: string
          description: Stable error code.
          enum:
            - INVALID_QUERY
            - SEARCH_UPSTREAM_ERROR
            - SEARCH_BACKEND_MISCONFIGURED
        details:
          type: object
          additionalProperties: true
          description: Optional context. Shape varies by error code.
    FetchRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: Target URL. Must pass Weft's URL safety check (no SSRF / private IP ranges).
          example: "https://x402.api.agentmail.to/v0/inboxes"
        max_cost_usd:
          type: string
          pattern: '^\d+(\.\d{1,6})?$'
          default: "0.10"
          description: Hard ceiling on what the buyer is willing to pay. Defaults to `0.10` USD.
          example: "0.05"
        method:
          type: string
          enum: [GET, POST, PUT, PATCH, DELETE]
          default: GET
          description: HTTP method to use against the upstream.
        body:
          description: Request body forwarded to the upstream. Pass `null` for GET.
          oneOf:
            - type: string
            - type: object
            - type: "null"
        headers:
          type: object
          additionalProperties:
            type: string
          description: |
            Headers forwarded to the upstream. Up to 32 headers, 4 KB
            total. The following are silently stripped: `host`,
            `authorization`, `cookie`, `proxy-authorization`,
            `x-forwarded-*`, `x-real-ip`, `x-payment`, `connection`,
            `upgrade`.
          example:
            Accept: application/json
            User-Agent: my-agent/1.0
    FetchResponse:
      type: object
      description: |
        Successful fetch envelope. `body_base64` is the upstream artifact
        bytes, base64-encoded. `paid_usd`, `tx_hash`, and `merchant` are
        populated only when the upstream charged for the response.
      required: [status, headers, body_base64, paid_usd, tx_hash, artifact_id, merchant]
      properties:
        status:
          type: integer
          description: HTTP status returned by the upstream after the paid replay.
          example: 200
        headers:
          type: object
          additionalProperties:
            type: string
          description: Response headers from the upstream.
        body_base64:
          type: string
          description: Base64-encoded response body. Empty string for empty bodies.
        paid_usd:
          type: string
          nullable: true
          description: USD amount actually settled. Null for free upstreams.
          example: "0.002"
        tx_hash:
          type: string
          nullable: true
          description: Settlement transaction hash. Null for free upstreams.
        artifact_id:
          type: integer
          nullable: true
          description: Internal artifact identifier if the response was persisted; `null` otherwise.
        merchant:
          allOf:
            - $ref: "#/components/schemas/Merchant"
          nullable: true
          description: Merchant reputation snapshot. Null for free upstreams.
    Merchant:
      type: object
      required: [address, settlement_count, first_seen_at, dispute_count]
      properties:
        address:
          type: string
          description: EVM address that received the payment.
        settlement_count:
          type: integer
          minimum: 0
          description: All-time settlement count for this merchant address.
        first_seen_at:
          type: string
          format: date-time
          nullable: true
          description: First time Weft observed a payment to this address.
        dispute_count:
          type: integer
          minimum: 0
          description: All-time dispute count for this merchant address.
    FetchErrorResponse:
      type: object
      required: [error, details, policy, balance, dashboard_url]
      description: |
        Bespoke error envelope for `/api/v1/fetch`. Every error carries the
        buyer's current `policy`, `balance`, and a `dashboard_url` so a CLI
        can render an actionable message without a second round-trip.

        `error` values include the fixed codes listed below plus the
        `POLICY_VIOLATION_<REASON>` family, where `<REASON>` is the violated
        policy field (`MAX_TX`, `DAILY`, or `WEEKLY` — see
        `PolicyViolation::REASONS`).
      properties:
        error:
          type: string
          description: Stable error code.
          example: EXCEEDED_MAX_COST
          enum:
            - INVALID_URL
            - EXCEEDED_MAX_COST
            - INSUFFICIENT_BALANCE
            - CONVERSION_UNAVAILABLE
            - MERCHANT_RETURNED_NON_402
            - ARTIFACT_TOO_LARGE
            - DENYLISTED_RECIPIENT
            - WALLET_ENVIRONMENT_MISMATCH
            - SETTLEMENT_FAILED
            - UNSUPPORTED_PAYMENT_METHOD
            - POLICY_VIOLATION_MAX_TX
            - POLICY_VIOLATION_DAILY
            - POLICY_VIOLATION_WEEKLY
        details:
          type: object
          additionalProperties: true
          description: Optional context. Shape varies by error code.
        policy:
          $ref: "#/components/schemas/SpendingPolicy"
        balance:
          $ref: "#/components/schemas/FetchBalanceSnapshot"
        dashboard_url:
          type: string
          format: uri
          description: Deep-link to the dashboard's policy page.
    FetchBalanceSnapshot:
      type: object
      required: [promo_usd, wallet_usdc, tempo_usd, total_usd, spent_today_usd]
      description: |
        Compact balance snapshot returned inside `FetchErrorResponse`. Less
        rich than `BalanceResponse` — just the fields a CLI needs to explain
        why a fetch failed.
      properties:
        promo_usd:
          type: string
          example: "0.00"
        wallet_usdc:
          type: string
          description: Live Base USDC balance.
          example: "12.34"
        tempo_usd:
          type: string
          nullable: true
          description: >
            Aggregated USD of allowlisted Tempo dollar tokens, 2dp. `null` when
            UNKNOWN (RPC read failed or no token allowlisted for the paired
            chain) — never "0.00" for an unread component.
          example: "3.00"
        total_usd:
          type: string
          nullable: true
          description: >
            Aggregated USD balance = Base USDC + Tempo dollar tokens, 2dp. Equals
            `wallet_usdc` alone when `tempo_usd` is null. Null when the Base USDC
            provider is unreachable.
          example: "15.34"
        spent_today_usd:
          type: string
          example: "0.42"
