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 console and nothing more. Revoking takes effect on the next
    message, not at some expiry.

    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 console;
    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: []
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'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >
        A session access JWT — the console is the expected caller — or an
        account-wide API key. A credential confined to a single project cannot
        manage grants: a grant is account-level, so a project-scoped key acting
        on one would be reaching past its own confinement.
  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'
  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 console 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
    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
          required: [code, message]
          properties:
            code:
              type: string
              example: expired_token
            message:
              type: string
              example: This link has expired. Message the Assistant again to get a fresh one.
            details:
              type: object
              additionalProperties: true
