openapi: 3.0.3
info:
  title: SOAT Agent Sessions API
  version: 1.0.0
  description: >
    Agent Sessions provide a simplified 1-user ↔ 1-agent conversational
    abstraction. Each session belongs to an agent (set via agent_id) and manages
    the underlying conversation and generation plumbing automatically. The end
    user behind the session is not created for you — pass actor_id to attach an
    existing actor.
  contact:
    name: SOAT Team
    url: https://github.com/ttoss/soat
servers:
  - url: '{baseUrl}'
    description: Base URL of your SOAT deployment (e.g. https://your-soat.com or http://localhost:5047)
    variables:
      baseUrl:
        description: The base URL of your SOAT deployment
        default: http://localhost:5047
tags:
  - name: Sessions
    description: Manage agent sessions
security:
  - bearerAuth: []
paths:
  /api/v1/sessions:
    post:
      tags:
        - Sessions
      summary: Create a session
      description: >
        Creates a new session for the specified agent, along with the underlying
        conversation, so the caller only needs this single call to start
        interacting with the agent. No actor is created: pass `actor_id` to
        attach an existing actor as the session's end user. When it is omitted
        the session has no actor, and generations in it carry no end-user
        attribution — they are not billed to an actor in the usage meter and
        they match no `actor`-scoped quota.
      operationId: createSession
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateSessionRequest'
      responses:
        '201':
          description: Session created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionRecord'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
        '409':
          description: An open session already exists for this actor (single_session_per_actor is enabled)
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
              example:
                error:
                  code: SINGLE_SESSION_CONFLICT
                  message: An open session already exists for this actor.
                  hint: >-
                    The agent allows one open session per actor. Reuse the
                    session named in `meta.session_id`, or close it first.
                  docs_url: >-
                    https://soat.ttoss.dev/docs/error-codes#single_session_conflict
                  meta:
                    session_id: sess_abc123
    get:
      tags:
        - Sessions
      summary: List sessions
      description: Returns sessions the caller can access, optionally filtered by agent, actor and status.
      operationId: listSessions
      parameters:
        - name: project_id
          in: query
          required: false
          description: >
            Filter by project public ID. Required for `soat:ResourceTag`
            policy conditions to narrow the listing: a policy is read per
            project, so without it the listing is scoped to the projects the
            caller can access and nothing further.
          schema:
            type: string
            example: proj_V1StGXR8Z5jdHi6B
        - name: agent_id
          in: query
          required: false
          description: Filter by agent public ID
          schema:
            type: string
            example: agent_V1StGXR8Z5jdHi6B
        - name: actor_id
          in: query
          required: false
          description: Filter by actor public ID
          schema:
            type: string
        - name: status
          in: query
          required: false
          description: >-
            Filter by session status. Repeat the parameter to OR values. A value
            outside the enum, empty string included, is a `400`.
          schema:
            type: array
            items:
              type: string
              enum: [open, closed, expired]
          style: form
          explode: true
        - $ref: './tags.yaml#/components/parameters/TagsQuery'
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: Paginated list of sessions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SessionRecord'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'

  /api/v1/sessions/{session_id}:
    get:
      tags:
        - Sessions
      summary: Get a session
      description: >-
        Returns details of a single session, including a `usage` roll-up of what
        its generations cost. The listing omits `usage`; for a window, a split
        by day or model, or one end user across every session, narrow the usage
        aggregate with `session_id` / `actor_id` instead.
      operationId: getSession
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Session details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionRecord'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
    patch:
      tags:
        - Sessions
      summary: Update a session
      description: Updates the session name and/or status.
      operationId: updateSession
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateSessionRequest'
      responses:
        '200':
          description: Updated session
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionRecord'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
    delete:
      tags:
        - Sessions
      summary: Delete a session
      description: >
        Deletes the session and its underlying conversation and messages. The
        session's actor is not deleted. Generations and traces produced by the
        session are not deleted either, since they are not linked to the
        session or conversation.
      operationId: deleteSession
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '204':
          description: Session deleted
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'

  /api/v1/sessions/{session_id}/messages:
    post:
      tags:
        - Sessions
      summary: Add a user message
      description: >
        Saves a user message to the session. When `auto_generate` is enabled on
        the session and no generation is currently in progress, generation is
        triggered automatically and the response is the generated turn, the
        same body `?wait=true` generate answers. Otherwise returns the saved
        user message.
      operationId: addSessionMessage
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - $ref: './idempotency.yaml#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AddSessionMessageRequest'
      responses:
        '200':
          description: Duplicate request — original message returned (idempotency_key matched)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddSessionMessageSaved'
        '201':
          description: User message saved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AddSessionMessageResponse'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
        '409':
          description: >-
            `IDEMPOTENCY_KEY_REUSED` — the key is already claimed by a message
            in this session sent from a different request. No message is saved.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '429':
          description: >-
            `QUOTA_EXCEEDED`: the session auto-generates and an `enforce`-mode
            generation quota is exhausted. `error.meta` carries `quota_id`,
            `metric`, `limit`, `window` and `resets_at`, with a `Retry-After`
            header. The message is saved. A session with
            `message_delay_seconds` answers `201` before the quota is read.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/sessions/{session_id}/generate:
    post:
      tags:
        - Sessions
      summary: Trigger agent generation
      description: >
        Triggers the agent to generate a response based on the current
        conversation. Background by default: returns `202 Accepted`
        immediately while the generation runs. Pass ?wait=true to block and
        receive the assistant reply (or a requires_action status if the agent
        needs client tool outputs) in the response.
      operationId: generateSessionResponse
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - name: wait
          in: query
          required: false
          # Pinned to `true` for tool calls; see the agents generate operation.
          x-soat-tool-forced: true
          description: When omitted or `false` (default), generation runs in the background and `202 Accepted` is returned immediately. Pass `true` to block until the generation settles and receive the result. A tool call, `builtin` or MCP, always waits.
          schema:
            type: boolean
            default: false
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerateSessionRequest'
      responses:
        '200':
          description: Agent reply or requires_action (only when `?wait=true`)
          content:
            application/json:
              schema:
                $ref: './conversations.yaml#/components/schemas/GenerateConversationMessageResponse'
        '202':
          description: Generation accepted and running in the background (default, when `wait` is omitted or `false`)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    enum: [accepted]
                  session_id:
                    type: string
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
        '409':
          description: Generation already in progress
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '410':
          description: Session has expired due to inactivity
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '429':
          description: >-
            `QUOTA_EXCEEDED`: an `enforce`-mode generation quota is exhausted.
            `error.meta` carries `quota_id`, `metric`, `limit`, `window` and
            `resets_at`, with a `Retry-After` header. Only with `?wait=true`:
            a background call is answered `202` before the quota is read.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
        '502':
          description: >
            Upstream AI provider error (AI_PROVIDER_ERROR). The error `meta`
            includes the `generation_id` and `trace_id` of the failed
            generation for post-mortem debugging via GET /api/v1/generations/{generation_id}.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/sessions/{session_id}/tool-outputs:
    post:
      tags:
        - Sessions
      summary: Submit tool outputs
      description: >
        Submits client tool outputs for a generation that returned
        requires_action. The agent continues its loop and returns the
        final or next requires_action result. A generation that did not
        run in the session's conversation is 404 (GENERATION_NOT_FOUND)
        and receives no outputs.
      operationId: submitSessionToolOutputs
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitSessionToolOutputsRequest'
      responses:
        '200':
          description: Generation result
          content:
            application/json:
              schema:
                $ref: './conversations.yaml#/components/schemas/GenerateConversationMessageResponse'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
        '409':
          description: >
            The generation is not paused on client tool calls
            (GENERATION_NOT_AWAITING_TOOL_OUTPUTS): it never paused, or its
            outputs were already submitted. Each pause accepts outputs once.
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'

  /api/v1/sessions/{session_id}/fork:
    post:
      tags:
        - Sessions
      summary: Fork a session
      description: >
        Branches a new session from a point in this session's history: same
        context, different continuation.


        The fork gets its own conversation whose messages **reference the same
        documents** as the parent rather than copying them, so there is one
        stored copy of the content and a retention purge erases it from both.
        Recorded tool results ride along on those messages and are **replayed**
        as model input on the fork's next turn — forking never re-invokes a
        tool, so exploring a "what if" cannot send an email or charge a card a
        second time. The consequence to accept is that a forked turn sees the
        tool data as it was, not as it is now.


        The fork is created **inert**: `auto_generate` is false and no
        generation is triggered. Drive it with the normal message and generate
        endpoints. The fork has no actor — attach one only if the branch is
        meant to be driven by the same end user, since
        `single_session_per_actor` agents allow one open session per actor.
      operationId: forkSession
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ForkSessionRequest'
      responses:
        '201':
          description: Fork created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionRecord'
        '400':
          description: >-
            `fork_at_position` names no message in the parent conversation, or
            `agent_id` is unknown or belongs to another project
          content:
            application/json:
              schema:
                $ref: './errors.yaml#/components/schemas/ErrorResponse'
              example:
                error:
                  code: VALIDATION_FAILED
                  message: fork_at_position 9 does not exist in the parent conversation.
                  hint: >-
                    Fix the request and retry. Unknown fields are rejected
                    outright, so compare the payload against the operation in
                    `/openapi.json`; `meta` names the offending field when the
                    check can identify one.
                  docs_url: 'https://soat.ttoss.dev/docs/error-codes#validation_failed'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'

  /api/v1/sessions/{session_id}/forks:
    get:
      tags:
        - Sessions
      summary: List a session's forks
      description: >
        Returns the sessions forked directly from this one. One level of
        lineage: a fork of a fork is listed under its own parent.
      operationId: listSessionForks
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
        - $ref: './pagination.yaml#/components/parameters/Limit'
        - $ref: './pagination.yaml#/components/parameters/Offset'
      responses:
        '200':
          description: Paginated list of forks
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/SessionRecord'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'

  /api/v1/sessions/{session_id}/tags:
    get:
      tags:
        - Sessions
      summary: Get session tags
      description: Returns the session's tags object.
      operationId: getSessionTags
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      responses:
        '200':
          description: Session tags
          content:
            application/json:
              schema:
                $ref: './tags.yaml#/components/schemas/TagBag'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
    put:
      tags:
        - Sessions
      summary: Replace session tags
      description: Replaces all tags on the session.
      operationId: replaceSessionTags
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: './tags.yaml#/components/schemas/TagBag'
      responses:
        '200':
          description: Updated tags
          content:
            application/json:
              schema:
                $ref: './tags.yaml#/components/schemas/TagBag'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'
    patch:
      tags:
        - Sessions
      summary: Merge session tags
      description: Merges the provided tags into the session's existing tags.
      operationId: mergeSessionTags
      x-soat-resource:
        kind: session
        from: session_id
      parameters:
        - $ref: '#/components/parameters/SessionId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: './tags.yaml#/components/schemas/TagBag'
      responses:
        '200':
          description: Updated tags
          content:
            application/json:
              schema:
                $ref: './tags.yaml#/components/schemas/TagBag'
        '401':
          $ref: './errors.yaml#/components/responses/Unauthorized'
        '403':
          $ref: './errors.yaml#/components/responses/Forbidden'
        '404':
          $ref: './errors.yaml#/components/responses/NotFound'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: JWT token or sk_ api key

  parameters:
    SessionId:
      name: session_id
      in: path
      required: true
      description: Session public ID
      schema:
        type: string
        example: sess_V1StGXR8Z5jdHi6B

  schemas:
    SessionRecord:
      type: object
      properties:
        id:
          type: string
          description: Session public ID
          example: sess_V1StGXR8Z5jdHi6B
        agent_id:
          x-soat-ref: agents
          type: string
          description: >
            Agent public ID, kept after a shared agent is deleted by its owner.
          example: agent_V1StGXR8Z5jdHi6B
        conversation_id:
          x-soat-ref: conversations
          type: string
          description: Underlying conversation public ID
          example: conv_V1StGXR8Z5jdHi6B
        status:
          type: string
          enum: [open, closed, expired]
          example: open
        name:
          type: string
          nullable: true
          example: Support chat
        actor_id:
          x-soat-ref: actors
          type: string
          nullable: true
          description: >
            Public ID of the user actor, or null when the session was created
            without one
          example: actor_V1StGXR8Z5jdHi6B
        tags:
          $ref: './tags.yaml#/components/schemas/TagBag'
        auto_generate:
          type: boolean
          default: false
          description: When true, automatically triggers generation after each user message (if no generation is in progress).
        generating_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp when the current generation started, or null if not generating.
        created_at:
          type: string
          format: date-time
        updated_at:
          type: string
          format: date-time
        inactivity_ttl_seconds:
          type: integer
          default: 0
          description: Number of seconds of inactivity after which the session expires. 0 means the session never expires.
          example: 300
        message_delay_seconds:
          type: integer
          nullable: true
          default: null
          description: >
            Number of seconds to wait after the last user message before sending to the LLM.
            Acts as a debounce: each new message resets the timer. null or absent means no delay (immediate processing).
          example: 3
        max_context_messages:
          type: integer
          nullable: true
          default: null
          description: >
            Maximum number of recent conversation messages sent to the model on
            each generation. Takes precedence over the agent's
            `max_context_messages`; null defers to it. A fork keeps its
            parent's value.
          example: 20
        last_activity_at:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the last activity on the session (message added or response generated).
        forked_from_session_id:
          x-soat-ref: sessions
          type: string
          nullable: true
          description: >
            Public ID of the session this one was forked from, or null when it
            was not forked. Also null once that parent is deleted — a fork
            survives its parent and keeps its own history.
          example: sess_V1StGXR8Z5jdHi6B
        forked_from_position:
          type: integer
          nullable: true
          description: >
            The parent conversation position this session branched after, or
            null when it is not a fork or was forked at the tip.
          example: 7
        usage:
          allOf:
            - $ref: './usage-totals.yaml#/components/schemas/UsageTotals'
          description: >-
            What the session's generations cost: token counts and `cost_usd`
            summed across every metered generation dispatched through it.
            Present on the single-session read; omitted from session and fork
            list responses.


            A fork is a session of its own, so it starts at zero rather than
            inheriting what the history it copied cost — summing `usage` across
            a session and its forks therefore never double-counts. Work the
            platform does around a session without a generation of its own (a
            memory extraction pass, for instance) is metered on the project, not
            here.

    ForkSessionRequest:
      type: object
      additionalProperties: false
      properties:
        fork_at_position:
          type: integer
          minimum: 0
          description: >
            The parent conversation `position` to branch after. Messages at
            positions 0..N are carried into the fork. Omit it to branch at the
            tip (the whole history).
          example: 7
        agent_id:
          x-soat-ref: agents
          type: string
          description: >
            Agent the fork runs against. Defaults to the parent session's
            agent; overriding it is the point of forking — same context, a
            different agent or agent version. Must belong to the same project
            as the session being forked.
          example: agent_V1StGXR8Z5jdHi6B
        name:
          type: string
          description: Optional name for the forked session
          example: retry with stricter system prompt
        tags:
          $ref: './tags.yaml#/components/schemas/TagBag'
        tool_context:
          type: object
          additionalProperties:
            type: string
          nullable: true
          description: >
            Overrides the parent's `tool_context` on the fork. Omit it and the
            fork inherits the parent's, so the branch is faithful to the run it
            came from.

    CreateSessionRequest:
      type: object
      required:
        - agent_id
      additionalProperties: false
      properties:
        agent_id:
          x-soat-ref: agents
          type: string
          description: >
            Agent this session belongs to. With a credential scoped to a
            project, it may also be an agent another project shares with that
            project; the session is then that project's.
          example: agent_V1StGXR8Z5jdHi6B
        name:
          type: string
          description: Optional session name
          example: Support chat
        tags:
          $ref: './tags.yaml#/components/schemas/TagBag'
        actor_id:
          x-soat-ref: actors
          type: string
          description: >
            Optional public ID of an existing actor to use as the user actor.
            Actors are created separately (POST /actors); this field only links
            one. Omit it and the session has no end user, so its generations
            match no actor-scoped quota.
          example: actor_V1StGXR8Z5jdHi6B
        auto_generate:
          type: boolean
          default: false
          description: When true, automatically triggers generation after each user message.
        tool_context:
          type: object
          additionalProperties:
            type: string
          nullable: true
          description: >-
            Key-value pairs forwarded as `X-Soat-Context-<key>` headers on every
            `http`, `mcp` and `builtin` tool call in this session. The header name is
            the deployment's configured context prefix (`X-Soat-Context-` by
            default) plus the key verbatim — no character is re-cased.
            Keys are stored exactly as sent and never case-converted.
            A key that is not a valid HTTP header name, or two keys that map to
            the same header, are rejected with `400 INVALID_TOOL_CONTEXT_KEY`.
            `session_id`, `actor_id` and `actor_external_id` are server-derived
            and dropped from the stored bag, in any casing.
            Write-only: no read of a session returns it. It also does not
            outlive the session: closing or expiring it clears the bag.
        inactivity_ttl_seconds:
          type: integer
          default: 0
          description: Number of seconds of inactivity after which the session expires. 0 means the session never expires.
          example: 300
        message_delay_seconds:
          type: integer
          nullable: true
          default: null
          description: >
            Number of seconds to wait after the last user message before sending to the LLM.
            Acts as a debounce: each new message resets the timer. null or absent means no delay (immediate processing).
          example: 3
        max_context_messages:
          type: integer
          nullable: true
          minimum: 1
          default: null
          description: >
            Maximum number of recent conversation messages sent to the model on
            each generation, taking precedence over the agent's
            `max_context_messages`. null or absent defers to the agent.
          example: 20

    UpdateSessionRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          nullable: true
          description: Session name (set to null to clear)
        status:
          type: string
          enum: [open, closed, expired]
          description: Session status
        auto_generate:
          type: boolean
          description: Enable or disable automatic generation after user messages.
        tool_context:
          type: object
          additionalProperties:
            type: string
          nullable: true
          description: >-
            Key-value pairs forwarded as `X-Soat-Context-<key>` headers on every
            `http`, `mcp` and `builtin` tool call in this session. The header name is
            the deployment's configured context prefix (`X-Soat-Context-` by
            default) plus the key verbatim — no character is re-cased.
            Keys are stored exactly as sent and never case-converted.
            A key that is not a valid HTTP header name, or two keys that map to
            the same header, are rejected with `400 INVALID_TOOL_CONTEXT_KEY`.
            `session_id`, `actor_id` and `actor_external_id` are server-derived
            and dropped from the stored bag, in any casing.
            Write-only: no read of a session returns it. It also does not
            outlive the session: closing or expiring it clears the bag.
        inactivity_ttl_seconds:
          type: integer
          description: >
            Number of seconds of inactivity after which the session expires.
            0 means the session never expires. Updates the stored TTL; the inactivity clock
            continues from the last activity timestamp.
          example: 300
        message_delay_seconds:
          type: integer
          nullable: true
          description: >
            Number of seconds to wait after the last user message before sending to the LLM.
            Acts as a debounce: each new message resets the timer. Set to null to disable the delay.
          example: 3
        max_context_messages:
          type: integer
          nullable: true
          minimum: 1
          description: >
            Maximum number of recent conversation messages sent to the model on
            each generation, taking precedence over the agent's
            `max_context_messages`. Set to null to defer to the agent.
          example: 20

    AddSessionMessageRequest:
      oneOf:
        - type: object
          additionalProperties: false
          required:
            - message
          properties:
            message:
              type: string
              description: User message text
              example: Hello, how can I deploy my app?
            tool_context:
              type: object
              additionalProperties:
                type: string
              nullable: true
              description: >-
                Key-value pairs forwarded as `X-Soat-Context-<key>` headers on every
                `http`, `mcp` and `builtin` tool call in this generation. The header name is
                the deployment's configured context prefix (`X-Soat-Context-` by
                default) plus the key verbatim — no character is re-cased.
                Keys are never case-converted — they round-trip exactly as
                sent. An invalid or colliding key is rejected with
                `400 INVALID_TOOL_CONTEXT_KEY`.
            idempotency_key:
              type: string
              description: >
                Optional deduplication key scoped to this session. If a message
                with the same key already exists in the session, the original
                message is returned with HTTP 200 and no new message or
                generation is triggered. Reusing the key with a different
                `message`, `document_id` or `tool_context` is
                `409 IDEMPOTENCY_KEY_REUSED`.
              example: wamid.HBgLNTUxMTk4...
        - type: object
          additionalProperties: false
          required:
            - document_id
          properties:
            document_id:
              x-soat-ref: documents
              type: string
              description: Public ID of a document used as the user message content.
            tool_context:
              type: object
              additionalProperties:
                type: string
              nullable: true
              description: >-
                Key-value pairs forwarded as `X-Soat-Context-<key>` headers on every
                `http`, `mcp` and `builtin` tool call in this generation. The header name is
                the deployment's configured context prefix (`X-Soat-Context-` by
                default) plus the key verbatim — no character is re-cased.
                Keys are never case-converted — they round-trip exactly as
                sent. An invalid or colliding key is rejected with
                `400 INVALID_TOOL_CONTEXT_KEY`.
            idempotency_key:
              type: string
              description: >
                Optional deduplication key scoped to this session. If a message
                with the same key already exists in the session, the original
                message is returned with HTTP 200 and no new message or
                generation is triggered. Reusing the key with a different
                `message`, `document_id` or `tool_context` is
                `409 IDEMPOTENCY_KEY_REUSED`.
              example: wamid.HBgLNTUxMTk4...

    AddSessionMessageSaved:
      type: object
      description: Message saved; auto-generate is off or a generation is already in progress.
      properties:
        role:
          type: string
          enum: [user]
        content:
          type: string
        document_id:
          x-soat-ref: documents
          type: string
          nullable: true
    AddSessionMessageResponse:
      # anyOf, not oneOf: the saved-message schema requires no field, so a
      # generated turn satisfies it too, and `oneOf` rejects a body that
      # matches both.
      anyOf:
        - $ref: '#/components/schemas/AddSessionMessageSaved'
        - $ref: './conversations.yaml#/components/schemas/GenerateConversationMessageResponse'

    GenerateSessionRequest:
      type: object
      additionalProperties: false
      properties:
        model:
          type: string
          description: Optional model override
          example: gpt-4o
        tool_context:
          type: object
          additionalProperties:
            type: string
          nullable: true
          description: >-
            Key-value pairs forwarded as `X-Soat-Context-<key>` headers on every
            `http`, `mcp` and `builtin` tool call in this generation. The header name is
            the deployment's configured context prefix (`X-Soat-Context-` by
            default) plus the key verbatim — no character is re-cased.
            Keys are never case-converted — they round-trip exactly as sent.
            An invalid or colliding key is rejected with
            `400 INVALID_TOOL_CONTEXT_KEY`.

    SubmitSessionToolOutputsRequest:
      type: object
      required:
        - generation_id
        - tool_outputs
      additionalProperties: false
      properties:
        generation_id:
          x-soat-ref: generations
          type: string
          description: The generation ID from the requires_action response
        tool_outputs:
          type: array
          items:
            type: object
            required:
              - tool_call_id
              - output
            additionalProperties: false
            properties:
              tool_call_id:
                type: string
              output:
                description: The tool output value
