openapi: 3.0.3
info:
  title: naturali.ai — Assistant API
  version: 1.0.0
  description: >
    The Naturali Assistant is the first-party agent you talk to on a messaging
    channel — Discord, WhatsApp, Slack — to operate your naturali account:
    inspect projects, check usage, manage agents and channels. This API is the
    consent layer in front of it: which channel identities are allowed to act on
    your account, and how one becomes allowed.

    A grant is account-scoped and carries no project. It is a consent record,
    not a permission boundary: every project-scoped operation still resolves
    ownership per request, so a linked identity reaches exactly what you already
    see in the app and nothing more. Revoking takes effect on the next
    message, not at some expiry.

    Grants are managed with a session access JWT or an account-wide API key. A
    credential confined to a single project cannot manage them: a grant is
    account-level, so a project-scoped key acting on one would reach past its
    own confinement.

    Linking starts on the channel, never here. Message the Assistant from an
    unlinked identity and it replies with a single-use link back to the app;
    that link lands on a confirmation screen which resolves the identity with
    `GET /v1/assistant/link` and, on an explicit click, redeems it with
    `POST /v1/assistant/link`. There is no endpoint that mints a link — a
    caller who could mint one could invite an identity that never asked to be
    invited.
  contact:
    name: naturali.ai
    url: https://naturali.ai
servers:
  - url: '{baseUrl}'
    description: Host of your naturali.ai deployment; every path carries the /v1 prefix.
    variables:
      baseUrl:
        description: Base host URL.
        default: https://api.naturali.ai
tags:
  - name: Assistant
    description: >
      Link and revoke the channel identities allowed to operate your naturali
      account through the Naturali Assistant.
security:
  - bearerAuth: []
  - oauth2:
      - mcp:access
paths:
  /v1/assistant/grants:
    get:
      tags: [Assistant]
      summary: List linked identities
      description: >
        Lists the caller's assistant grants — one per channel identity that may
        operate their account. Grants belong to the account, so this is the
        caller's own set regardless of which projects they own.
      operationId: listAssistantGrants
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of grants.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssistantGrantList'
        '401':
          $ref: '#/components/responses/Unauthorized'
  /v1/assistant/grants/{grant_id}:
    parameters:
      - $ref: '#/components/parameters/GrantId'
    delete:
      tags: [Assistant]
      summary: Revoke a linked identity
      description: >
        Revokes the grant, disabling the Assistant for that identity. The grant
        is resolved on every inbound message, so the next one from that identity
        is refused before the agent is invoked — revocation is immediate, not
        eventual. The identity is free to link again afterwards.
      operationId: revokeAssistantGrant
      responses:
        '204':
          description: Grant revoked.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/assistant/link:
    get:
      tags: [Assistant]
      summary: Resolve a pending link
      description: >
        Resolves a link token **without consuming it**, so the confirmation
        screen can name the identity being linked ("@user on Discord") before
        anyone commits to it. Naming it is what makes a link pasted into the
        wrong hands fail the human check as well as the server-side binding.

        Reading is deliberately separate from redeeming: a single-use nonce must
        not be burned by a link preview, a URL scanner or a browser prefetch.
      operationId: previewAssistantLink
      parameters:
        - $ref: '#/components/parameters/LinkToken'
      responses:
        '200':
          description: The identity this token would link, and what it would be allowed to do.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssistantLinkPreview'
        '400':
          description: >
            The token is unknown, expired, or already redeemed
            (`invalid_token` / `expired_token`). Getting a fresh one means
            messaging the Assistant again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Assistant]
      summary: Redeem a link token
      description: >
        Redeems a link token and creates the grant, binding the channel identity
        the token carries to the authenticated account. The identity is read from
        the token server-side — nothing in this request can point the link at a
        different one.

        The token is the idempotency key: it is single-use, so a replay of this
        request fails rather than creating a second grant.
      operationId: redeemAssistantLink
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssistantLinkRedeem'
      responses:
        '201':
          description: Grant created. The Assistant answers that identity from the next message on.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssistantGrant'
        '400':
          description: >
            The token is missing, unknown, expired, or already redeemed
            (`invalid_token` / `expired_token`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: >
            The identity is already linked to an account
            (`identity_already_linked`). One channel identity operates one
            naturali account: re-pointing it means revoking the existing grant
            first, which is explicit and auditable rather than silent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/assistant/messages:
    get:
      tags: [Assistant]
      summary: Read the conversation
      description: >
        The newest 100 messages of your conversation with the Assistant in the
        app, oldest first. An account that has not messaged it yet has an empty
        conversation.
      operationId: listAssistantMessages
      responses:
        '200':
          description: The conversation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssistantMessageList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/ConversationForbidden'
        '502':
          $ref: '#/components/responses/TurnUnavailable'
        '503':
          $ref: '#/components/responses/AssistantUnavailable'
    post:
      tags: [Assistant]
      summary: Message the Assistant
      description: >
        Runs one turn as your account and answers with the messages it added:
        yours, then the Assistant's reply. The turn is paid from your credit and
        refused before it runs when the balance is below zero.
      operationId: sendAssistantMessage
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AssistantMessageCreate'
      responses:
        '201':
          description: The messages this turn added, oldest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssistantMessageList'
        '400':
          description: '`text` is missing or blank.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: >
            Your credit balance is below zero (`insufficient_credit`). Top up to
            message the Assistant again.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '403':
          $ref: '#/components/responses/ConversationForbidden'
        '502':
          $ref: '#/components/responses/TurnUnavailable'
        '503':
          $ref: '#/components/responses/AssistantUnavailable'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: A naturali API key (nat_sk_…) or a session JWT.
    oauth2:
      type: oauth2
      description: >-
        A connected app's OAuth access token, issued by this API's authorization
        server (discovery: /.well-known/oauth-authorization-server). Its one scope
        carries every operation, confined to the projects the user chose when
        approving the app.
      flows:
        authorizationCode:
          authorizationUrl: https://api.naturali.ai/authorize
          tokenUrl: https://api.naturali.ai/token
          refreshUrl: https://api.naturali.ai/token
          scopes:
            mcp:access: Every operation this API serves, on the projects the grant covers.
  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Maximum items per page — an integer from 1 to 100 (default 20).
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    Cursor:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from a previous response's next_cursor.
      schema:
        type: string
    GrantId:
      name: grant_id
      in: path
      required: true
      description: Grant public ID (agr_ prefix).
      schema:
        type: string
        example: agr_V1StGXR8Z5jdHi6B
    LinkToken:
      name: token
      in: query
      required: true
      description: The single-use token from the link the Assistant sent on the channel.
      schema:
        type: string
        example: alt_9f8e7d6c5b4a39281706
  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: The resource does not exist (existence is not leaked).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    ConversationForbidden:
      description: >
        The credential cannot hold the account's conversation
        (`access_denied`): it is confined to one project, or it is the
        Assistant's own turn credential.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    TurnUnavailable:
      description: The turn could not be run or read (`upstream_unavailable`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    AssistantUnavailable:
      description: >
        This deployment serves no Assistant conversation
        (`assistant_unavailable`).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
  schemas:
    AssistantGrant:
      type: object
      description: >
        One channel identity authorized to operate one naturali account through
        the Assistant.
      properties:
        id:
          type: string
          description: Public grant ID (agr_ prefix).
          example: agr_V1StGXR8Z5jdHi6B
        channel:
          $ref: '#/components/schemas/AssistantChannel'
        identifier:
          type: string
          description: >
            The channel identity, rendered with its channel prefix — a Discord
            user id, a WhatsApp phone number, a Slack team and user. One
            identity operates one account.
          example: discord:112233445566778899
        display_name:
          type: string
          nullable: true
          description: >
            The identity as its channel reported it, when it reported one — what
            a person recognizes instead of an opaque id. Null when the channel
            offers nothing better than the identifier.
          example: ana
        scopes:
          type: array
          description: >
            What the Assistant may do on the account. Deliberately coarse: the
            consent for a specific dangerous action is asked for when it
            happens, through the approval queue, rather than pre-granted here.
          items:
            $ref: '#/components/schemas/AssistantScope'
          example: ['read']
        status:
          type: string
          enum: [active, revoked]
          description: Whether the Assistant currently answers this identity.
          example: active
        last_used_at:
          type: string
          format: date-time
          nullable: true
          description: When the Assistant last acted under this grant; null if never.
          example: '2026-07-17T09:12:00.000Z'
        created_at:
          type: string
          format: date-time
          example: '2026-07-17T00:00:00.000Z'
      required:
        [
          id,
          channel,
          identifier,
          display_name,
          scopes,
          status,
          last_used_at,
          created_at,
        ]
    AssistantChannel:
      type: string
      enum: [discord, whatsapp, slack]
      description: >
        The channel the linked identity lives on. The app is never a value
        here: a signed-in web session already *is* the identity, so it needs no
        grant.
      example: discord
    AssistantScope:
      type: string
      enum: [read, manage]
      description: >
        `read` lists and inspects projects, agents, channels, knowledge and
        usage. `manage` additionally mutates, still subject to the approval
        queue for destructive operations. A link currently grants `read`.
      example: read
    AssistantLinkPreview:
      type: object
      description: >
        What a pending link would do, resolved from its token without consuming
        it.
      properties:
        channel:
          $ref: '#/components/schemas/AssistantChannel'
        identifier:
          type: string
          description: The channel identity this token would link, with its channel prefix.
          example: discord:112233445566778899
        display_name:
          type: string
          nullable: true
          description: The identity as its channel reported it, for the confirmation screen.
          example: ana
        scopes:
          type: array
          items:
            $ref: '#/components/schemas/AssistantScope'
          description: What redeeming this token would allow.
          example: ['read']
        expires_at:
          type: string
          format: date-time
          description: When the token stops being redeemable. Links are short-lived by design.
          example: '2026-07-17T00:10:00.000Z'
      required: [channel, identifier, display_name, scopes, expires_at]
    AssistantLinkRedeem:
      type: object
      required: [token]
      properties:
        token:
          type: string
          description: The single-use token from the link the Assistant sent on the channel.
          example: alt_9f8e7d6c5b4a39281706
    AssistantMessage:
      type: object
      required: [id, role, text]
      properties:
        id:
          type: string
          example: doc_V1StGXR8Z5jdHi6B
        role:
          type: string
          enum: [user, assistant]
          example: assistant
        text:
          type: string
          example: You have two projects.
    AssistantMessageCreate:
      type: object
      required: [text]
      additionalProperties: false
      properties:
        text:
          type: string
          minLength: 1
          example: List my projects.
    AssistantMessageList:
      type: object
      required: [data]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/AssistantMessage'
    AssistantGrantList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/AssistantGrant'
        next_cursor:
          type: string
          nullable: true
          example: null
    ErrorResponse:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          description: >-
            Structured error. Every error response uses this shape, so `code` can
            be read without first checking the type of `error`.
          required:
            - code
            - message
          properties:
            code:
              type: string
              description: >-
                Machine-readable error code: lower_snake for an error naturali
                raises (`access_denied`), UPPER_SNAKE for one the runtime reports
                (`RESOURCE_NOT_FOUND`).
              example: access_denied
            message:
              type: string
              description: Human-readable explanation.
              example: Your role in this project does not carry this action.
            details:
              type: object
              additionalProperties: true
              description: >-
                Structured context for an error naturali raises, such as the
                `resource` and `limit` of a `plan_limit_reached`.
            meta:
              type: object
              description: Structured context for an error the runtime reports.
