openapi: 3.1.0

info:
  title: Flip API
  version: "1beta"
  summary: Turn a plain-English outcome into a ready-to-sign, multi-step, cross-chain transaction plan.
  description: |
    # Overview

    The Flip API exposes the same intent-to-transaction engine that powers the
    Flip app, as a small set of endpoints under a stable, versioned contract.

    ## Non-custodial by contract

    No endpoint ever holds keys, signs, or broadcasts. `/plan` returns UNSIGNED
    transaction data — EVM `to`/`data`/`value` calldata and Solana base64 transaction
    messages — that your own wallet or MPC signer executes. Flip has no signing
    authority anywhere in the flow. The API decides *what* to do and *how* to encode
    it; your signer decides whether to sign it.

    ## The flow: intent, plan, sign

    1. **`POST /intent`** — natural language in, a structured plan out (the `intents[]`:
       *what* to do, not yet *how*). May return `clarifications` instead when the
       request is ambiguous.
    2. **`POST /plan`** — the `intents[]` in, unsigned transactions out (the `steps[]`:
       real calldata your signer executes).
    3. **`POST /steps/reencode`** — re-quote a single step at sign time. Required for
       correct integrations: `chained` and `max` amounts are re-sized from live
       balances, and expiring routes (Jupiter blockhash, LiFi tool pinning) are
       refreshed. Returns `409` when a route is gone and the step must be re-planned.
    4. **`POST /simulate`** *(optional)* — dry-run the `steps[]` against a fork before
       signing to surface reverts and gas up front.

    ## Authentication & metering

    Every request is authenticated with a partner API key as a bearer token
    (`Authorization: Bearer fk_...`). Calls are metered against a per-tenant credit
    balance: **5 credits per delivered call** during beta. Check the balance any time
    with `GET /usage`; feature-detect capabilities and limits with `GET /meta`.

    Agents (programmatic alerts) and webhook delivery ship in this `/v1beta` API — see the
    **Agents** and **Webhook delivery** tags below.
  contact:
    name: Flip Labs
  x-schema-version: "2026-07-15"

servers:
  - url: https://app.fliplabs.ai/api/v1beta
    description: Production (beta)

security:
  - PartnerKey: []

tags:
  - name: Planning
    description: Turn intent into signable transactions.
  - name: Execution
    description: Re-quote and simulate steps at sign time.
  - name: Account
    description: Usage, credit balance, and capability discovery.
  - name: Agents
    description: |
      Programmatic alerts (price / yield / health-factor / portfolio-move / schedule / a
      plain-English condition) scoped to your tenant. Not metered by credits — gated by a
      per-tenant active-agent quota (by rate tier) and the standard per-key rate limit.
  - name: Webhook delivery
    description: |
      Register an HTTPS endpoint that agent triggers are pushed to, and pull/replay trigger
      history. See the `webhooks` section below for the delivery contract (signing,
      retries, dead-lettering).
  - name: Streaming (SSE)
    x-traitTag: true
    description: |
      Send `Accept: text/event-stream` to `/intent` (or `options.stream = true`) to stream
      the plan as it is built — the same event set the app renders: `delta` and `reasoning`
      (prose as it is written), `tool-start` / `tool-end` (render "thinking" status lines),
      and a terminal `final` event carrying the structured `intents` / `clarifications`.
      Omit the header for a single buffered JSON response.
  - name: Multi-turn conversations
    x-traitTag: true
    description: |
      `/intent` is stateless — carry prior turns yourself in the `conversation_context`
      request field. Join the prior turns with newlines, one turn per line, each line
      `User: <text>` or `Flip: <text>`, most recent last, ~8 turns max (the server
      truncates the block at 6000 characters). The current message always goes in
      `prompt`, never in the block. Example follow-up request:

          prompt: "actually only move half of it"
          conversation_context: "User: move my idle USDC into the best stable yield\nFlip: You hold 12,400 USDC idle on Ethereum. Aave v3 on Base pays the best stable rate, so I'll bridge to Base and deposit."
  - name: Clarifications
    x-traitTag: true
    description: |
      When a request is ambiguous ("which wallet funds this?", "which Morpho market?"),
      `/intent` returns `intents: []` and a populated `clarifications[]` — each
      `{ id, kind, question, options: [{ id, label }] }`. Render the options to your
      user; on pick, re-send `/intent` with `clarify_follow_up: true` and `prompt` set
      to the original prompt + `"\n\n"` + the plain-text answer block
      `Clarification — <question>: <picked label>`. The flag makes the planner
      acknowledge the answer and continue — it never re-raises the same clarification.
      Example re-send:

          prompt: "deploy my idle USDC\n\nClarification — Which wallet should fund this?: Main treasury (Ethereum)"
          clarify_follow_up: true
  - name: Versioning
    x-traitTag: true
    description: |
      Public fields are `snake_case` and stable. Internal app routes may change freely;
      this `/v1beta` surface is the frozen public contract, echoed on every response via
      the `Flip-Schema-Version` header. Breaking changes ship under a new version prefix;
      additive changes bump the dated `schema_version` in place.

      **2026-07-15** — additive: intent actions `lp-add`, `lp-remove`, `lp-collect`
      (Uniswap liquidity positions, full-range); chain `robinhood`; `/simulate` may
      now return 400 when a calldata-bearing step targets an address with no
      contract code (dead/self-destructed target detection). Doc correction:
      `SimulateRequest` now documents the live shape (`chain` + `sender` +
      `steps` — the previously published `steps`+`wallets` shape was never
      what the API accepted). Fix: `/steps/reencode` no longer fails
      same-chain swap re-quotes with LiFi error 1011; when a pinned route can
      no longer fill, reencode now retries once unpinned instead of hard-
      failing; responses carry `quoted_at`; default slippage is floored for
      small stable-input swaps ($1 → 3%, ≥$5 → unchanged 1%) so dust swaps
      stop missing their minimum output during the signing window.

      **2026-07-07** — initial beta contract.

paths:
  /intent:
    post:
      operationId: createIntent
      tags: [Planning]
      summary: Parse a natural-language prompt into a structured plan
      description: |
        Wraps the app's `/api/intent` planner — same model routing, tools, and
        clarification logic. Natural language in; a structured `intents[]` plan out
        (or `clarifications[]` when ambiguous). Costs 5 credits per delivered call.

        **Multi-turn:** pass prior turns in `conversation_context` — newline-joined
        `User: <text>` / `Flip: <text>` lines, most recent last, ~8 turns max — with
        the current message in `prompt` (see the `followUp` example).

        **Clarification round-trip:** when `clarifications[]` comes back, render the
        `options[]`; on pick, re-send with `prompt` = original prompt + `"\n\n"` +
        `Clarification — <question>: <picked label>` and `clarify_follow_up: true`
        so the planner acknowledges the answer and continues (see the
        `clarifyAnswer` example).

        Send `Accept: text/event-stream` to stream the build as Server-Sent Events;
        otherwise a single JSON body is returned once planning completes.
      parameters:
        - $ref: '#/components/parameters/AcceptStream'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IntentRequest'
            examples:
              idleYield:
                summary: Move idle USDC into the best stable yield
                value:
                  prompt: move my idle USDC into the best stable yield across my chains
                  wallets:
                    - address: "0x8f2a...d5"
                      chain: ethereum
              followUp:
                summary: A multi-turn follow-up carrying the prior turns
                value:
                  prompt: actually only move half of it
                  conversation_context: "User: move my idle USDC into the best stable yield across my chains\nFlip: You hold 12,400 USDC idle on Ethereum. Aave v3 on Base pays the best stable rate, so I'll bridge to Base and deposit."
                  wallets:
                    - address: "0x8f2a...d5"
                      chain: ethereum
              clarifyAnswer:
                summary: Re-send after the user picked a clarification option
                value:
                  prompt: "deploy my idle USDC\n\nClarification — Which wallet should fund this?: Main treasury (Ethereum)"
                  clarify_follow_up: true
                  wallets:
                    - address: "0x8f2a...d5"
                      chain: ethereum
      responses:
        '200':
          description: |
            The structured plan. With `Accept: text/event-stream` the body is an SSE
            stream (see the `text/event-stream` content below); otherwise a single
            JSON `IntentResponse`.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/IntentResponse'
              examples:
                plan:
                  summary: A two-step bridge-then-deposit plan
                  value:
                    reasoning: >-
                      You hold 12,400 USDC idle on Ethereum. Aave v3 on Base is paying
                      the best stable rate right now, so I'll bridge to Base and deposit.
                    suggested_name: Idle USDC → best stable yield
                    kind: transactional
                    intents:
                      - action: bridge
                        asset: USDC
                        from_chain: ethereum
                        to_chain: base
                        amount: max
                      - action: deposit
                        protocol: aave
                        asset: USDC
                        chain: base
                        amount: chained
                        pool_id: aave-v3-base-usdc
                        vault_address: "0xa238dd80..."
                    clarifications: []
                clarify:
                  summary: Ambiguous request → a clarification instead of intents
                  value:
                    reasoning: >-
                      You've got USDC ready to deploy. Pick which wallet should fund
                      this and I'll build the plan on the next pass.
                    suggested_name: Deploy idle USDC
                    kind: transactional
                    intents: []
                    clarifications:
                      - id: source_wallet
                        kind: wallet
                        question: Which wallet should fund this?
                        options:
                          - id: "0x8f2a...d5"
                            label: Main treasury (Ethereum)
                          - id: "0x41bc...9a"
                            label: Ops wallet (Base)
            text/event-stream:
              schema:
                $ref: '#/components/schemas/IntentStreamEvent'
              examples:
                stream:
                  summary: A trimmed SSE transcript
                  value: |
                    event: reasoning
                    data: {"text":"You hold 12,400 USDC idle on Ethereum."}

                    event: tool-start
                    data: {"name":"getYields"}

                    event: tool-end
                    data: {"name":"getYields"}

                    event: final
                    data: {"reasoning":"...","suggested_name":"Idle USDC → best stable yield","kind":"transactional","intents":[{"action":"bridge","asset":"USDC","from_chain":"ethereum","to_chain":"base","amount":"max"}],"clarifications":[]}
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/EngineError'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /plan:
    post:
      operationId: createPlan
      tags: [Planning]
      summary: Resolve intents into unsigned, ready-to-sign transactions
      description: |
        Wraps the app's `/api/intent-plan` resolver — trusted-vault gating, Pendle-PT
        expansion, route prefetch, feasibility checks, and the repair pass are all
        inherited. Takes the `intents[]` from `/intent` (or hand-built to the same
        shape) plus the signing `wallets[]` with their on-chain `balances`, and returns
        the ordered `steps[]` of unsigned transactions. Costs 5 credits per call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlanRequest'
      responses:
        '200':
          description: The ordered list of unsigned transaction steps.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlanResponse'
              examples:
                steps:
                  summary: Bridge + deposit, unsigned
                  value:
                    steps:
                      - order: 1
                        type: bridge
                        chain: ethereum
                        protocol: LiFi
                        action_type: Bridge
                        asset: USDC
                        amount: 12400
                        to: "0x1231deb6..."
                        data: "0x4630a0d8..."
                        value: "0"
                        description: Bridge 12,400 USDC Ethereum → Base via LiFi
                        wait_for_bridge: true
                      - order: 2
                        type: deposit
                        chain: base
                        protocol: aave
                        action_type: Deposit
                        asset: USDC
                        amount: 0
                        to: "0xa238dd80..."
                        data: "0x617ba037..."
                        value: "0"
                        description: Supply USDC to Aave v3 on Base
                        chained:
                          from_key: "0x8f2a...d5@base@USDC"
                          estimate_amount: 12391.4
                          min_output: 12300
                    summary:
                      total_after_fees: 12,391.4 USDC
                      bridge_count: 1
                      est_fee_usd: "3.10"
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/EngineError'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /steps/reencode:
    post:
      operationId: reencodeStep
      tags: [Execution]
      summary: Re-quote a single step at sign time
      description: |
        Wraps the app's `/api/re-encode-step`. Re-encodes one step against the
        real amount available at sign time — required for correct integrations:
        `chained` and `max` amounts are re-sized from live balances, and expiring
        routes (Jupiter blockhash, LiFi tool pinning) are refreshed so the user signs
        the same aggregator and fee structure they were quoted. Returns `409` with
        `error: route_expired` when a pinned route is gone and the step must be
        re-planned via `/plan`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReencodeRequest'
            examples:
              chainedDeposit:
                summary: Re-encode a chained deposit with the real bridged amount
                value:
                  step:
                    type: deposit
                    asset: USDC
                    chain: base
                    protocol: aave
                    to: "0xa238dd80..."
                    data: "0x617ba037..."
                  actual_amount: 12391.4
      responses:
        '200':
          description: The freshly encoded transaction data for the step.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReencodeResponse'
              examples:
                ok:
                  value:
                    ok: true
                    data: "0x617ba037...updated"
                    amount_raw: "12391400000"
                    value: "0"
                unsupported:
                  summary: Step kind can't be re-encoded — surface the reason and stop (hard-fail)
                  value:
                    ok: false
                    unsupported_reason: swap re-encode requires a fresh /plan
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '409':
          $ref: '#/components/responses/RouteExpired'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /simulate:
    post:
      operationId: simulateSteps
      tags: [Execution]
      summary: Dry-run steps against a fork before signing
      description: |
        Wraps the app's simulation layer (Tenderly for EVM, a Solana simulate for
        Solana). Executes the `steps[]` against a fork and returns per-step success,
        status, and gas so you can surface reverts and cost before anything is signed.
        Read-only — nothing is broadcast. Costs 5 credits per call.

        Calldata-bearing steps are pre-validated against the target address: a step
        that CALLS an address with no deployed code (dead or self-destructed
        contract) returns 400 before any simulation runs. Plain value transfers to
        EOAs are exempt and simulate normally.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SimulateRequest'
      responses:
        '200':
          description: Per-step simulation results plus an aggregate.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimulateResponse'
              examples:
                ok:
                  value:
                    success: true
                    gas_used: "246,000"
                    gas_price: "0.02 gwei"
                    gas_price_source: rpc
                    total_cost: "$0.02"
                    error: null
                    steps:
                      - success: true
                        status: "✅"
                        status_code: 200
                        action: Approve USDC for Kyberswap
                        action_type: Approve
                        asset: USDC
                        gas: "36,138"
                        error: null
                      - success: true
                        status: "✅"
                        status_code: 200
                        action: Swap 1 USDC for ETH
                        action_type: Swap
                        asset: USDC
                        gas: "209,862"
                        error: null
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '402':
          $ref: '#/components/responses/InsufficientCredits'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          $ref: '#/components/responses/EngineError'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /usage:
    get:
      operationId: getUsage
      tags: [Account]
      summary: Return the tenant's credit balance and recent ledger
      description: |
        The current per-tenant credit balance, the flat per-call cost, and the most
        recent metered calls. Reads are free (not metered). This is integrator/partner
        metering — for checking balances and debugging 402s in your own ops, not an
        end-user UI element. Don't render a credits balance or refill CTA in the
        end-user Compose chat.
      responses:
        '200':
          description: Balance and recent usage.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageResponse'
              examples:
                usage:
                  value:
                    balance: 4820
                    cost_per_call: 5
                    recent:
                      - kind: spend
                        amount: 5
                        reason: api_intent
                        at: 1751909204000
        '401':
          $ref: '#/components/responses/InvalidKey'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /meta:
    get:
      operationId: getMeta
      tags: [Account]
      summary: Discover schema version, supported actions, chains, and limits
      description: |
        Machine-readable capability discovery: the current schema version, the flat
        per-call credit cost, the supported intent actions, the supported chains, and
        the rate-limit tiers. Feature-detect from this rather than hard-coding. Free.
      responses:
        '200':
          description: Capability and version metadata.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetaResponse'
              examples:
                meta:
                  value:
                    schema_version: "2026-07-15"
                    cost_per_call: 5
                    actions:
                      - transfer
                      - sweep
                      - bridge
                      - deposit
                      - withdraw
                      - stake
                      - unstake
                      - claim
                      - approve
                      - swap
                      - borrow
                      - repay
                      - lp-add
                      - lp-remove
                      - lp-collect
                    chains:
                      - ethereum
                      - base
                      - arbitrum
                      - optimism
                      - polygon
                      - robinhood
                      - solana
                    rate_tiers:
                      beta:
                        max_requests: 30
                        window_ms: 60000
                      standard:
                        max_requests: 120
                        window_ms: 60000
                      high:
                        max_requests: 600
                        window_ms: 60000
        '401':
          $ref: '#/components/responses/InvalidKey'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /agents:
    post:
      operationId: createAgent
      tags: [Agents]
      summary: Create a agent — plain-English or typed
      description: |
        Send **either** a plain-English body `{ request, wallets }` **or** a typed body
        `{ kind, wallets, ... }` — never both, never neither (`400` otherwise). Not
        metered by credits; gated instead by a per-tenant active-agent quota (`429
        agent_quota_exceeded` once the tier cap is hit — beta 50 / standard 500 /
        high 5000).

        **Plain-English (`{ request, wallets }`)** runs the same compile pipeline the
        Flip app and Telegram bot use. Three outcomes:
        - A concrete, measurable condition → `201` with `agent` + `compile`.
        - A schedule phrase ("every day at 9am...", no measurable condition) → `201`,
          created as a `kind: schedule` agent.
        - Ambiguous → `200` with `status: needs_clarification`, a `question`, and up to
          4 `options`. Re-submit by sending `request` again with the picked option
          folded in: `"<original request>\n\nClarification — <question>: <picked
          label>"` — the same inline convention `/intent`'s clarification round-trip
          uses, just folded into the next `request` string (there's no multi-turn state
          to carry here).
        - Not a watchable condition, or refused by an internal match-gate (the compiled
          check doesn't faithfully match what was asked) → `422 unwatchable`.

        **Phase A is notify-only.** Even when the compiler recognizes a "when X, do Y"
        request with a real follow-on action, the agent is armed as notify-only and
        the action is dropped — action agents over the API are a later phase.

        **Typed (`{ kind, wallets, ... }`)** maps 1:1 onto the internal create paths for
        `price` / `yield` / `health` / `portfolio` / `schedule` — no compile step, no LLM
        call, always `201`. `kind: "condition"` is not a typed-create kind — it only
        results from the plain-English path.

        For `kind: "price"`, `coingecko_id` is required in the request — this endpoint
        does not resolve a ticker symbol to a CoinGecko id for you; look it up first
        (e.g. CoinGecko's `/coins/list`) and pass the id directly (e.g. `"ethereum"`,
        not `"ETH"`).

        Fires are delivered to any webhook endpoints you've registered
        (`POST /webhook-endpoints`) — recommended, so you don't have to poll. With none
        registered, a agent still arms, evaluates, and triggers normally; retrieve its
        triggers from `GET /agent-events` instead.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentCreateRequest'
            examples:
              plainEnglish:
                summary: Plain-English — a measurable price condition
                value:
                  request: notify me when ETH is above 5000
                  wallets: ["0x8f2a...d5"]
              plainEnglishSchedule:
                summary: Plain-English — a schedule phrase (no measurable condition)
                value:
                  request: check in on my portfolio every weekday morning at 9am
                  wallets: ["0x8f2a...d5"]
              typedPrice:
                summary: Typed — price above a threshold
                value:
                  kind: price
                  wallets: ["0x8f2a...d5"]
                  asset_symbol: ETH
                  coingecko_id: ethereum
                  condition: above
                  threshold: 5000
              typedHealth:
                summary: Typed — Aave health factor below a threshold
                value:
                  kind: health
                  wallets: ["0x8f2a...d5"]
                  protocol: aave
                  chain: base
                  threshold: 1.5
              typedSchedule:
                summary: Typed — a weekly check-in, explicit timezone
                value:
                  kind: schedule
                  wallets: ["0x8f2a...d5"]
                  cadence: weekly
                  hour: 9
                  minute: 0
                  day_of_week: 1
                  timezone: America/New_York
      responses:
        '200':
          description: Ambiguous plain-English request — resolve the clarification and re-submit.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NeedsClarificationResponse'
              examples:
                clarify:
                  value:
                    status: needs_clarification
                    question: "Which \"gas\" — Ethereum L1 gwei, or a specific chain?"
                    options:
                      - id: opt_0
                        label: Ethereum L1 gas (gwei)
                      - id: opt_1
                        label: Base gas (gwei)
        '201':
          description: The created agent.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentCreateResponse'
              examples:
                condition:
                  summary: Plain-English condition, compiled and armed
                  value:
                    agent:
                      id: w_condition_k17f2a9c
                      type: condition
                      kind: condition
                      status: active
                      created_at: 1753142400000
                      wallets: ["0x8f2a...d5"]
                      intent: notify me when ETH is above 5000
                      rendered: ETH price is above $5,000
                      cadence: hourly
                    compile:
                      rendered: ETH price is above $5,000
                      fire_headline: null
                      cadence: hourly
                typedPrice:
                  summary: Typed price agent
                  value:
                    agent:
                      id: w_alert_k9d81ab2
                      type: alert
                      kind: price
                      status: active
                      created_at: 1753142400000
                      wallets: ["0x8f2a...d5"]
                      condition: above
                      threshold: 5000
                      asset_symbol: ETH
                      coingecko_id: ethereum
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '422':
          $ref: '#/components/responses/Unwatchable'
        '429':
          description: Either the per-key rate limit or the active-agent quota was exceeded.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                rateLimited:
                  value:
                    error: rate_limited
                    friendly_message: Rate limit exceeded for this key. Slow down and retry.
                    issues: []
                quotaExceeded:
                  value:
                    error: agent_quota_exceeded
                    friendly_message: This tenant already has 50 active agents — the "beta" tier limit. Pause or delete one before creating another.
                    issues: []
        '503':
          $ref: '#/components/responses/NotConfigured'

    get:
      operationId: listAgents
      tags: [Agents]
      summary: List agents
      description: |
        Tenant-scoped, newest-first, offset-paginated. Both `status` and `kind` filter
        against the **public** vocabulary shown on the returned agent objects (not
        the internal per-table storage) — see the callouts on each parameter below.
      parameters:
        - name: status
          in: query
          schema:
            type: string
            enum: [active, paused]
          description: >-
            Filters on the public status (see the `Agent.status` schema note) —
            matches regardless of which internal table/kind a agent lives in, so
            `status=paused` correctly surfaces a paused agent of any kind, not just
            `schedule` ones.
        - name: kind
          in: query
          schema:
            type: string
            enum: [price, yield, health, aave-health, spark-health, portfolio, news, condition, schedule]
          description: >-
            `health` matches both `aave-health`- and `spark-health`-kind rows (the same
            aggregate value shown in each agent's own `kind` field) — this is the
            filter value to use. `aave-health`/`spark-health` are also accepted
            individually if you want just one protocol's health agents. `news`
            agents can appear in a listing but cannot be created through this API.
        - name: wallet
          in: query
          schema:
            type: string
          description: Scope to agents whose wallet set includes this address (case-insensitive).
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque pagination cursor from a previous response's `next_cursor`.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: A page of agents.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentListResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /agents/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        description: The public agent id, e.g. `w_condition_k17f2a9c`.
        example: w_condition_k17f2a9c
    get:
      operationId: getAgent
      tags: [Agents]
      summary: Get one agent
      responses:
        '200':
          description: The agent.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentGetResponse'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '404':
          $ref: '#/components/responses/AgentNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'
    patch:
      operationId: updateAgent
      tags: [Agents]
      summary: Pause/resume a agent, or recompile a condition agent's text
      description: |
        Send **either** `{ status }` **or** `{ request }`.

        `{ status: "paused" | "active" }` works identically on any agent kind — the
        public vocabulary is always `active`/`paused` regardless of what's stored
        internally per kind (see the `Agent.status` schema note). An id that's
        well-formed but doesn't resolve to a row you own (already deleted, or another
        tenant's) is `404`, not `500`.

        `{ request: "<new plain-English text>" }` only works on `kind: "condition"`
        agents (`400` on any other kind — delete and recreate a typed agent
        instead). Re-runs the same compile pipeline `POST /agents` uses and mirrors
        its outcomes 1:1: `ready` → `200 { agent }` (in place — same id, trigger history
        reset, matching the app's own edit-a-condition-agent behavior); ambiguous →
        `200 { status: "needs_clarification", ... }`; not watchable → `422 unwatchable`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AgentPatchRequest'
            examples:
              pause:
                summary: Pause a agent
                value: { status: paused }
              resume:
                summary: Resume a agent
                value: { status: active }
              recompile:
                summary: Recompile a condition agent's text
                value: { request: "notify me when ETH is above 6000 instead" }
      responses:
        '200':
          description: The updated agent, or a clarification if the new `request` text was ambiguous.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AgentGetResponse'
                  - $ref: '#/components/schemas/NeedsClarificationResponse'
        '400':
          $ref: '#/components/responses/InvalidRequest'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '404':
          $ref: '#/components/responses/AgentNotFound'
        '422':
          $ref: '#/components/responses/Unwatchable'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'
    delete:
      operationId: deleteAgent
      tags: [Agents]
      summary: Delete a agent
      description: |
        A structurally-invalid id (doesn't match the `w_<type>_<id>` shape) is `404`.

        Idempotent: a well-formed id whose row is already gone (e.g. a repeated
        delete, or a retry after a timed-out first attempt) returns `204` as a no-op —
        it never distinguishes "already gone" from "just deleted" in the response.
      responses:
        '204':
          description: Deleted, or already gone — idempotent either way.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '404':
          $ref: '#/components/responses/AgentNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /agents/{id}/test:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
        example: w_condition_k17f2a9c
    post:
      operationId: testFireAgent
      tags: [Agents]
      summary: Manually trigger a agent once, for integration testing
      description: |
        Queues a synthetic trigger through the exact same delivery pipeline a real trigger
        takes (webhook push + the `GET /agent-events` pull rail both see it),
        flagged `type: "agent.test"` — independent of whether the agent's real
        trigger is currently met. `202` because the event is queued into the same
        delivery sweep a real trigger uses, not returned inline; it typically lands
        within the next delivery-cron tick (every 2 minutes).
      responses:
        '202':
          description: The test trigger was queued.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentTestFireResponse'
              example:
                value:
                  ok: true
                  note: "test event will be delivered like a real trigger, flagged test:true"
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '404':
          $ref: '#/components/responses/AgentNotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /agent-events:
    get:
      operationId: listAgentEvents
      tags: [Agents]
      summary: List/replay trigger events — the pull rail
      description: |
        Tenant-scoped trigger history, newest-first, cursor-paginated on `triggered_at`.
        This is how a partner without a registered webhook endpoint reconciles triggers,
        and how any partner replays or backfills around a delivery gap. Delivery
        (both this rail and the webhook push rail below) is **at-least-once** — dedup
        on `id` regardless of which rail an event was read from.

        **Note:** this rail and the pushed webhook body (see the `webhooks` section)
        are built from the same event mapping — `id`, `type`, `agent`, and the set of
        `trigger` fields are identical for the same trigger on both rails. The one
        intentional difference is the timestamp: this rail returns `created_at` as an
        integer epoch-ms, while the pushed webhook body returns `created` as an
        ISO-8601 string, for the same instant. Both rails' `trigger` objects are
        `snake_case` (e.g. `value_at_trigger`), so a handler can parse either
        rail's event with the same code.
      parameters:
        - name: cursor
          in: query
          schema:
            type: string
          description: >-
            Opaque pagination cursor from a previous response's `next_cursor` (the
            exclusive-before `triggered_at` of the last item on that page). A garbage
            cursor is treated as absent (first page), not a `400`.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: A page of trigger events.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentEventListResponse'
              examples:
                events:
                  value:
                    events:
                      - id: evt_k7m2ab91
                        type: agent.triggered
                        created_at: 1753142400000
                        agent:
                          id: k17f2a9c
                          kind: condition
                        trigger:
                          headline: ETH crossed above $5,000
                          kind: condition
                          condition: above
                          threshold: 0
                          wallet: "0x8f2a...d5"
                    next_cursor: null
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /webhook-endpoints:
    post:
      operationId: createWebhookEndpoint
      tags: [Webhook delivery]
      summary: Register a webhook endpoint
      description: |
        `https://` only; the hostname can't be a raw private/loopback/link-local IP
        literal (including the `169.254.169.254` cloud metadata address),
        `localhost`/`*.localhost`, or `*.local`/`*.internal` — `400 invalid_url`
        otherwise. This is a hostname/IP-literal check only; a public hostname that
        later resolves to a private address is re-checked at every send, but not
        pinned against rebinding between check and connect.

        Maximum **5 endpoints per tenant** — exceeding it is `429
        endpoint_quota_exceeded`. Remove an old endpoint first if you're unsure how
        many you have registered (`GET /webhook-endpoints`).

        The response's `signing_secret` is shown **exactly once** — store it now. It
        is not derived from anything you can look up later; only its hash is kept
        server-side. See the `webhooks` section for how to verify it against inbound
        deliveries.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEndpointCreateRequest'
            examples:
              register:
                value:
                  url: https://example.com/webhooks/flip
      responses:
        '201':
          description: The registered endpoint and its signing secret (shown once).
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointCreateResponse'
              examples:
                created:
                  value:
                    endpoint:
                      id: k9c7f2ab
                      url: https://example.com/webhooks/flip
                    signing_secret: whs_3f9a7b2e1c4d8f6a0b5e9c2d7a1f4b8e6c0d3a9f7b2e5c1d8a4f0b6e3c9d7a2f
        '400':
          description: Malformed body, or a URL that fails the safety check.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                invalidRequest:
                  value:
                    error: invalid_request
                    friendly_message: "`url` is required."
                    issues:
                      - field: url
                        message: required string
                invalidUrl:
                  value:
                    error: invalid_url
                    friendly_message: private_ip
                    issues:
                      - field: url
                        message: private_ip
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '429':
          description: Either the per-key rate limit or the 5-endpoint-per-tenant cap was exceeded.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                rateLimited:
                  value:
                    error: rate_limited
                    friendly_message: Rate limit exceeded for this key. Slow down and retry.
                    issues: []
                quotaExceeded:
                  value:
                    error: endpoint_quota_exceeded
                    friendly_message: This tenant already has 5 webhook endpoints registered — the maximum. Remove one before registering another.
                    issues: []
        '503':
          $ref: '#/components/responses/NotConfigured'

    get:
      operationId: listWebhookEndpoints
      tags: [Webhook delivery]
      summary: List registered webhook endpoints
      description: Never includes secrets — only the `POST` response ever carries the raw `signing_secret`.
      responses:
        '200':
          description: The tenant's registered endpoints.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookEndpointListResponse'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

  /webhook-endpoints/{id}:
    delete:
      operationId: deleteWebhookEndpoint
      tags: [Webhook delivery]
      summary: Remove a webhook endpoint
      description: |
        Idempotent: an id that doesn't resolve to a row you own (already removed, or
        another tenant's) still returns `204` as a no-op.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          example: k9c7f2ab
      responses:
        '204':
          description: Removed, or already gone — idempotent either way.
          headers:
            Flip-Schema-Version:
              $ref: '#/components/headers/FlipSchemaVersion'
        '401':
          $ref: '#/components/responses/InvalidKey'
        '403':
          $ref: '#/components/responses/ScopeForbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/NotConfigured'

webhooks:
  agentFired:
    post:
      operationId: agentFiredWebhook
      tags: [Webhook delivery]
      summary: A agent trigger pushed to your registered endpoint
      description: |
        Sent to every `active` endpoint registered for your tenant whenever a agent
        of yours triggers (including test triggers from `POST /agents/{id}/test`, which
        carry `type: "agent.test"`). Delivery is **at-least-once** — dedup on the
        payload's `id` (identical to the `X-Flip-Event-Id` header), never assume
        single delivery.

        **Acknowledge with any `2xx` status within 10 seconds.** Anything else —
        non-2xx, a timeout, or a redirect (redirects are never followed) — counts as a
        failed attempt and is retried with backoff: **1m → 5m → 30m → 2h → 12h** after
        attempts 1 through 5, then the delivery is **dead-lettered** (no further
        retries for that specific trigger). An endpoint that racks up 10 consecutive
        dead-lettered deliveries is auto-disabled (`GET /webhook-endpoints` will show
        `status: "disabled"`) — re-register or fix your receiver and the next trigger
        goes to a healthy endpoint again.

        **Structurally identical to the `GET /agent-events` pull-rail's event shape
        for the same trigger** — `id`, `type`, `agent`, and the set of `trigger` fields all
        come from the same event mapping. Two differences to know about:
        - The timestamp here is `created` (an ISO-8601 string); the pull rail's
          equivalent is `created_at` (an integer epoch-ms) — same instant, different
          field name and format.
        - Both rails emit `snake_case` keys inside `trigger` (e.g. `value_at_trigger`),
          matching every other public object in this API.

        In both rails, `agent.id` is the SAME public `w_<type>_<id>` value returned
        by create/list/get — pass it straight back to `/agents/{id}`,
        `/agents/{id}/test`, `PATCH` or `DELETE` without rebuilding it. Note the
        prefix is the storage `type` (alert | condition | reminder), NOT the public
        `kind`: a `kind: "price"` agent is `w_alert_...`, so `kind` alone is not
        enough to construct the id — which is why the event carries it. It is identical
        across both rails for the same trigger, including manual test-triggers
        (`POST /agents/{id}/test`) of every kind.
      parameters:
        - name: X-Flip-Event-Id
          in: header
          required: true
          schema:
            type: string
          description: Identical to the payload's `id`. The stable dedup key across retries and across both delivery rails.
          example: evt_k7m2ab91
        - name: X-Flip-Signature
          in: header
          required: true
          schema:
            type: string
          description: |
            `t=<unix_seconds>,v1=<hex HMAC-SHA256(signing_secret, "<t>.<raw_body>")>` —
            the Stripe signed-webhooks convention. Verify with a constant-time
            comparison against the raw (unparsed) request body, and reject timestamps
            outside a ~5 minute tolerance. Node reference implementation:

            ```js
            const crypto = require("crypto");

            function verifyFlipSignature(secret, header, rawBody, toleranceSec = 300) {
              const parts = Object.fromEntries(
                header.split(",").map((kv) => {
                  const i = kv.indexOf("=");
                  return [kv.slice(0, i).trim(), kv.slice(i + 1).trim()];
                }),
              );
              const { t, v1 } = parts;
              if (!t || !v1) return false;
              if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;

              const expected = crypto
                .createHmac("sha256", secret)
                .update(`${t}.${rawBody}`)
                .digest("hex");

              const expectedBuf = Buffer.from(expected, "hex");
              const gotBuf = Buffer.from(v1, "hex");
              if (expectedBuf.length !== gotBuf.length) return false;
              return crypto.timingSafeEqual(expectedBuf, gotBuf);
            }
            ```
          example: "t=1753142400,v1=5f6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e3c9d7a2f0b6e"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEventPayload'
            examples:
              triggered:
                value:
                  id: evt_k7m2ab91
                  type: agent.triggered
                  created: "2026-07-30T18:40:00.000Z"
                  agent:
                    id: k17f2a9c
                    kind: condition
                  trigger:
                    headline: ETH crossed above $5,000
                    kind: condition
                    condition: above
                    threshold: 0
                    wallet: "0x8f2a...d5"
              test:
                value:
                  id: evt_k7m2ac02
                  type: agent.test
                  created: "2026-07-30T18:41:10.000Z"
                  agent:
                    id: k9d81ab2
                    kind: price
                  trigger:
                    headline: "⚠️ ETH price above $5,000"
                    kind: price
                    condition: above
                    threshold: 5000
                    wallet: "0x8f2a...d5"
      responses:
        '200':
          description: >-
            Any `2xx` within 10 seconds acknowledges the delivery; the response body is
            ignored.

components:
  securitySchemes:
    PartnerKey:
      type: http
      scheme: bearer
      bearerFormat: fk_...
      description: |
        A partner API key, presented as `Authorization: Bearer fk_...`. Keys are
        scoped to a partner tenant with a rate-limit tier and authorize metered
        spend — keep them server-side. Request early-access keys via fliplabs.ai.

  headers:
    FlipSchemaVersion:
      description: The public contract version this response was built against.
      schema:
        type: string
        example: "2026-07-15"

  parameters:
    AcceptStream:
      name: Accept
      in: header
      required: false
      description: >-
        Set to `text/event-stream` to receive the plan as Server-Sent Events.
        Any other value (or omitted) yields a single buffered JSON response.
      schema:
        type: string
        enum:
          - application/json
          - text/event-stream
        default: application/json

  responses:
    # Every endpoint can return this. It was reachable from day one and
    # undocumented until 2026-08-11, so a client had no code to branch on when
    # something failed on our side rather than theirs.
    InternalError:
      description: >-
        Something failed on Flip's side. The request was well-formed; retrying is
        reasonable, and a persistent `internal_error` is worth reporting.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: internal_error
            friendly_message: Couldn't complete that right now — try again shortly.
            issues: []
    InvalidPatch:
      description: >-
        The patch is well-formed but not applicable to this agent. Today that means
        a `request` edit on a typed agent: only plain-English condition agents can be
        recompiled from a new request — delete and recreate a typed one instead.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: invalid_patch
            friendly_message: >-
              `request` edits are only supported for plain-English condition agents.
            issues: []
    InvalidRequest:
      description: Malformed body, or an unsupported chain/asset. `issues[]` lists the offending fields.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: invalid_request
            friendly_message: "`prompt` is required."
            issues:
              - field: prompt
                message: required string
    InvalidKey:
      description: Missing, malformed, revoked, or invalid API key.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: invalid_key
            friendly_message: "Missing or malformed API key. Pass it as `Authorization: Bearer fk_...`."
            issues: []
    InsufficientCredits:
      description: The tenant's credit balance is below the call cost.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: insufficient_credits
            friendly_message: This call costs 5 credits; the tenant balance is 3. Top up to continue.
            issues: []
    ScopeForbidden:
      description: The key is not authorized for the requested scope.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: scope_forbidden
            friendly_message: This key is not authorized for "intent".
            issues: []
    RouteExpired:
      description: A pinned route is no longer available; re-plan the affected step via `/plan`.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: route_expired
            friendly_message: This route just expired. Re-plan the step and try again.
            issues: []
    RateLimited:
      description: Per-key rate-limit tier exceeded. Back off and retry.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: rate_limited
            friendly_message: Rate limit exceeded for this key. Slow down and retry.
            issues: []
    EngineError:
      description: The upstream planning/simulation engine was unreachable or errored.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: engine_error
            friendly_message: The planning engine returned an error.
            issues: []
    NotConfigured:
      description: >-
        The API is temporarily unavailable. `not_configured` means the service is
        not set up; `compile_unavailable` means the plain-English compiler is
        transiently down and the call is worth retrying in a few seconds — typed
        agent creation is unaffected either way.
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            notConfigured:
              value:
                error: not_configured
                friendly_message: The API is not available right now.
                issues: []
            compileUnavailable:
              value:
                error: compile_unavailable
                friendly_message: >-
                  Couldn't compile that request right now — this is transient,
                  retry in a few seconds. Typed agent creation is unaffected.
                issues: []
    Unwatchable:
      description: >-
        Not a watchable condition, or the compiled check was refused by an internal
        match-gate (it doesn't faithfully match what was asked).
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: unwatchable
            friendly_message: >-
              That isn't a watchable condition — try a measurable trigger like
              "notify me when ETH is above 5000".
            issues: []
    AgentNotFound:
      description: No agent with that id (a structurally-invalid id, or a real id this tenant doesn't own).
      headers:
        Flip-Schema-Version:
          $ref: '#/components/headers/FlipSchemaVersion'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error: agent_not_found
            friendly_message: No agent with that id.
            issues: []

  schemas:
    Error:
      type: object
      description: The single error envelope every 4xx/5xx response uses.
      required: [error, friendly_message, issues]
      properties:
        error:
          type: string
          description: A stable machine-readable error code.
          examples: [invalid_request, invalid_key, insufficient_credits, route_expired, rate_limited]
        friendly_message:
          type: string
          description: A human-readable, user-safe explanation.
        issues:
          type: array
          description: Structured field-level problems, where relevant. Often empty.
          items:
            $ref: '#/components/schemas/Issue'
      additionalProperties: false

    Issue:
      type: object
      required: [message]
      properties:
        field:
          type: string
          description: The offending request field, if the problem is field-specific.
        message:
          type: string
          description: What is wrong with that field.
      additionalProperties: false

    Wallet:
      type: object
      description: A wallet in play. The planner prefers these for sourcing and destinations.
      required: [address, chain]
      properties:
        address:
          type: string
          description: EVM 0x address or Solana base58 address (never lower-cased).
          example: "0x8f2a...d5"
        chain:
          type: string
          description: Chain key, e.g. ethereum, base, arbitrum, optimism, polygon, solana.
          example: ethereum
        label:
          type: string
          description: Optional human label surfaced in reasoning.
        balances:
          type: array
          description: Optional on-chain holdings for this wallet, read by the feasibility gate in `/plan`.
          items:
            $ref: '#/components/schemas/TokenBalance'
      additionalProperties: true

    TokenBalance:
      type: object
      required: [symbol, amount]
      properties:
        symbol:
          type: string
          example: USDC
        amount:
          type: number
          description: Token-denominated amount (not wei).
          example: 12400
        usd_value:
          type: number
        chain:
          type: string
          description: Per-token chain, so summed-across-chains balances aren't mis-attributed.
      additionalProperties: true

    Balance:
      type: object
      description: Pre-fetched holdings for a wallet so the planner sizes without a lookup.
      required: [address, chain]
      properties:
        address:
          type: string
        chain:
          type: string
        eth_balance:
          type: number
        usdc_balance:
          type: number
        stable_usd:
          type: number
        total_usd:
          type: number
        tokens:
          type: array
          items:
            $ref: '#/components/schemas/TokenBalance'
      additionalProperties: true

    IntentRequest:
      type: object
      required: [prompt, wallets]
      properties:
        prompt:
          type: string
          minLength: 1
          maxLength: 2000
          description: The desired outcome in plain English.
          example: move my idle USDC into the best stable yield across my chains
        wallets:
          type: array
          minItems: 1
          description: The wallets in play — at least one.
          items:
            $ref: '#/components/schemas/Wallet'
        balances:
          type: array
          description: Optional pre-fetched holdings. Omit and Flip fetches them.
          items:
            $ref: '#/components/schemas/Balance'
        conversation_context:
          type: string
          maxLength: 6000
          description: |
            Prior turns of the conversation, for multi-turn planning: the prior turns
            newline-joined, one turn per line, each line `User: <text>` or
            `Flip: <text>`, most recent last, ~8 turns max. The current message goes
            in `prompt`, never in this block. Truncated at 6000 characters.
          example: "User: move my idle USDC into the best stable yield\nFlip: You hold 12,400 USDC idle on Ethereum. Aave v3 on Base pays the best stable rate, so I'll bridge to Base and deposit."
        clarify_follow_up:
          type: boolean
          description: |
            Set `true` when re-sending after the user picked a clarification option.
            The re-send pattern: `prompt` = the original prompt + `"\n\n"` + the
            plain-text answer block `Clarification — <question>: <picked label>`.
            The flag makes the planner acknowledge the answer and continue planning —
            it never re-raises the same clarification.
        options:
          type: object
          description: Coarse run options. Raw model ids are never exposed.
          properties:
            stream:
              type: boolean
              description: "Stream the build as SSE (equivalent to `Accept: text/event-stream`)."
            tier:
              type: string
              enum: [fast, quality]
              description: Coarse latency/quality preference.
          additionalProperties: false
      additionalProperties: false

    Intent:
      type: object
      description: |
        One planned action — *what* to do, not yet *how*. `action` selects which of the
        other fields apply. `amount` is a token-denominated number, or the string `max`
        (use the full available balance) or `chained` (size from the previous step's real
        output at sign time). Common per-action fields:
        transfer/stake/unstake/deposit/withdraw/borrow/repay use `asset`, `chain`, `from`,
        `amount`; `bridge` uses `from_chain`/`to_chain` (+ optional `to_asset`); `swap`
        uses `from_asset`/`to_asset` (+ optional `to_chain`); `deposit`/`withdraw` add
        `protocol`, `pool_id`, `vault_address`; `sweep` uses `sources[]` + `destination`;
        `lp-add` uses `token0`/`token1`, `fee_tier` (Uniswap units, e.g. 3000 = 0.3%),
        `amount0`/`amount1`, optional `slippage_bps` — full-range positions only;
        `lp-remove`/`lp-collect` reference the position by pair + chain.
      required: [action]
      properties:
        action:
          type: string
          enum:
            - transfer
            - sweep
            - bridge
            - deposit
            - withdraw
            - stake
            - unstake
            - claim
            - approve
            - swap
            - borrow
            - repay
            - lp-add
            - lp-remove
            - lp-collect
          description: The kind of on-chain action.
        asset:
          type: string
          description: The token symbol acted on.
          example: USDC
        chain:
          type: string
          description: The chain the action happens on.
          example: base
        from_chain:
          type: string
          description: Source chain (bridge; or cross-chain swap).
        to_chain:
          type: string
          description: Destination chain (bridge; or cross-chain swap).
        from_asset:
          type: string
          description: Source token (swap).
        to_asset:
          type: string
          description: Destination token (swap; or destination-side conversion on a bridge).
        from:
          type: string
          description: The source wallet address.
        to:
          type: string
          description: The destination wallet address (transfer).
        spender:
          type: string
          description: The contract being granted allowance (approve).
        protocol:
          type: string
          description: Protocol key for deposit/withdraw/claim/borrow/repay, e.g. aave, morpho.
        pool_id:
          type: string
          description: Catalog pool id for the target market.
        vault_address:
          type: string
          description: The vault/market contract address.
        amount:
          $ref: '#/components/schemas/Amount'
        min_amount:
          type: number
          description: Floor amount for a sweep source to be included.
        slippage_bps:
          type: integer
          minimum: 1
          maximum: 5000
          description: Slippage tolerance in basis points (swap/bridge).
        sources:
          type: array
          description: Sweep sources — each `{ chain, wallet }`.
          items:
            type: object
            properties:
              chain: { type: string }
              wallet: { type: string }
        destination:
          type: object
          description: Sweep destination — `{ chain, wallet }`.
          properties:
            chain: { type: string }
            wallet: { type: string }
      additionalProperties: true

    Amount:
      description: A token-denominated number, or the literal `max` or `chained`.
      oneOf:
        - type: number
        - type: string
          enum: [max, chained]
      example: max

    Clarification:
      type: object
      description: A blocking question the caller must resolve before a plan can be built.
      required: [question, options]
      properties:
        id:
          type: string
          description: Stable identifier for the clarification.
        kind:
          type: string
          enum: [chain, asset, wallet, amount, other]
          description: Drives a typed picker in the UI.
        question:
          type: string
          description: The question to put to the user.
        options:
          type: array
          items:
            $ref: '#/components/schemas/ClarificationOption'
      additionalProperties: true

    ClarificationOption:
      type: object
      required: [id, label]
      properties:
        id:
          type: string
        label:
          type: string
        value:
          type: string
          description: Optional machine value to fold back into the re-POSTed prompt.
      additionalProperties: true

    IntentResponse:
      type: object
      description: The buffered result of `/intent`. Either `intents` or `clarifications` is populated.
      required: [reasoning, intents]
      properties:
        reasoning:
          type: string
          description: Plain-prose explanation of the plan, written for the end user.
        suggested_name:
          type: string
          description: A short template name for the workflow. Empty for informational answers.
        kind:
          type: string
          enum: [transactional, informational]
          description: Whether the prompt produced a transaction plan or a plain answer.
        intents:
          type: array
          description: The structured actions to resolve via `/plan`. Empty when clarifications are raised.
          items:
            $ref: '#/components/schemas/Intent'
        clarifications:
          type: array
          description: Blocking questions when the request is ambiguous. Empty when `intents` is populated.
          items:
            $ref: '#/components/schemas/Clarification'
        dropped:
          type: array
          description: Intents the engine could not fulfill, with a reason. Informational.
          items:
            type: object
            additionalProperties: true
      additionalProperties: true

    IntentStreamEvent:
      type: object
      description: |
        One Server-Sent Event on the `/intent` stream. The wire format is
        `event: <name>\ndata: <json>\n\n`. Event names: `delta` and `reasoning`
        (`{ text }` prose fragments), `tool-start` / `tool-end` (`{ name }`), and a
        terminal `final` carrying the full `IntentResponse`.
      properties:
        event:
          type: string
          enum: [delta, reasoning, tool-start, tool-end, final]
        data:
          description: The JSON payload for the event (shape depends on `event`).
          oneOf:
            - type: object
              properties:
                text: { type: string }
            - type: object
              properties:
                name: { type: string }
            - $ref: '#/components/schemas/IntentResponse'
      additionalProperties: true

    PlanRequest:
      type: object
      required: [intents, wallets]
      properties:
        intents:
          type: array
          minItems: 1
          description: The `intents[]` from `/intent` (or hand-built to the same shape).
          items:
            $ref: '#/components/schemas/Intent'
        wallets:
          type: array
          minItems: 1
          description: Signing wallets, each ideally carrying the on-chain `balances` the feasibility gate reads.
          items:
            $ref: '#/components/schemas/Wallet'
      additionalProperties: false

    Step:
      type: object
      description: |
        One unsigned transaction. EVM steps carry `to`/`data`/`value` calldata; Solana
        steps carry a base64 `solana_tx` (+ `solana_format`) instead. A step whose
        `amount` is `chained` is sized from the previous step's real output at sign time
        — re-encode it via `/steps/reencode` before signing.
      required: [order, type, chain, to, data, value]
      properties:
        order:
          type: integer
          description: 1-based execution order.
        type:
          type: string
          enum: [transfer, bridge, approve, deposit, withdraw, stake, unstake, claim, swap, borrow, repay, wrap]
        chain:
          type: string
        chain_id:
          type: integer
        protocol:
          type: string
        action_type:
          type: string
          description: Human-facing action label, e.g. "Bridge", "Deposit".
        asset:
          type: string
        from_chain:
          type: string
        to_chain:
          type: string
        amount:
          type: number
          description: Token-denominated amount. 0 for a chained step until re-encoded.
        amount_raw:
          type: string
          description: Base-unit (wei / smallest-unit) amount as a decimal string.
        to:
          type: string
          description: EVM target contract address. `0x` on Solana steps.
        data:
          type: string
          description: EVM calldata. `0x` on Solana steps.
        value:
          type: string
          description: Native value in wei as a decimal string.
        description:
          type: string
        estimated_gas:
          type: string
        bridge_fee:
          type: number
        bridge_time:
          type: string
        wait_for_bridge:
          type: boolean
          description: True when this step must wait for a preceding bridge mint to land.
        chained:
          type: object
          description: Present when the amount was chained from an earlier producer step.
          properties:
            from_key:
              type: string
              description: The `${wallet}@${chain}@${asset}` of the producer output.
            estimate_amount:
              type: number
            min_output:
              type: number
          additionalProperties: true
        pool_id:
          type: string
        solana_tx:
          type: string
          description: Base64 Solana transaction. Only set when `chain` is solana.
        solana_format:
          type: string
          enum: [legacy, v0]
        solana_error:
          type: string
        slippage:
          type: number
        fee_bps:
          type: number
        swap_tool:
          type: string
          description: Pinned aggregator tool key (e.g. sushiswap-v3) for correct re-quote.
        error:
          type: string
          description: Set when the planner couldn't build this step.
        success:
          type: boolean
      additionalProperties: true

    PlanResponse:
      type: object
      required: [steps]
      properties:
        steps:
          type: array
          items:
            $ref: '#/components/schemas/Step'
        summary:
          $ref: '#/components/schemas/PlanSummary'
      additionalProperties: true

    PlanSummary:
      type: object
      properties:
        total_after_fees:
          type: string
          example: 12,391.4 USDC
        bridge_count:
          type: integer
        est_fee_usd:
          type: string
          example: "3.10"
      additionalProperties: true

    ReencodeRequest:
      type: object
      required: [step, actual_amount]
      properties:
        step:
          $ref: '#/components/schemas/Step'
        actual_amount:
          type: number
          description: The real amount, in token units (not wei), to re-encode the step for.
          example: 12391.4
      additionalProperties: false

    ReencodeResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
        data:
          type: string
          description: Freshly encoded EVM calldata.
        amount_raw:
          type: string
        value:
          type: string
        solana_tx:
          type: string
          description: Re-built base64 Solana transaction (Solana steps).
        solana_format:
          type: string
          enum: [legacy, v0]
        unsupported_reason:
          type: string
          description: "Set with `ok: false` when the step can't be re-encoded. HARD-FAIL: never fall back to signing plan-time calldata — it embeds quote-time amounts and deadlines. Show this reason in the UI and stop (re-plan to continue)."
        quoted_at:
          type: string
          format: date-time
          description: >-
            When this quote was priced. Quotes are perishable — the embedded
            minimum output is set at quote time, so open the wallet
            immediately and call reencode again if the user idles (~30s+).
      additionalProperties: true

    SimulateRequest:
      type: object
      required: [chain, sender, steps]
      properties:
        chain:
          type: string
          description: >-
            The chain the steps execute on — one simulate call per chain.
            EVM slugs dispatch to the Tenderly fork; `solana` dispatches to
            the Solana simulator.
          enum: [ethereum, base, arbitrum, optimism, polygon, solana]
          example: arbitrum
        sender:
          type: string
          description: >-
            The address the simulation runs FROM (your user's signing
            wallet). EVM: required 0x-hex. Solana: applied to any step
            missing its own `wallet`.
          example: "0x0036534C48a754163F0f446601f36BCfdA840f57"
        steps:
          type: array
          minItems: 1
          description: The `steps[]` from `/plan` to dry-run.
          items:
            $ref: '#/components/schemas/Step'
      additionalProperties: false

    SimulateResponse:
      type: object
      required: [success, steps]
      description: >-
        The LIVE response contract as returned by the simulation engine
        (documented from real responses, 2026-07-15 — earlier spec versions
        published a smaller shape that never matched the wire).
      properties:
        success:
          type: boolean
          description: True when every step simulated successfully.
        gas_used:
          type: string
          description: Total gas across steps, comma-formatted (e.g. "608,282").
          example: "608,282"
        gas_price:
          type: string
          description: Gas price used for the $ estimate (e.g. "0.02 gwei").
          example: "0.02 gwei"
        gas_price_source:
          type: string
          description: >-
            "rpc" when the price came from a live per-chain RPC read;
            "fallback" when the hardcoded last-resort price was used ($ figures
            are approximate in that case).
          enum: [rpc, fallback]
        total_cost:
          type: string
          description: Estimated total fee in USD (e.g. "$0.02").
          example: "$0.02"
        error:
          type: string
          nullable: true
          description: >-
            Human-readable revert reason when the batch failed (e.g.
            "Return amount is not enough", "ERC20: transfer amount exceeds
            balance") — safe to show directly in a UI.
        steps:
          type: array
          items:
            $ref: '#/components/schemas/SimulateStepResult'
      additionalProperties: true

    SimulateStepResult:
      type: object
      required: [success]
      properties:
        success:
          type: boolean
        status:
          type: string
          description: Emoji status marker ("✅" / "❌") — display convenience only.
        status_code:
          type: integer
        action:
          type: string
          description: Human label for the step (e.g. "Approve USDC for Kyberswap").
        action_type:
          type: string
          description: Step category (e.g. "Approve", "Transfer", "Swap").
        protocol:
          type: string
        asset:
          type: string
        gas:
          type: string
          description: Per-step gas, comma-formatted string.
        error:
          type: string
          nullable: true
          description: Human-readable revert reason for THIS step — show it in the chat card before opening the wallet.
        asset_changes:
          type: array
          description: Balance changes observed in the fork (may be empty).
          items:
            type: object
            additionalProperties: true
      additionalProperties: true

    UsageResponse:
      type: object
      required: [balance, cost_per_call, recent]
      properties:
        balance:
          type: integer
          description: Current per-tenant credit balance.
        cost_per_call:
          type: integer
          description: Flat credit cost per metered call.
          example: 5
        recent:
          type: array
          items:
            $ref: '#/components/schemas/UsageEntry'
      additionalProperties: false

    UsageEntry:
      type: object
      properties:
        kind:
          type: string
          enum: [spend, grant, refund]
        amount:
          type: integer
        reason:
          type: string
          example: api_intent
        at:
          type: integer
          description: Unix epoch milliseconds.
      additionalProperties: true

    MetaResponse:
      type: object
      required: [schema_version, cost_per_call, actions, chains]
      properties:
        schema_version:
          type: string
          example: "2026-07-15"
        cost_per_call:
          type: integer
          example: 5
        actions:
          type: array
          description: The supported intent actions.
          items:
            type: string
        chains:
          type: array
          description: The supported chains.
          items:
            type: string
        rate_tiers:
          type: object
          description: The per-key rate-limit tiers.
          additionalProperties:
            type: object
            properties:
              max_requests:
                type: integer
              window_ms:
                type: integer
      additionalProperties: true

    AgentWallets:
      type: array
      minItems: 1
      maxItems: 10
      description: >-
        EVM `0x` + 40 hex, or Solana base58 (32-44 chars). Case is preserved on the way
        in/out for every kind, including `health` (Solana addresses are
        case-sensitive).
      items:
        type: string
      example: ["0x8f2a...d5"]

    PlainEnglishAgentCreate:
      type: object
      required: [request, wallets]
      properties:
        request:
          type: string
          minLength: 3
          maxLength: 500
          example: notify me when ETH is above 5000
        wallets:
          $ref: '#/components/schemas/AgentWallets'
      additionalProperties: false

    PriceAgentCreate:
      type: object
      description: Typed create — price.
      required: [kind, wallets, asset_symbol, coingecko_id, condition, threshold]
      properties:
        kind:
          type: string
          enum: [price]
        wallets:
          $ref: '#/components/schemas/AgentWallets'
        asset_symbol:
          type: string
          maxLength: 20
          example: ETH
        coingecko_id:
          type: string
          maxLength: 100
          description: The literal CoinGecko id (e.g. `ethereum`) — not resolved from `asset_symbol` server-side.
          example: ethereum
        condition:
          type: string
          enum: [above, below]
        threshold:
          type: number
          exclusiveMinimum: 0
          example: 5000
        pct_change:
          type: number
          exclusiveMinimum: 0
          description: Optional — trigger on a percent move instead of an absolute threshold cross.
        base_price:
          type: number
          exclusiveMinimum: 0
          description: Reference price `pct_change` is measured from, when set.
      additionalProperties: false

    YieldAgentCreate:
      type: object
      description: Typed create — yield.
      required: [kind, wallets, pool_id, protocol, chain, asset, condition, threshold]
      properties:
        kind:
          type: string
          enum: [yield]
        wallets:
          $ref: '#/components/schemas/AgentWallets'
        pool_id:
          type: string
          maxLength: 200
          description: Catalog pool id for the target market.
        protocol:
          type: string
          maxLength: 60
        chain:
          type: string
          maxLength: 40
        asset:
          type: string
          maxLength: 20
        metric:
          type: string
          enum: [apy, supplyApy, borrowApy]
          default: apy
        condition:
          type: string
          enum: [above, below]
        threshold:
          type: number
          minimum: 0
      additionalProperties: false

    HealthAgentCreate:
      type: object
      description: >-
        Typed create — Aave/Spark health factor. `threshold` is a decimal health
        factor (e.g. `1.5`), not a percent — values `>= 100` are rejected as a likely
        mistake.
      required: [kind, wallets, protocol, chain, threshold]
      properties:
        kind:
          type: string
          enum: [health]
        wallets:
          $ref: '#/components/schemas/AgentWallets'
        target_wallet:
          type: string
          description: The address to watch, if different from `wallets[0]` (the owner).
        protocol:
          type: string
          enum: [aave, spark]
        chain:
          type: string
          maxLength: 40
        threshold:
          type: number
          exclusiveMinimum: 0
          exclusiveMaximum: 100
          example: 1.5
      additionalProperties: false

    PortfolioAgentCreate:
      type: object
      description: >-
        Typed create — portfolio move. Always triggers on an "above" move of `threshold`
        percent; there is no "below" direction for this kind.
      required: [kind, wallets, threshold]
      properties:
        kind:
          type: string
          enum: [portfolio]
        wallets:
          $ref: '#/components/schemas/AgentWallets'
        threshold:
          type: number
          exclusiveMinimum: 0
          description: Percent move, absolute.
        asset_symbol:
          type: string
          maxLength: 20
          description: Specific symbol to watch, or omit for the whole portfolio.
        movement_window:
          type: string
          enum: ["24h", "7d"]
          default: "24h"
      additionalProperties: false

    ScheduleAgentCreate:
      type: object
      description: >-
        Typed create — a recurring notify-only check-in. Unlike the plain-English
        schedule path (which anchors to UTC noon), this variant takes an explicit
        `timezone`.
      required: [kind, wallets, cadence, hour, minute, timezone]
      properties:
        kind:
          type: string
          enum: [schedule]
        wallets:
          $ref: '#/components/schemas/AgentWallets'
        cadence:
          type: string
          enum: [daily, weekly, monthly]
        hour:
          type: integer
          minimum: 0
          maximum: 23
          description: Local hour, per `timezone`.
        minute:
          type: integer
          minimum: 0
          maximum: 59
        day_of_week:
          type: integer
          minimum: 0
          maximum: 6
          description: "Required for `cadence: weekly` (0 = Sunday … 6 = Saturday)."
        day_of_month:
          type: integer
          minimum: 1
          maximum: 31
          description: "Required for `cadence: monthly` (clamped to the month's length)."
        timezone:
          type: string
          description: IANA timezone, e.g. `America/New_York`.
        label:
          type: string
          description: Optional display name. Defaults to "Portfolio check-in".
      additionalProperties: false

    AgentCreateRequest:
      description: >-
        Either the plain-English shape, or one of the five typed-kind shapes
        (discriminated by `kind`). Never both, never neither.
      oneOf:
        - $ref: '#/components/schemas/PlainEnglishAgentCreate'
        - $ref: '#/components/schemas/PriceAgentCreate'
        - $ref: '#/components/schemas/YieldAgentCreate'
        - $ref: '#/components/schemas/HealthAgentCreate'
        - $ref: '#/components/schemas/PortfolioAgentCreate'
        - $ref: '#/components/schemas/ScheduleAgentCreate'

    Agent:
      type: object
      description: |
        The normalized shape for every agent kind — fields present depend on `kind`;
        absent fields are simply omitted (this is one flexible shape, not a oneOf,
        matching how `Step` is documented elsewhere in this spec).

        `status` is always one of exactly three public values, regardless of which
        internal table a agent lives in: `active`, `paused` (either one settable via
        `PATCH .../{id}`), or `degraded` — `condition` agents only, set by the cron
        when the compiled check keeps returning "unknown" (a read-only state, never
        partner-set; resolves back to `active` once the check succeeds again). A
        `price`/`yield`/`health`/`portfolio` agent that already triggered once and
        re-armed still shows `active`, never a separate "triggered" state.
      required: [id, type, kind, status, wallets]
      properties:
        id:
          type: string
          description: "`w_<type>_<internal id>` — use this verbatim in `/agents/{id}` calls."
          example: w_condition_k17f2a9c
        type:
          type: string
          enum: [alert, condition, reminder]
          description: The underlying storage table. Redundant with the id's own prefix; exposed for convenience.
        kind:
          type: string
          enum: [price, yield, health, portfolio, news, condition, schedule]
          description: >-
            The public kind vocabulary. `news` can appear on a listing but is not a
            creatable kind through this API.
        status:
          type: string
          enum: [active, paused, degraded]
          description: See the schema description above.
        created_at:
          type: integer
          description: Unix epoch milliseconds.
        last_checked_at:
          type: integer
          description: Unix epoch milliseconds. Alert/condition kinds only.
        last_fired_at:
          type: integer
          description: Unix epoch milliseconds.
        wallets:
          $ref: '#/components/schemas/AgentWallets'
        condition:
          type: string
          enum: [above, below]
          description: price/yield/health/portfolio kinds. Always `above` for portfolio; always `below` for health.
        threshold:
          type: number
        asset_symbol:
          type: string
          description: price/portfolio kinds.
        coingecko_id:
          type: string
          description: price kind.
        pct_change:
          type: number
          description: price kind, when set at create.
        base_price:
          type: number
          description: price kind, when set at create.
        pool_id:
          type: string
          description: yield kind.
        protocol:
          type: string
          description: yield/health kinds.
        chain:
          type: string
          description: yield/health kinds.
        asset:
          type: string
          description: yield kind.
        metric:
          type: string
          enum: [apy, supplyApy, borrowApy]
          description: yield kind.
        target_wallet:
          type: string
          description: health kind, when it differs from the owner wallet.
        movement_window:
          type: string
          enum: ["24h", "7d"]
          description: portfolio kind.
        rendered:
          type: string
          description: condition kind — the plan's plain-English readback (what actually triggers).
        fire_headline:
          type: string
          description: condition kind — custom one-line trigger headline, when set.
        cadence:
          type: string
          enum: [hourly, daily, weekly, monthly]
          description: condition kind (hourly/daily) or schedule kind (daily/weekly/monthly).
        intent:
          type: string
          description: condition kind — the original plain-English text.
        label:
          type: string
          description: schedule kind — display name.
        hour:
          type: integer
          description: schedule kind.
        minute:
          type: integer
          description: schedule kind.
        day_of_week:
          type: integer
          description: schedule kind, cadence weekly.
        day_of_month:
          type: integer
          description: schedule kind, cadence monthly.
        timezone:
          type: string
          description: schedule kind.
      additionalProperties: true

    AgentCreateResponse:
      type: object
      required: [agent]
      properties:
        agent:
          $ref: '#/components/schemas/Agent'
        compile:
          type: object
          nullable: true
          description: Present (non-null) only when created from a plain-English condition request.
          properties:
            rendered:
              type: string
            fire_headline:
              type: string
              nullable: true
            cadence:
              type: string
              enum: [hourly, daily]
          additionalProperties: true
      additionalProperties: true

    AgentGetResponse:
      type: object
      required: [agent]
      properties:
        agent:
          $ref: '#/components/schemas/Agent'
      additionalProperties: false

    AgentListResponse:
      type: object
      required: [agents, next_cursor]
      properties:
        agents:
          type: array
          items:
            $ref: '#/components/schemas/Agent'
        next_cursor:
          type: string
          nullable: true
      additionalProperties: false

    AgentPatchRequest:
      oneOf:
        - type: object
          required: [status]
          properties:
            status:
              type: string
              enum: [paused, active]
          additionalProperties: false
        - type: object
          required: [request]
          properties:
            request:
              type: string
              minLength: 3
              maxLength: 500
          additionalProperties: false

    NeedsClarificationResponse:
      type: object
      required: [status, question, options]
      properties:
        status:
          type: string
          enum: [needs_clarification]
        question:
          type: string
        options:
          type: array
          maxItems: 4
          items:
            $ref: '#/components/schemas/ClarificationOption'
      additionalProperties: false

    AgentTestFireResponse:
      type: object
      required: [ok]
      properties:
        ok:
          type: boolean
        note:
          type: string
      additionalProperties: false

    AgentEvent:
      type: object
      description: >-
        One item from the `GET /agent-events` pull rail. Same event mapping as the
        pushed webhook body for the same trigger (see `WebhookEventPayload`) — identical
        `id`/`type`/`agent`/`trigger` field set, except this rail's timestamp is
        `created_at` (epoch-ms) instead of `created` (ISO-8601), and this rail's `trigger`
        object is `snake_case` while the pushed payload's currently isn't (see
        `WebhookEventPayload.trigger`'s note).
      required: [id, type, created_at, agent, trigger]
      properties:
        id:
          type: string
          description: "`evt_<internal notification id>` — identical to the pushed webhook's `id` / `X-Flip-Event-Id` for the same trigger."
          example: evt_k7m2ab91
        type:
          type: string
          enum: [agent.triggered, agent.degraded, agent.test]
        created_at:
          type: integer
          description: Unix epoch milliseconds.
        agent:
          type: object
          properties:
            id:
              type: string
              description: >-
                The bare internal agent row id — NOT the `w_<type>_<id>` form used
                elsewhere in this API. Identical to this same trigger's `agent.id` on
                the pushed webhook payload, with one exception: a manual test-trigger
                (`POST /agents/{id}/test`) on a `condition`-kind agent currently
                omits this back-reference on both rails, so it arrives empty for that
                one case (price/yield/health/portfolio/schedule test-triggers are fine).
            kind:
              type: string
          additionalProperties: true
        trigger:
          type: object
          description: >-
            `condition` and `threshold` are present on every trigger (they're
            non-optional internally) but are placeholder values (`"above"` / `0`) for
            `condition`- and `schedule`-kind triggers, where they aren't meaningful —
            trust them only for `price`/`yield`/`health`/`portfolio` kinds.
          properties:
            headline:
              type: string
            kind:
              type: string
            condition:
              type: string
              enum: [above, below]
            threshold:
              type: number
            value_at_trigger:
              type: number
            price_at_trigger:
              type: number
            change_pct:
              type: number
            wallet:
              type: string
          additionalProperties: true
      additionalProperties: true

    AgentEventListResponse:
      type: object
      required: [events, next_cursor]
      properties:
        events:
          type: array
          items:
            $ref: '#/components/schemas/AgentEvent'
        next_cursor:
          type: integer
          nullable: true
      additionalProperties: false

    WebhookEventPayload:
      type: object
      description: >-
        The body POSTed to your registered endpoint for each trigger. Same event mapping
        as `AgentEvent` (the `GET /agent-events` pull rail's shape) for the same
        trigger — see `POST /webhook-endpoints`'s `webhooks.agentFired` entry for the
        full delivery contract (headers, retries) and the one known difference in
        `trigger`'s key casing.
      required: [id, type, created, agent, trigger]
      properties:
        id:
          type: string
          example: evt_k7m2ab91
        type:
          type: string
          enum: [agent.triggered, agent.degraded, agent.test]
        created:
          type: string
          format: date-time
          description: ISO-8601. (The pull rail's equivalent field is `created_at`, an integer epoch-ms, for the same instant.)
        agent:
          type: object
          properties:
            id:
              type: string
              description: >-
                The public `w_<type>_<id>` agent id — the same value create/list/get
                return, and identical to this trigger's `agent.id` on the pull rail.
                Pass it back verbatim on `/agents/{id}` calls.
            kind:
              type: string
          additionalProperties: true
        trigger:
          type: object
          description: >-
            Same field set as `AgentEvent.trigger` (`headline`, `kind`, `condition`,
            `threshold`, and — sparse, per-kind —
            `value_at_trigger`/`price_at_trigger`/
            `change_pct`, plus `wallet`) — `snake_case`, same as the pull rail.
            The pull rail's `trigger` object carries the identical data, identically
            cased.
          required: [headline, kind, wallet]
          properties:
            headline:
              type: string
            kind:
              type: string
            condition:
              type: string
              enum: [above, below]
            threshold:
              type: number
            value_at_trigger:
              type: number
            price_at_trigger:
              type: number
            change_pct:
              type: number
            wallet:
              type: string
          additionalProperties: true
      additionalProperties: true

    WebhookEndpointCreateRequest:
      type: object
      required: [url]
      properties:
        url:
          type: string
          maxLength: 2048
          example: https://example.com/webhooks/flip
      additionalProperties: false

    WebhookEndpointCreateResponse:
      type: object
      required: [endpoint, signing_secret]
      properties:
        endpoint:
          type: object
          required: [id, url]
          properties:
            id:
              type: string
            url:
              type: string
          additionalProperties: false
        signing_secret:
          type: string
          description: Shown exactly once. Store it now — it cannot be retrieved again (only its hash is kept server-side).
          example: whs_3f9a7b2e1c4d8f6a0b5e9c2d7a1f4b8e6c0d3a9f7b2e5c1d8a4f0b6e3c9d7a2f
      additionalProperties: false

    WebhookEndpoint:
      type: object
      required: [id, url, status]
      properties:
        id:
          type: string
        url:
          type: string
        status:
          type: string
          enum: [active, disabled]
        disabled_reason:
          type: string
          description: Set when the delivery worker auto-disabled this endpoint (10 consecutive dead-lettered deliveries).
        created_at:
          type: integer
          description: Unix epoch milliseconds.
      additionalProperties: false

    WebhookEndpointListResponse:
      type: object
      required: [endpoints]
      properties:
        endpoints:
          type: array
          items:
            $ref: '#/components/schemas/WebhookEndpoint'
      additionalProperties: false
