> ## Documentation Index
> Fetch the complete documentation index at: https://docs.seesaw.fun/llms.txt
> Use this file to discover all available pages before exploring further.

# Preview publishing an opinion (step 1 of 2)

> Checks the speaking rules — the owner holds a position on the topic,
trading is still open, the text is 1–500 characters — and returns the
summary to confirm plus, when publishing is possible, a
`confirm_token` for `POST /opinions`. **Charges nothing.** Requires the
`write` scope; counts against the read window only.

The side the opinion is attributed to is resolved server-side from the
owner's position; it is never client-declared.




## OpenAPI

````yaml /api-reference/openapi.yaml post /topics/{id}/opinion-preview
openapi: 3.1.0
info:
  title: SeeSaw Open API
  version: 2.0.0
  description: >
    The **open API** of SeeSaw — a dedicated surface for AI agents and

    third-party integrations, exposed both as REST (`/open/v1/*`) and as an

    **MCP server** (`/mcp`, Streamable HTTP, stateless) in the same process.


    Every key can **read**; keys created with the `write` scope can also act

    on the owner's behalf: place a position, publish an opinion, back an

    opinion. Writes always run in two phases — a `preview_*` call returns the

    summary the user is meant to confirm plus a short-lived `confirm_token`,

    and the execute call accepts only that token.


    This contract is independent from the app-facing API
    (`docs/api/swagger.yaml`):

    it does not inherit app DTO shapes and follows its own conventions.


    ## Conventions

    - Field names are `snake_case`; enums are lowercase strings.

    - Decimals (coins, gems, prices) are **strings with 4 decimal places**
      (`"100.0000"`) — never floats. Rounding for display is the client's call.
      One deliberate exception: `avg_price` (coins per ruby, a ratio rather
      than a price) carries **6 decimal places**, and says so at the field.
    - Timestamps are RFC 3339 UTC (`2026-09-09T12:00:00Z`).

    - Every resource carries a human-visitable `url`.

    - Lists use **opaque cursor pagination**: `{data, next_cursor, has_more}`.
      `limit` defaults to 20, max 100. Reusing a cursor with different query
      params does not guarantee continuity.
    - Single resources are returned bare (no wrapper); errors use
      `{"error": {"code", "message", "details"}}`.
    - Vocabulary follows the 2.0 glossary: **opinion** (not comment), **back**,
      **position**, **stake**, **payout**.

    ## Authentication and scopes

    Every request (public data included) requires a **personal API key** (PAT):

    `Authorization: Bearer sspat_…`. Keys are issued per user (max 5 active)

    and can be revoked anytime. Key management lives on the app API

    (`/v1/users/me/api-keys`, JWT auth — see swagger.yaml), NOT on this surface.


    Two scope tiers, chosen when the key is created and **fixed for the life of

    the key** (there is no upgrade path — create a new key instead):


    | Scopes | Grants |

    | --- | --- |

    | `["read"]` | The whole read surface. The default. |

    | `["read","write"]` | The read surface **plus** the six write operations. |


    A read-only key hitting a write endpoint gets `403 FORBIDDEN_SCOPE`; over

    MCP it never sees the write tools at all.


    ## The write flow

    1. Call `POST …/order-preview`, `…/opinion-preview` or `…/back-preview`.
       Nothing is charged; the response carries a `summary` plus, when the
       action is currently possible, a `confirm_token` and its `expires_at`.
    2. **Show the summary to the user and get explicit confirmation.**

    3. Call `POST /orders`, `/opinions` or `/backs` with only that token.


    Tokens are valid **5 minutes**, are bound to the issuing user, key, action

    and parameters, and are single-use. `place_order` and `back_opinion` are

    idempotent on retry with the same token (`replayed: true`);

    `publish_opinion` is strictly single-use and answers a retry with

    `409 CONFIRM_TOKEN_USED`.


    ## Rate limits (per key)

    - **Read window**: 120 requests/min and 10,000 requests/day across the
      surface. The three `preview_*` operations count only against this window.
    - **Search**: additionally capped at 30 requests/min — a dedicated per-key
      budget shared by REST `/search` and the MCP `search_topics` tool.
    - **Write window**: the three executing operations (`/orders`, `/opinions`,
      `/backs`) are additionally capped at **10/min and 200/day**; they also
      consume the read window.
    - `X-RateLimit-Limit` / `X-RateLimit-Remaining` / `X-RateLimit-Reset`
      response headers reflect the binding minute window; 429 responses
      carry `Retry-After`. Over MCP, exceeding a quota surfaces as a tool
      error naming the retry delay.

    ## MCP server

    `POST https://api.seesaw.fun/mcp` (Streamable HTTP, stateless, same

    `Authorization` header). A read key sees **11 tools**; a read+write key

    sees **17**:


    - Read: `search_topics`, `list_topics`, `list_topic_categories`,
      `get_topic`, `list_topic_opinions`, `get_my_profile`,
      `list_my_positions`, `list_my_opinions`, `list_my_backed_opinions`,
      `list_my_watchlist`, `list_my_coin_ledger`.
    - Write: `preview_order` / `place_order`, `preview_opinion` /
      `publish_opinion`, `preview_back` / `back_opinion`.

    Read and preview tools are annotated `readOnlyHint: true`; the three

    executing tools carry `destructiveHint: true` and

    `_meta["anthropic/requiresUserInteraction"] = true`, which compatible

    clients turn into a mandatory confirmation prompt. Tool results carry the

    corresponding REST response JSON.


    Read tools take an optional `language` argument; REST reads the

    `Accept-Language` header instead. Supported: `en` (default), `zh-CN`,

    `zh-TW`, `ja`, `ko`, `th`, `vi`, `id`.


    ## Trading mechanisms

    2.0 topics run one of two mechanisms. `v21_lmsr` is the live one: a fixed

    **100-coin** stake, one order per topic, no selling, no fees; a correct

    option pays **rubies** equal to the order's `units` (the stake itself is

    not returned). `v2_share` topics are legacy share-pool markets — readable,

    but not tradable through this surface (`preview_order` answers

    `MECHANISM_UNSUPPORTED`). List and search endpoints default to `v21_lmsr`.
servers:
  - url: https://api.seesaw.fun/open/v1
    description: Production
security:
  - apiKey: []
tags:
  - name: Topics
    description: 2.0 prediction topics — browse, search, detail, opinions
  - name: Me
    description: Data of the authenticated key owner
  - name: Trading
    description: Place a position (preview then execute, `write` scope)
  - name: Opinions
    description: Publish and back opinions (preview then execute, `write` scope)
paths:
  /topics/{id}/opinion-preview:
    post:
      tags:
        - Opinions
      summary: Preview publishing an opinion (step 1 of 2)
      description: |
        Checks the speaking rules — the owner holds a position on the topic,
        trading is still open, the text is 1–500 characters — and returns the
        summary to confirm plus, when publishing is possible, a
        `confirm_token` for `POST /opinions`. **Charges nothing.** Requires the
        `write` scope; counts against the read window only.

        The side the opinion is attributed to is resolved server-side from the
        owner's position; it is never client-declared.
      operationId: previewOpinion
      parameters:
        - $ref: '#/components/parameters/TopicID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - content
              properties:
                content:
                  type: string
                  minLength: 1
                  maxLength: 500
                reply_to_opinion_id:
                  type: string
                  format: uuid
                  description: Omit for a top-level opinion.
      responses:
        '200':
          description: The preview (check `can_publish` before confirming)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OpinionPreview'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ForbiddenScope'
        '404':
          $ref: '#/components/responses/NotFound'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  parameters:
    TopicID:
      name: id
      in: path
      required: true
      description: Topic UUID.
      schema:
        type: string
        format: uuid
  schemas:
    OpinionPreview:
      type: object
      required:
        - can_publish
        - summary
      properties:
        can_publish:
          type: boolean
        reason:
          type: string
          enum:
            - TOPIC_CLOSED
            - NO_POSITION
            - CONTENT_INVALID
          description: |
            Present only when `can_publish` is false. `TOPIC_CLOSED` takes
            priority; `CONTENT_INVALID` means empty or over 500 characters.
        summary:
          $ref: '#/components/schemas/OpinionSummary'
        confirm_token:
          type: string
        expires_at:
          type: string
          format: date-time
    OpinionSummary:
      type: object
      description: |
        What the user confirms before `POST /opinions`. `side_option_label` is
        the owner's staked side, resolved server-side.
      required:
        - topic_title
        - fee_coins
        - balance_after
        - side_option_label
        - content_preview
        - reply_to
      properties:
        topic_title:
          type: string
        fee_coins:
          type: string
          description: What publishing costs. Currently `"0.0000"` — publishing is free.
        balance_after:
          type: string
        side_option_label:
          type: string
        content_preview:
          type: string
          description: First 140 characters, with an ellipsis when truncated.
        reply_to:
          oneOf:
            - type: object
              required:
                - id
                - excerpt
              properties:
                id:
                  type: string
                  format: uuid
                excerpt:
                  type: string
            - type: 'null'
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - code
            - message
          properties:
            code:
              type: string
              enum:
                - UNAUTHORIZED
                - FORBIDDEN
                - FORBIDDEN_SCOPE
                - NOT_FOUND
                - OPINION_NOT_FOUND
                - INVALID_ARGUMENT
                - RATE_LIMITED
                - SERVICE_DISABLED
                - INTERNAL
                - CONFIRM_TOKEN_INVALID
                - CONFIRM_TOKEN_EXPIRED
                - CONFIRM_TOKEN_USED
                - CONFIRM_UNAVAILABLE
                - MECHANISM_UNSUPPORTED
                - PREVIEW_UNAVAILABLE
                - TOPIC_ALREADY_STAKED
                - TOPIC_CLOSED
                - TOPIC_QUOTE_EXPIRED
                - TOPIC_QUOTE_INVALID
                - TOPIC_MECHANISM_MISMATCH
                - RETRY_IDEMPOTENCY_KEY
                - OPINION_ALREADY_BACKED
                - OPINION_BACK_PRICE_MOVED
                - OPINION_BACK_RETRY
                - OPINION_BACK_FULL
                - OPINION_BACK_CHAIN_BROKEN
                - OPINION_NEEDS_POSITION
                - OPINION_TOPIC_CLOSED
                - OPINION_TOPIC_HIDDEN
                - OPINIONS_UNAVAILABLE
                - INSUFFICIENT_BALANCE
                - CONTENT_INVALID
                - CONTENT_MODERATED
                - USER_PUNISHED
              description: |
                Stable contract values. `SERVICE_DISABLED` (503) is the
                emergency kill switch; `FORBIDDEN_SCOPE` (403) means the key
                lacks the `write` scope. The `CONFIRM_TOKEN_*` and rule codes
                only occur on the write surface.
            message:
              type: string
            details:
              type: object
              description: >-
                Optional context, e.g. `{"field": "limit"}`, `{"retry_after":
                12}` or `{"required_scope": "write"}`.
              additionalProperties: true
  responses:
    BadRequest:
      description: Invalid argument (`details.field` names the offending parameter)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: >-
        Missing, invalid, revoked, or expired API key (carries
        `WWW-Authenticate`)
      headers:
        WWW-Authenticate:
          schema:
            type: string
          description: Bearer realm="seesaw-open-api"
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ForbiddenScope:
      description: |
        The key lacks the scope this operation needs — `FORBIDDEN_SCOPE`, with
        `details.required_scope`. Create a key with the `write` scope.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: >-
        Resource missing, or hidden by privacy/moderation (existence does not
        leak)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: >-
        Rate limit exceeded (see `Retry-After` and `details.retry_after`
        seconds)
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalError:
      description: >-
        Internal error (`INTERNAL`, or `CONFIRM_UNAVAILABLE` when token signing
        is unavailable)
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: sspat_ personal API key
      description: |
        Personal API key issued per SeeSaw user account:
        `Authorization: Bearer sspat_…`. Manage keys on the app API
        (`/v1/users/me/api-keys`). Revocation propagates within ~60 seconds
        (validation cache window).

        The key's **scopes** decide what it may do: every key has `read`; a key
        created with `["read","write"]` may also use the six write operations
        (marked `x-required-scope: write` in this document). Scopes are fixed at
        creation — to gain write access, create a new key. A read-only key
        calling a write operation gets `403 FORBIDDEN_SCOPE`.

````