openapi: 3.0.3
info:
  title: naturali.ai — Projects API
  version: 1.0.0
  description: >
    Projects are the isolation and billing boundary. An account holds multiple
    projects; every resource belongs to exactly one. API keys are
    project-scoped by default.
  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: Projects
    description: Create and manage projects — the per-client / per-environment boundary.
security:
  - bearerAuth: []
  - oauth2:
      - mcp:access
paths:
  /v1/projects:
    get:
      tags: [Projects]
      summary: List projects
      description: >
        Lists the projects the caller is a member of. A project-scoped API key
        lists only its own project.
      operationId: listProjects
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Cursor'
      responses:
        '200':
          description: A page of projects.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectList'
        '401':
          $ref: '#/components/responses/Unauthorized'
    post:
      tags: [Projects]
      summary: Create a project
      description: >
        Creates a project (one per client or per environment).


        Your plan limits how many projects you may own. At the limit this
        responds `403` with `plan_limit_reached`, whose `details` carry the
        `plan` and the `limit`. An archived project still counts — archiving
        keeps every resource, so it frees nothing; deleting a project does.


        A new project starts at the content-retention window your plan sets
        (`trace_content_retention_days`); `PATCH` it to anything shorter.
      operationId: createProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectCreate'
      responses:
        '201':
          description: Project created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: >-
            The `idempotency_key` is already claimed by a different request
            (`idempotency_key_reused`), or by one still in flight
            (`idempotency_request_in_progress`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/projects/{project_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: Get a project
      description: >
        Returns one project you are a member of, and your `role` in it. An id
        you are not a member of — including one that does not exist — responds
        `404`, not `403`: the API never confirms that an id exists elsewhere.

        `403` is reserved for the cases where there is nothing to hide: a
        project you *are* in, addressed with a credential scoped to a different
        one, or an action your role does not carry. There the message is what
        makes the failure fixable.
      operationId: getProject
      responses:
        '200':
          description: Project details.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags: [Projects]
      summary: Update a project
      description: >
        Rename or archive a project, and/or change its content-retention
        settings (`trace_content_retention_days`, `trace_content_mode`), its
        execution ceilings (`max_concurrent_runs`, `max_chain_generations`,
        `max_orchestration_run_depth`), its priced-model gate
        (`require_priced_model`), its project-scope guardrails
        (`guardrail_ids`) and managed conversion (`managed_conversion`).
        Archiving is reversible; resources are retained.

        Requires the `admin` role in the project (an `owner` has it too). These
        are the terms every member works under, which is why setting them sits
        above the role that works under them.

        The ceilings and the priced-model gate are uncapped by plan: each one
        only ever narrows what the project may spend, so setting one takes on a
        restriction rather than claiming an entitlement. On the three ceilings
        `null` clears the project's own bound and omission leaves it alone —
        they are different instructions.

        `guardrail_ids` is the floor under every tool call by every agent in
        the project, including tools added later. The list is replaced
        wholesale, so send the ids you want to keep; `[]` detaches every one.
        An id naming no guardrail in the project responds `400` with
        `guardrail_not_found`, whose `details.missing` lists the ids. It is
        uncapped by plan, like the ceilings: a guardrail can only tighten what
        runs.

        The two retention controls answer different questions. The window
        bounds how long content *stays* — a daily sweep purges anything past
        it, leaving auditable skeletons behind. `trace_content_mode: none`
        means content is never *written*, which is the stronger guarantee: it
        cannot be missed by a sweep or survive in a backup.


        Your plan sets the longest window you may keep content for. A wider one
        — `null` included, which keeps content indefinitely — responds `403`
        with `plan_limit_reached`, whose `details` carry the `plan` and the
        `limit` in days. Anything shorter is always allowed. Moving to a plan
        with a shorter window takes effect at the end of the billing cycle, and
        content already stored is then purged by age like everything else.
      operationId: updateProject
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectUpdate'
      responses:
        '200':
          description: Project updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '400':
          description: >-
            A malformed field, or `guardrail_not_found` for a `guardrail_ids`
            entry naming no guardrail in the project.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Projects]
      summary: Delete a project
      description: >
        Permanently deletes the project and its backing runtime project. Fails with
        409 if the runtime project still has dependent resources — remove them
        first, or pass `force=true` to delete the project and all its dependents
        (agents, providers, tools, sessions, generations, traces). Forcing is
        destructive and irreversible.

        Requires the `owner` role — an `admin` runs the project day to day, but
        destroying it is the billing owner's call.
      operationId: deleteProject
      parameters:
        - $ref: '#/components/parameters/Force'
      responses:
        '204':
          description: Project deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The project still has dependent resources on the runtime (retry with `force=true`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/projects/{project_id}/pause:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Projects]
      summary: Pause a project
      description: >
        Stops everything the project runs, with one call. From the moment it
        answers, nothing new starts: generations, tool calls, orchestration
        runs, eval runs and trigger fires in the project respond `409` with
        `PROJECT_PAUSED`, and so does resuming a single run or task the pause
        holds. A channel keeps receiving messages and answers each with a
        neutral "can't reply right now" line.

        What is already in motion is parked, not discarded: live orchestration
        runs pause at their next checkpoint, open tasks stop dispatching,
        schedule triggers stop firing and queued eval items wait. A generation
        already running finishes. Reads and configuration changes keep working.

        Idempotent: pausing a paused project answers it unchanged and keeps the
        first `reason`. Emits `project.paused`. Requires the `admin` role.
      operationId: pauseProject
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectPause'
      responses:
        '200':
          description: The paused project.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/projects/{project_id}/resume:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    post:
      tags: [Projects]
      summary: Resume a project
      description: >
        Lifts the project's pause and hands back exactly what it held: the runs
        and tasks it parked resume, and schedule triggers fire again from their
        next occurrence after now — an occurrence that fell due while paused is
        not fired late. A run or task paused on its own before the project was
        paused stays paused. Emits `project.resumed`. Requires the `admin` role.
      operationId: resumeProject
      responses:
        '200':
          description: The resumed project.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Project'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: The project is not paused (`project_not_paused`).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/projects/{project_id}/members:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: List project members
      description: >
        Lists who may act in the project, and with what role. Readable by every
        member, whatever their role: who else is in the project is not
        a privileged fact, and hiding it makes "why can that person see my
        agents?" unanswerable.

        Somebody invited who has never signed in is listed too, with
        status `pending` — the invitation is already a grant, so hiding it until
        they arrive would hide access that exists.
      operationId: listProjectMembers
      responses:
        '200':
          description: The project's members, oldest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectMemberList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Projects]
      summary: Add a project member
      description: >
        Adds a colleague to the project by email address. Free and uncapped on
        every plan: a member adds no run, no channel, no trigger and no byte, so
        every cost they cause is already billed through the project's owner.
        Members are counted nowhere.


        **The address does not need an account.** Inviting one that has none
        creates it unverified and records the membership beside it, so the
        invitee is already a member with status `pending`. Their first sign-in
        code — which they request themselves, from the link in the invitation —
        is what makes them `active`. There is no separate acceptance step and no
        invitation to expire: the address is the credential, and the invitation
        reached it.


        An `owner` may grant `admin` or `member`; an `admin` may grant `member`
        only, so administration cannot hand out its own authority. `role: owner`
        is refused — that is the billing owner, and transferring it is a
        different act.


        The invitation email is a courtesy, not the grant: if it cannot be sent
        the membership still stands, and the response still says `pending`.
        Requires an account-wide credential — a project-scoped key cannot grant
        access that would outlive its own revocation.
      operationId: addProjectMember
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectMemberCreate'
      responses:
        '201':
          description: The member was added.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectMember'
        '400':
          description: Missing `email`, or a `role` outside `admin`/`member`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >
            The caller is a `member`, an `admin` granting `admin`, or
            a project-scoped credential.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: That address is already a member of this project.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '429':
          description: This account has sent too many invitations today.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /v1/projects/{project_id}/members/{member_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - name: member_id
        in: path
        required: true
        schema:
          type: string
        description: The membership ID (`pmem_` prefix), not the user ID.
        example: pmem_V1StGXR8Z5jdHi6B
    patch:
      tags: [Projects]
      summary: Change a member's role
      description: >
        Moves a member between `admin` and `member`. The project `owner` only:
        promoting somebody to `admin` hands them every write in the project, and
        an `admin` who could do that could erase the difference between the two
        roles for themselves.


        The owner's own row is refused in either direction — it names who pays,
        so changing it is a transfer rather than a role change.
      operationId: updateProjectMember
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProjectMemberUpdate'
      responses:
        '200':
          description: The member's new role.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectMember'
        '400':
          description: A `role` outside `admin`/`member`, or the owner's own row.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags: [Projects]
      summary: Remove a project member
      description: >
        Removes a member, or leaves the project yourself.


        **Anyone may remove themselves**, whatever their role: a colleague who
        no longer wants access should not have to ask for it, and leaving takes
        nothing from anybody else. Removing *somebody else* needs authority — an
        `owner` may remove anyone, an `admin` may remove a `member`
        and not another `admin`.


        The owner's own row is refused: a project with no owner is unreachable,
        and the row names who pays.
      operationId: removeProjectMember
      responses:
        '204':
          description: The member was removed.
        '400':
          description: The row is the project owner's.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
  /v1/projects/{project_id}/usage:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: Get per-project usage
      description: >
        The per-project meter — the re-billing view (A11/C12/P3). Aggregates the
        project's usage over an optional [from, to] window, bucketed by a single
        dimension. Costs are the billing-grade cost_usd the runtime freezes at write
        time; null means nothing in the bucket was priced (never that it was
        free). Only managed providers are priced (on the runtime), so cost reflects
        managed usage; BYOK usage carries no LLM cost.


        Every bucket also carries components — the amounts actually measured. The
        token counts describe LLM usage alone, so that is where a storage,
        api_request or compute_execution bucket reports its real quantity instead
        of zeroed token fields.


        The rollup can also be narrowed to one session or one end user before it
        is bucketed. That is the question no dimension answers: group_by splits
        the project's whole spend, so "what did this conversation cost, per day"
        and "what has this end user spent across every session" are reachable
        only by narrowing. The narrowings intersect, apply to the totals as well
        as the buckets, and are echoed back complete — null for each one not
        applied — because a rollup of zeros is otherwise indistinguishable from
        a project that spent nothing.
      operationId: getProjectUsage
      parameters:
        - name: group_by
          in: query
          required: false
          description: >
            Dimension to bucket by. `day` buckets on the event's UTC calendar
            day; the others on the matching id. `model` buckets on the model
            *and* the provider that served it, so one model name served by two
            providers is two groups (see `ai_provider_id`). `ai_provider`
            buckets on the provider the spend was billed against — a routed
            generation's serving target, or the agent's pinned provider — which
            is the total a per-provider reconciliation wants, without summing
            the model dimension by hand. `run` buckets on the orchestration run
            an event belongs to; work that runs no orchestration collapses into
            the single null bucket, so the bucket count there is not a count of
            runs. `session` and `actor` bucket on the conversation and the end
            user behind the spend — what a customer re-billing their own users
            charges each of them for; traffic with no end user behind it
            collapses into the single null bucket on both. `source` buckets on
            what the spend was incurred for (`eval`, `eval_judge`), which is
            how verification spend is told apart from the traffic serving real
            users; ordinary traffic carries no source and is the null bucket.
          schema:
            type: string
            enum:
              [
                model,
                ai_provider,
                agent,
                run,
                day,
                meter_type,
                actor,
                session,
                source,
              ]
            default: model
        - $ref: '#/components/parameters/UsageMeterType'
        - $ref: '#/components/parameters/UsageSessionId'
        - $ref: '#/components/parameters/UsageActorId'
        - $ref: '#/components/parameters/UsageAgentId'
        - $ref: '#/components/parameters/UsageAiProviderId'
        - $ref: '#/components/parameters/UsageOrchestrationRunId'
        - $ref: '#/components/parameters/UsageOrchestrationId'
        - $ref: '#/components/parameters/UsageGenerationId'
        - $ref: '#/components/parameters/UsageTraceId'
        - $ref: '#/components/parameters/UsageSource'
        - $ref: '#/components/parameters/UsageTriggerId'
        - $ref: '#/components/parameters/UsageActionId'
        - name: include
          in: query
          required: false
          description: >
            Optional extras to compute. The only value is distinct, which adds
            the distinct object — one count per kind of entity the window
            touched. Opt-in because each counter costs the window another sort,
            so a caller who does not ask keeps the cheap response whatever the
            event table grows to. Any other value is a 400.
          schema:
            type: string
            enum: [distinct]
        - name: from
          in: query
          required: false
          description: Inclusive lower bound (ISO-8601) on event time. Omit for no lower bound.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: false
          description: Inclusive upper bound (ISO-8601) on event time. Omit for no upper bound.
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          required: false
          description: >
            Maximum number of groups entries to return. Does not affect the
            top-level totals or groups.total, which describe the whole window.
            A value above 100 is refused rather than clamped, so a caller
            paging by their own stride never silently skips a bucket.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          required: false
          description: Number of groups entries to skip.
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Usage for the project.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectUsage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/projects/{project_id}/usage/events:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: List project usage events
      description: >
        The rows a rollup summed — one per metered occurrence, most recent
        first. Takes the same narrowings
        [the meter](/docs/api/projects/get-project-usage) does, so a query that
        produced a bucket answers here unchanged and reads back what went into
        it. This is the audit and reconciliation view: what was measured, when,
        against which agent, session, actor and provider, and what each
        component was priced at.


        An id naming nothing in this project yields an empty page rather than
        dropping the filter, so a mistyped narrowing can never widen the list
        past what was asked for.
      operationId: listProjectUsageEvents
      parameters:
        - $ref: '#/components/parameters/UsageMeterType'
        - $ref: '#/components/parameters/UsageSessionId'
        - $ref: '#/components/parameters/UsageActorId'
        - $ref: '#/components/parameters/UsageAgentId'
        - $ref: '#/components/parameters/UsageAiProviderId'
        - $ref: '#/components/parameters/UsageOrchestrationRunId'
        - $ref: '#/components/parameters/UsageOrchestrationId'
        - $ref: '#/components/parameters/UsageGenerationId'
        - $ref: '#/components/parameters/UsageTraceId'
        - $ref: '#/components/parameters/UsageSource'
        - $ref: '#/components/parameters/UsageTriggerId'
        - $ref: '#/components/parameters/UsageActionId'
        - name: limit
          in: query
          required: false
          description: >
            Maximum number of events to return. A value above 100 is refused
            rather than clamped, so a caller paging by their own stride never
            silently skips a row.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          required: false
          description: Number of events to skip.
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: One page of usage events.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectUsageEventPage'
        '400':
          description: A narrowing was given more than once, or a bound is out of range.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/projects/{project_id}/usage/receipt:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: Get a generation or orchestration-run billing receipt
      description: >
        What one occurrence was billed, line by line: per-event line items with
        their measured components and unit prices, a per-meter-type split, and
        the totals. Pass generation_id for one generation, or
        orchestration_run_id for a receipt summed across every event the run
        metered. Exactly one of the two is required.


        This is the itemisation behind a single number — where
        [the meter](/docs/api/projects/get-project-usage) says a window cost
        $12.40, a receipt says which components of which call made up one
        occurrence of it. On an orchestration-run receipt every line carries
        node_id, so grouping by it gives the per-node cost the total hides; a
        retried node contributes one line per attempt, which is the intended
        reading for spend.
      operationId: getProjectUsageReceipt
      parameters:
        - name: generation_id
          in: query
          required: false
          description: >
            Generation public ID. Mutually exclusive with
            orchestration_run_id.
          schema:
            type: string
            example: gen_V1StGXR8Z5jdHi6B
        - name: orchestration_run_id
          in: query
          required: false
          description: >
            Orchestration run public ID. Returns the receipt summed across
            every generation the run metered. Mutually exclusive with
            generation_id.
          schema:
            type: string
            example: orun_V1StGXR8Z5jdHi6B
      responses:
        '200':
          description: The receipt.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProjectUsageReceipt'
        '400':
          description: Neither selector was given, or both were.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >
            No usage was recorded for that id in this project — which is also
            the answer for an id belonging to another project.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/projects/{project_id}/usage/thresholds:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
    get:
      tags: [Projects]
      summary: List usage thresholds
      description: >
        The spend alerts this project has set. A threshold fires the
        usage.threshold_crossed event when the project's windowed usage
        crosses it, so one is only useful alongside a webhook subscribed to
        that event.
      operationId: listProjectUsageThresholds
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: One page of thresholds.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/UsageThreshold'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
                required: [data, total, limit, offset]
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags: [Projects]
      summary: Set a usage threshold
      description: >
        Creates an alert on this project's windowed usage. Crossing it fires
        usage.threshold_crossed once per window — a calendar_month threshold
        fires at most once in a month, and a rolling_24h one re-arms when the
        windowed total falls back below 90% of the threshold, so a total
        hovering at the line does not alert repeatedly.


        Requires the admin role: an alerting rule belongs to the account that
        pays, not to one member.
      operationId: createProjectUsageThreshold
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UsageThresholdCreate'
      responses:
        '201':
          description: The threshold was created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageThreshold'
        '400':
          description: An unknown metric or window, or a threshold that is not above zero.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The caller is a `member`; an alerting rule needs `admin`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'

  /v1/projects/{project_id}/usage/thresholds/{threshold_id}:
    parameters:
      - $ref: '#/components/parameters/ProjectId'
      - name: threshold_id
        in: path
        required: true
        schema:
          type: string
        example: uth_V1StGXR8Z5jdHi6B
    delete:
      tags: [Projects]
      summary: Delete a usage threshold
      description: Stops the alert. Requires the admin role.
      operationId: deleteProjectUsageThreshold
      responses:
        '204':
          description: Deleted.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: The caller is a `member`; an alerting rule needs `admin`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'
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:
    UsageMeterType:
      name: meter_type
      in: query
      required: false
      description: >
        Narrow the rollup to one meter type (llm_tokens, compute_execution,
        api_request, storage, tool_execution). Omit to include every meter. Useful with
        group_by=model, whose dimension otherwise mixes model ids with
        platform SKUs. An unrecognized value yields an empty rollup, not an
        error.
      schema:
        type: string
        example: llm_tokens
    UsageSessionId:
      name: session_id
      in: query
      required: false
      description: >
        Narrow the whole rollup — every bucket and the top-level totals
        alike — to one session's traffic. Combines with group_by:
        group_by=day&session_id=... is one conversation's spend per day,
        group_by=model the same spend split by model. This is the figure
        behind the usage roll-up a single session read carries, broken down.
        An id naming no session in this project yields an empty rollup,
        never the project total.
      schema:
        type: string
        example: sess_V1StGXR8Z5jdHi6B
    UsageActorId:
      name: actor_id
      in: query
      required: false
      description: >
        The same narrowing, for one end user across every session they
        appear in — what a customer re-billing their own users charges each
        of them for. An id naming no actor in this project yields an empty
        rollup, never the project total.
      schema:
        type: string
        example: actor_V1StGXR8Z5jdHi6B
    UsageAgentId:
      name: agent_id
      in: query
      required: false
      description: >
        Narrow to one agent's traffic, across every session, run and
        trigger that dispatched it.
      schema:
        type: string
        example: agent_V1StGXR8Z5jdHi6B
    UsageAiProviderId:
      name: ai_provider_id
      in: query
      required: false
      description: >
        Narrow to the spend billed against one provider record — a routed
        generation's serving target, or the agent's pinned provider.
      schema:
        type: string
        example: aip_V1StGXR8Z5jdHi6B
    UsageOrchestrationRunId:
      name: orchestration_run_id
      in: query
      required: false
      description: Narrow to the events one orchestration run metered.
      schema:
        type: string
        example: orun_V1StGXR8Z5jdHi6B
    UsageOrchestrationId:
      name: orchestration_id
      in: query
      required: false
      description: >
        Narrow to the runs of one orchestration — the ones it started
        itself, never the subtree a loop or sub-orchestration node started
        under it, which is metered against the child orchestration where it
        was incurred. Additive: summed across a project's orchestrations
        the figures reach the project total exactly once.
      schema:
        type: string
        example: orch_V1StGXR8Z5jdHi6B
    UsageGenerationId:
      name: generation_id
      in: query
      required: false
      description: >
        Narrow to one generation's events. A generation writes more than
        one when it meters several dimensions, such as tokens and compute.
      schema:
        type: string
        example: gen_V1StGXR8Z5jdHi6B
    UsageTraceId:
      name: trace_id
      in: query
      required: false
      description: Narrow to the events recorded under one trace.
      schema:
        type: string
        example: trace_V1StGXR8Z5jdHi6B
    UsageSource:
      name: source
      in: query
      required: false
      description: >
        Narrow by what the spend was incurred for — eval and eval_judge are
        verification spend. Ordinary traffic carries no source, so it
        cannot be selected by this filter; omit the filter for everything.
      schema:
        type: string
        example: eval
    UsageTriggerId:
      name: trigger_id
      in: query
      required: false
      description: >
        Narrow to the spend one trigger initiated. Matched as recorded
        rather than resolved, so it still selects the spend of a trigger
        that has since been deleted.
      schema:
        type: string
        example: trig_V1StGXR8Z5jdHi6B
    UsageActionId:
      name: action_id
      in: query
      required: false
      description: >
        Narrow to one caller-supplied action label, for per-action spend.
        Matched as recorded, like trigger_id.
      schema:
        type: string
    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
    Force:
      name: force
      in: query
      required: false
      description: >
        When true, delete the resource together with its dependents instead of
        returning 409. Destructive and irreversible.
      schema:
        type: boolean
        default: false
    ProjectId:
      name: project_id
      in: path
      required: true
      description: Project public ID (proj_ prefix).
      schema:
        type: string
        example: proj_V1StGXR8Z5jdHi6B
  responses:
    Unauthorized:
      description: Missing or invalid credentials.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    BadRequest:
      description: The request was malformed or failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: >-
        The credential is scoped to a different project, or the caller's role in
        the project does not carry this action.
      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:
    Project:
      type: object
      properties:
        id:
          type: string
          description: Public project ID (proj_ prefix).
          example: proj_V1StGXR8Z5jdHi6B
        name:
          type: string
          example: acme-perpetual
        status:
          type: string
          enum: [active, archived]
          example: active
        role:
          $ref: '#/components/schemas/ProjectRole'
        owner_user_id:
          type: string
          description: >
            The user who pays for the project — its billing owner, and the one
            member holding the `owner` role. Membership decides who may act in a
            project; this decides who is charged for it.
          example: user_V1StGXR8Z5jdHi6B
        trace_content_retention_days:
          type: integer
          nullable: true
          minimum: 1
          description: >
            How long trace and generation content is kept before a daily sweep
            purges it. `null` (the default) disables retention, so content is
            kept until purged on demand. A swept record is content-purged the
            same way an on-demand purge does it — the row survives as an
            auditable skeleton with `content_redacted_at` set.
          example: null
        trace_content_mode:
          type: string
          enum: [full, none]
          description: >
            Whether trace and generation content is persisted at all. `full`
            (the default) stores it; `none` is zero-retention — content is
            never written, for every agent in this project. `none` is a floor,
            not a default: an agent may tighten itself to `none`, but cannot
            loosen a `none` project back to `full`.
          example: full
        max_concurrent_runs:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Orchestration runs of this project driven at once. `null` (the
            default) is unlimited. Enforced when a run is claimed — runs past
            the limit wait for a slot rather than failing.
          example: null
        max_chain_generations:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Generations one continuation chain in this project may hold before
            it stops being resumed. `null` (the default) leaves the
            platform-wide ceiling in force. The budget actually applied is the
            smallest of that ceiling, this number, and the agent's own — an
            agent author can be stricter than this, never looser.
          example: null
        max_orchestration_run_depth:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Nesting levels a run tree in this project may reach before the next
            child is refused. `null` (the default) leaves the platform-wide
            bound in force; the bound applied is the smaller of the two.
          example: null
        require_priced_model:
          type: boolean
          description: >
            Whether a generation whose model carries no price is refused before
            the provider is called. `false` by default, which runs the model and
            meters it at no cost. Mainly of use with your own providers — see
            [Requiring a priced model](/docs/modules/projects#requiring-a-priced-model).
          example: false
        guardrail_ids:
          type: array
          items:
            type: string
          description: >
            Guardrails attached at the project scope — the floor under every
            tool call by every agent in the project, including tools added
            later. Empty by default. See
            [Attaching one](/docs/modules/guardrails#attaching-one).
          example: []
        paused_at:
          type: string
          format: date-time
          nullable: true
          description: >
            When the project was paused; `null` while it runs. See
            [Pausing a project](/docs/modules/projects#pausing-a-project).
          example: null
        pause_reason:
          type: string
          nullable: true
          maxLength: 256
          description: >
            The reason the pause named; `null` while the project runs, or when
            the pause named none.
          example: null
        managed_conversion:
          type: boolean
          description: >
            Whether scanned PDFs, images and audio are converted to text on
            ingest with no setup. `true` by default. See
            [Managed conversion](/docs/modules/documents#managed-conversion).
          example: true
        created_at:
          type: string
          format: date-time
          example: '2026-07-17T00:00:00.000Z'
        updated_at:
          type: string
          format: date-time
          example: '2026-07-17T00:00:00.000Z'
      required:
        - id
        - name
        - status
        - role
        - owner_user_id
        - trace_content_retention_days
        - trace_content_mode
        - max_concurrent_runs
        - max_chain_generations
        - max_orchestration_run_depth
        - require_priced_model
        - guardrail_ids
        - managed_conversion
        - paused_at
        - pause_reason
        - created_at
        - updated_at
    ProjectPause:
      type: object
      additionalProperties: false
      properties:
        reason:
          type: string
          maxLength: 256
          description: >
            Why the project is paused. Stored as `pause_reason` and carried onto
            every run and task the pause parks.
          example: anomaly detected by the spend monitor
    ProjectRole:
      type: string
      enum: [owner, admin, member]
      description: >
        What the member may do in this project. `owner` — everything, including
        deleting it and paying for it; exactly one per project. `admin` —
        everything a `member` may do, plus managing who else is in the project
        and how the project itself is configured. `member` — every read and
        write on the project's own resources, including minting project API
        keys.

        When returned on a `Project`, this is *your* role, not a property of the
        project: it is what lets a client hide an action the request would refuse.
      example: owner
    ProjectMember:
      type: object
      properties:
        id:
          type: string
          description: Public membership ID (pmem_ prefix).
          example: pmem_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          example: proj_V1StGXR8Z5jdHi6B
        user_id:
          type: string
          example: user_V1StGXR8Z5jdHi6B
        email:
          type: string
          format: email
          nullable: true
          description: The member's address; null if the account no longer exists.
          example: ana@acme.com
        name:
          type: string
          nullable: true
          example: Ana Silva
        status:
          type: string
          enum: [active, pending]
          nullable: true
          description: >
            `pending` until the member has signed in for the first time,
            `active` afterwards. Derived from the account, not stored, so it
            needs nothing to run when they arrive. Null if the account no longer
            exists — there is no status to report for somebody who is never
            arriving.
          example: active
        role:
          $ref: '#/components/schemas/ProjectRole'
        invited_by_user_id:
          type: string
          nullable: true
          description: >
            Who added this member; null for the founding owner, who was added by
            the act of creating the project.
          example: null
        created_at:
          type: string
          format: date-time
          example: '2026-07-17T00:00:00.000Z'
      required: [id, project_id, user_id, email, name, status, role, invited_by_user_id, created_at]
    ProjectMemberList:
      type: object
      description: >
        The complete member list — unpaginated, because a project's membership is
        a handful of people, not a collection that grows without bound.
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ProjectMember'
      required: [data]
    GrantableProjectRole:
      type: string
      enum: [admin, member]
      description: >
        The roles a membership write may name. `owner` is absent by
        construction: it is the billing owner, so granting it would change who
        pays, and transferring that is a different act with its own decision.
      example: member
    ProjectMemberCreate:
      type: object
      required: [email, role]
      properties:
        email:
          type: string
          format: email
          description: >
            The colleague's address, lower-cased before lookup. It does not need
            an account yet.
          example: ana@acme.com
        role:
          $ref: '#/components/schemas/GrantableProjectRole'
    ProjectMemberUpdate:
      type: object
      required: [role]
      properties:
        role:
          $ref: '#/components/schemas/GrantableProjectRole'
    ProjectCreate:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Human-readable project name.
          example: acme-perpetual
        idempotency_key:
          type: string
          maxLength: 255
          description: >-
            Deduplication key, unique within your account, that makes a retry of
            an ambiguous failure safe. The first request under a key performs the
            write and answers `201`; any later request carrying the same key
            answers `200` with that same result, so a timeout you cannot
            interpret can simply be retried.


            The key is claimed for as long as the record exists and never
            silently expires. Reusing one with a different request body is
            `409 idempotency_key_reused` — a key names one request, so changing
            the body and keeping the key is a bug rather than a retry. A retry
            that arrives while the original is still in flight is
            `409 idempotency_request_in_progress`.
          example: signup-2026-09-18-acct-42
    ProjectUpdate:
      type: object
      description: At least one field must be present.
      minProperties: 1
      properties:
        name:
          type: string
          example: acme-renamed
        status:
          type: string
          enum: [active, archived]
          example: archived
        trace_content_retention_days:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Days of trace and generation content retention before the daily
            sweep purges it. Send `null` to disable retention (content is then
            kept until purged on demand). Omitting the field leaves the current
            window unchanged — `null` and absent are different instructions.


            Bounded by your plan: a window longer than the plan allows, and
            `null` on any plan that sets a window, respond `403`
            `plan_limit_reached`.
          example: 90
        trace_content_mode:
          type: string
          enum: [full, none]
          description: >
            Set `none` for zero-retention: content is never written for any
            agent in this project. Tightening to `none` does not erase content
            already on disk — purge it explicitly, or set a retention window to
            have the sweep do it.
          example: none
        max_concurrent_runs:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Orchestration runs driven at once. Send `null` to lift the limit.
            Omitting the field leaves it unchanged.
          example: 5
        max_chain_generations:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Generations one continuation chain may hold. Send `null` to drop
            back to the platform-wide ceiling. Omitting the field leaves it
            unchanged.
          example: 25
        max_orchestration_run_depth:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Nesting levels a run tree may reach. Send `null` to drop back to
            the platform-wide bound. Omitting the field leaves it unchanged.
          example: 3
        require_priced_model:
          type: boolean
          description: >
            Refuse a generation whose model carries no price, before the
            provider is called. Uncapped by plan — it only ever narrows what
            the project may spend.
          example: true
        guardrail_ids:
          type: array
          items:
            type: string
            minLength: 1
          description: >
            Guardrails attached at the project scope, replaced wholesale. Send
            `[]` to detach every one; omitting the field leaves them unchanged.
            Uncapped by plan — a guardrail can only tighten what runs.
          example: [guard_V1StGXR8Z5jdHi6B]
        managed_conversion:
          type: boolean
          description: >
            Turn managed conversion off or back on. Off removes the managed
            ingestion rules; rules of your own stay.
          example: false
    UsageTokens:
      type: object
      description: Token counts (input_tokens already includes cached input).
      properties:
        input_tokens:
          type: integer
          example: 120000
        output_tokens:
          type: integer
          example: 45000
        cached_tokens:
          type: integer
          example: 30000
        total_tokens:
          type: integer
          description: input_tokens + output_tokens.
          example: 165000
      required: [input_tokens, output_tokens, cached_tokens, total_tokens]
    UsageComponent:
      type: object
      description: >
        One measured amount inside a bucket. The token counts describe LLM usage
        alone, so this is where a bucket metering storage, requests or compute
        execution reports what it actually measured — without it such a bucket
        would read as all zeros, indistinguishable from one that measured
        nothing.
      properties:
        component:
          type: string
          description: >
            The measured dimension — input_tokens, cached_tokens, output_tokens,
            reasoning_tokens, compute_second, request, gb_day, …

            Components are not always disjoint. `reasoning_tokens` is the part
            of `output_tokens` a reasoning model spent thinking: it is reported
            for visibility and priced as output, so its own `cost_usd` is null
            to keep it from being counted twice. On a model that reasons by
            default it can be most of the output — and most of the bill — for a
            short answer.
          example: gb_day
        unit:
          type: string
          description: The unit quantity is measured in.
          example: gb_day
        quantity:
          type: number
          description: >
            The summed measured amount, in unit. Fractional for measures like
            GB-days and compute seconds.
          example: 0.4
        cost_usd:
          type: number
          nullable: true
          description: >
            Billing-grade cost in USD for this component; null when it was not
            priced, or when another component already prices it (as
            `output_tokens` prices `reasoning_tokens`). The quantity is still
            measured — null never means free.
          example: null
      required: [component, unit, quantity, cost_usd]
    UsageComponents:
      type: object
      description: The measured amounts in a bucket, one entry per component.
      properties:
        components:
          type: array
          description: >
            Sorted by component, then unit. Empty when nothing was measured in
            the bucket.
          items:
            $ref: '#/components/schemas/UsageComponent'
      required: [components]
    UsageGroup:
      allOf:
        - type: object
          properties:
            key:
              type: string
              nullable: true
              description: >
                The bucket's value in the chosen dimension (model, meter type,
                source, AI provider/agent/run/session/actor id, or YYYY-MM-DD
                day); null when it does not apply. Under `group_by=model` this
                is the model name, the same string `GET /v1/models` lists and
                an agent is configured with; under `group_by=ai_provider` it is
                the provider id.
              example: nova-lite-v1
            ai_provider_id:
              type: string
              nullable: true
              description: >
                The AI provider that served the bucket's model, under
                `group_by=model`; null on every other dimension, including
                `group_by=ai_provider`, where the provider is `key`. The model
                dimension buckets on the model *and* its provider, so one model
                served by two providers is two groups — this is what tells them
                apart. The groups still sum to the totals.
              example: aip_V1StGXR8Z5jdHi6B
            cost_usd:
              type: number
              nullable: true
              description: Billing-grade cost in USD; null when nothing in the bucket was priced.
              example: 1.23
            event_count:
              type: integer
              description: >
                Metered events in the bucket. Cost alone does not say whether a
                bucket is one expensive call or a thousand cheap ones, and it
                is not a count of generations: one generation writes an event
                per dimension it meters.
              example: 42
          required: [key, ai_provider_id, cost_usd, event_count]
        - $ref: '#/components/schemas/UsageTokens'
        - $ref: '#/components/schemas/UsageComponents'
    ProjectUsageGroups:
      type: object
      description: >
        One page of buckets, one entry per distinct value in the chosen
        dimension. The rollup grows with the window — day buckets per day,
        model per model served — so the buckets are paged while total counts the
        whole window. Ordered by cost_usd descending, ties broken by key then
        ai_provider_id ascending, nulls last: the first page is the biggest
        spenders, and paging never repeats or skips a bucket.
      required: [data, total, limit, offset]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/UsageGroup'
        total:
          type: integer
          description: >
            Distinct buckets in the window, independent of limit, including a
            null bucket for the events the chosen dimension does not apply to.
            It is a count of buckets and not of anything they describe: under
            group_by=run a project that runs no orchestration has exactly one
            bucket whatever its volume. How many runs an account made is
            GET /v1/users/me/usage.
          example: 12
        limit:
          type: integer
          example: 50
        offset:
          type: integer
          example: 0
    UsageEventComponent:
      type: object
      description: >
        One priced dimension of a usage event. Every meter type is expressed
        as components, so tokens and infra read uniformly: an llm_tokens event
        carries input_tokens / output_tokens (and cached_tokens, plus a
        non-billable reasoning_tokens detail), a compute_execution event one
        compute_second.
      properties:
        component:
          type: string
          description: The measured dimension — input_tokens, compute_second, gb_day, …
          example: input_tokens
        quantity:
          type: number
          description: The measured amount, in unit.
          example: 1200
        unit:
          type: string
          example: token
        billable:
          type: boolean
          description: >
            Whether this component contributes to cost. Non-billable details —
            reasoning_tokens, which is a subset of output_tokens — are never
            priced and never double-counted into the totals.
        unit_price:
          type: number
          nullable: true
          description: USD per unit, frozen at write time; null when unpriced.
        cost_usd:
          type: number
          nullable: true
          description: quantity x unit_price, frozen at write time; null when unpriced.
        price_id:
          type: string
          nullable: true
          description: The price-book row that priced this component.
      required: [component, quantity, unit]

    ProjectUsageEvent:
      type: object
      description: >
        One metered occurrence — a completed model call, a node execution, a
        storage sample. Attribution and total cost live here; the measured
        amounts live in components.
      properties:
        id:
          type: string
          example: ue_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          x-naturali-ref: projects
        generation_id:
          type: string
          nullable: true
          x-naturali-ref: generations
        trace_id:
          type: string
          nullable: true
          x-naturali-ref: traces
        agent_id:
          type: string
          nullable: true
          x-naturali-ref: agents
        actor_id:
          type: string
          nullable: true
          x-naturali-ref: actors
          description: >
            The end user the occurrence was produced for, frozen at write
            time. Null when no end user is behind the work — orchestration
            runs, triggers, direct API generations.
        session_id:
          type: string
          nullable: true
          x-naturali-ref: sessions
          description: >
            The session the occurrence ran in, frozen at write time. Null for
            work not dispatched through a session.
        orchestration_run_id:
          type: string
          nullable: true
          x-naturali-ref: orchestration-runs
        node_id:
          type: string
          nullable: true
          description: The orchestration node within the run, when applicable.
        ai_provider_id:
          type: string
          nullable: true
          x-naturali-ref: ai-providers
          description: >
            The provider billed — the target a model route picked for the
            turn, or the agent's pinned provider. Null if the provider was
            since deleted; the provider/model snapshot still records what was
            billed.
        trigger_id:
          type: string
          nullable: true
          x-naturali-ref: triggers
        action_id:
          type: string
          nullable: true
          description: The caller-supplied action label, when one was given.
        meter_type:
          type: string
          description: llm_tokens, compute_execution, api_request, storage or tool_execution.
          example: llm_tokens
        source:
          type: string
          nullable: true
          description: >
            What the spend was incurred for. eval is an eval run's item
            generations and eval_judge an llm_judge scorer's own completion,
            so running a suite is priced apart from grading it. embedding is
            any embedding call. Null for ordinary agent traffic.
          example: null
        provider:
          type: string
          nullable: true
          description: >
            The vendor the SKU was billed against, retained even if the
            provider record is deleted. Platform meters — storage, requests,
            compute execution — are billed by naturali itself and read
            "naturali".
          example: bedrock
        model:
          type: string
          nullable: true
          description: >
            The model, or the billable SKU for a platform meter. On the
            managed offering this is the same public name GET /v1/models
            lists; a provider you brought yourself keeps the string that
            vendor uses.
          example: nova-lite-v1
        cost_usd:
          type: number
          nullable: true
          description: >
            Total USD cost, the sum of the priced components, frozen at write
            time. Null when nothing was priced.
        components:
          type: array
          items:
            $ref: '#/components/schemas/UsageEventComponent'
        created_at:
          type: string
          format: date-time
      required: [id, project_id, meter_type, provider, model, cost_usd, components, created_at]

    ProjectUsageEventPage:
      type: object
      description: >
        One page of events. total counts every event the narrowing matched,
        independent of limit.
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/ProjectUsageEvent'
        total:
          type: integer
          example: 1280
        limit:
          type: integer
          example: 50
        offset:
          type: integer
          example: 0
      required: [data, total, limit, offset]

    ProjectUsageReceipt:
      type: object
      description: >
        The itemisation of one generation or one orchestration run. Exactly
        one of generation_id / orchestration_run_id is present, matching the
        selector that was asked for.
      properties:
        generation_id:
          type: string
          x-naturali-ref: generations
          description: Present on a per-generation receipt.
        orchestration_run_id:
          type: string
          x-naturali-ref: orchestration-runs
          description: Present on an orchestration-run receipt.
        currency:
          type: string
          example: USD
        line_items:
          type: array
          description: >
            One line per usage event — for a generation receipt the events on
            that generation, for a run receipt every event across the run.
          items:
            type: object
            properties:
              event_id:
                type: string
                example: ue_V1StGXR8Z5jdHi6B
              meter_type:
                type: string
                example: llm_tokens
              provider:
                type: string
                nullable: true
                description: >
                  As on an event: the vendor billed, or "naturali" for a
                  platform meter.
                example: bedrock
              model:
                type: string
                nullable: true
                description: >
                  As on an event, with one caveat: a line carries no provider
                  of its own, so the public name is resolved by reading the
                  events behind the receipt. Where that read cannot cover
                  every line, all of them keep the string the meter recorded
                  rather than some being renamed and some not.
                example: nova-lite-v1
              node_id:
                type: string
                nullable: true
                description: >
                  The orchestration node that produced the event. On a run
                  receipt every line carries it, so grouping by node_id gives
                  the per-node cost the total hides. A retried node
                  contributes one line per attempt — a retry is real money.
                  Null when no node produced the event.
              cost_usd:
                type: number
                nullable: true
              components:
                type: array
                items:
                  $ref: '#/components/schemas/UsageEventComponent'
            required: [event_id, meter_type, provider, model, cost_usd, components]
        by_meter_type:
          type: array
          description: >
            Per-meter-type cost rollup — the tokens-and-infra split. A
            single-type receipt has one entry whose cost equals the total.
          items:
            type: object
            properties:
              meter_type:
                type: string
              cost_usd:
                type: number
                nullable: true
            required: [meter_type, cost_usd]
        totals:
          type: object
          description: Token counts and cost summed across the line items.
          properties:
            cost_usd:
              type: number
              nullable: true
              description: Sum of the priced components; null when nothing was priced.
            input_tokens:
              type: integer
              description: Full prompt tokens, cached input included.
            output_tokens:
              type: integer
            cached_tokens:
              type: integer
            reasoning_tokens:
              type: integer
              description: >
                The part of output_tokens a reasoning model spent thinking.
                Reported for visibility and priced as output, so it is never
                counted twice.
      required: [currency, line_items, by_meter_type, totals]

    UsageThreshold:
      type: object
      description: An alert on this project's windowed usage.
      properties:
        id:
          type: string
          example: uth_V1StGXR8Z5jdHi6B
        project_id:
          type: string
          x-naturali-ref: projects
        metric:
          type: string
          enum: [cost_usd, tokens]
          description: >
            What is measured — cost_usd across every meter type, or tokens
            (input + output + cached).
        window:
          type: string
          enum: [calendar_month, rolling_24h]
          description: >
            The current UTC calendar month, or the trailing 24 hours.
        threshold:
          type: number
          description: The value the windowed aggregate must cross to fire.
          example: 250
        last_fired_at:
          type: string
          format: date-time
          nullable: true
          description: When it last fired; null until the first time.
        fired_window_key:
          type: string
          nullable: true
          description: >
            The YYYY-MM window of the last fire, which is what keeps a
            calendar_month threshold to one alert per month. Null for
            rolling_24h and before the first fire.
          example: null
        created_at:
          type: string
          format: date-time
      required: [id, project_id, metric, window, threshold]

    UsageThresholdCreate:
      type: object
      properties:
        metric:
          type: string
          enum: [cost_usd, tokens]
        window:
          type: string
          enum: [calendar_month, rolling_24h]
        threshold:
          type: number
          description: Must be greater than zero.
          example: 250
      required: [metric, window, threshold]

    ProjectUsageFilters:
      type: object
      description: >
        Every narrowing this meter knows, echoed back exactly as the caller
        sent it and null when unset. Echoed because a rollup of zeros is
        otherwise indistinguishable from a project that spent nothing, and
        always complete so a caller reading one key can tell "not narrowed"
        from "this meter does not know that narrowing". Grouped rather than
        spread across the top level: at twelve they would outnumber the
        figures, and a top-level ai_provider_id would sit beside a per-bucket
        ai_provider_id that means something else.


        The eight naming a resource (session_id, actor_id, agent_id,
        ai_provider_id, orchestration_run_id, orchestration_id, generation_id,
        trace_id) are resolved against the project and empty the rollup when
        they name nothing in it; the rest are matched against the value the
        event recorded.


        meter_type, session_id and actor_id also appear at the top level,
        where they were the response's only narrowing echo before the others
        existed.
      properties:
        meter_type:
          type: string
          nullable: true
        session_id:
          type: string
          nullable: true
          x-naturali-ref: sessions
        actor_id:
          type: string
          nullable: true
          x-naturali-ref: actors
        agent_id:
          type: string
          nullable: true
          x-naturali-ref: agents
        ai_provider_id:
          type: string
          nullable: true
          x-naturali-ref: ai-providers
        orchestration_run_id:
          type: string
          nullable: true
          x-naturali-ref: orchestration-runs
        orchestration_id:
          type: string
          nullable: true
          x-naturali-ref: orchestrations
        generation_id:
          type: string
          nullable: true
          x-naturali-ref: generations
        trace_id:
          type: string
          nullable: true
          x-naturali-ref: traces
        source:
          type: string
          nullable: true
        trigger_id:
          type: string
          nullable: true
        action_id:
          type: string
          nullable: true
      required:
        [
          meter_type,
          session_id,
          actor_id,
          agent_id,
          ai_provider_id,
          orchestration_run_id,
          orchestration_id,
          generation_id,
          trace_id,
          source,
          trigger_id,
          action_id,
        ]

    ProjectUsageDistinct:
      type: object
      nullable: true
      description: >
        Present only when include=distinct was sent; null otherwise — never
        zeroes for a rollup that did not compute it, because a counter nobody
        asked for must not read as "none".


        How many distinct entities of each kind the window touched — the
        counters a "how many generations / runs / end users this cycle"
        question reads, and what groups.total is not: bucket cardinality
        counts buckets, and every dimension keeps a null bucket for the events
        it does not apply to.


        Nulls are not counted here: work with no end user behind it
        contributes to event_count and to neither actors nor sessions, and a
        standalone generation counts under generations and not under
        orchestration_runs.


        None of these add up. Two adjacent windows' sessions overlap wherever
        a session spans the boundary, and a run that straddles midnight is in
        both days. A wider figure is a wider query, never a sum of narrower
        ones.
      properties:
        generations:
          type: integer
          example: 812
        traces:
          type: integer
        orchestration_runs:
          type: integer
        agents:
          type: integer
        actors:
          type: integer
        sessions:
          type: integer
        ai_providers:
          type: integer

    ProjectUsage:
      allOf:
        - type: object
          properties:
            project_id:
              type: string
              x-naturali-ref: projects
              example: proj_V1StGXR8Z5jdHi6B
            window:
              type: object
              description: The [from, to] bounds applied, echoed back (null = unbounded).
              properties:
                from:
                  type: string
                  format: date-time
                  nullable: true
                  example: null
                to:
                  type: string
                  format: date-time
                  nullable: true
                  example: null
              required: [from, to]
            group_by:
              type: string
              enum: [model, ai_provider, agent, run, day, meter_type]
              example: model
            meter_type:
              type: string
              nullable: true
              description: The meter-type filter applied, echoed back; null when unfiltered.
              example: null
            session_id:
              type: string
              nullable: true
              x-naturali-ref: sessions
              description: >
                The session the rollup was narrowed to, echoed back; null when
                unnarrowed. Always present, so a caller can tell an unnarrowed
                rollup from one this meter did not narrow.
              example: null
            actor_id:
              type: string
              nullable: true
              x-naturali-ref: actors
              description: The actor the rollup was narrowed to, echoed back; null when unnarrowed.
              example: null
            filters:
              $ref: '#/components/schemas/ProjectUsageFilters'
            distinct:
              $ref: '#/components/schemas/ProjectUsageDistinct'
            event_count:
              type: integer
              description: >
                Metered events in the whole window, however many buckets were
                paged through.
              example: 1280
            cost_usd:
              type: number
              nullable: true
              description: Total billing-grade cost in USD; null when nothing was priced.
              example: 1.23
            groups:
              $ref: '#/components/schemas/ProjectUsageGroups'
          required:
            [
              project_id,
              window,
              group_by,
              meter_type,
              session_id,
              actor_id,
              filters,
              distinct,
              event_count,
              cost_usd,
              groups,
            ]
        - $ref: '#/components/schemas/UsageTokens'
        - $ref: '#/components/schemas/UsageComponents'
    ProjectList:
      type: object
      required: [data, next_cursor]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Project'
        next_cursor:
          type: string
          nullable: true
          description: Cursor for the next page, or null at the end.
          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.
