openapi: 3.1.0

info:
  title: Narya Host API
  version: 1.0.0
  description: |-
    The contract between the Narya host and every client. On your own machine, one long-lived host serves this API over a Unix socket in your Narya home. The terminal client and the one-shot command that Lerian ships drive this API, and a client you write drives the same one.

    Requests authenticate with a bearer token issued by the identity provider the host is configured with. On your own machine, the host also accepts a request with no token from your own operating system user. Once signed-in work has claimed the home for an organization, it does so only while this machine is signed in to that organization. Such a request gets 401 with NRY-0011 on work that reaches your organization beyond the machine. An operation that declares another security scheme also accepts that credential. Any other request without a valid credential gets 401 with NRY-0011. A caller whose role lacks the permission an operation needs gets 403 with NRY-0028.

    Submitting a message returns 202 with a turn id. Everything the turn produces streams over GET /v1/events as server-sent events with a typed envelope, and a stream resumes from a Last-Event-ID header.

    One error envelope: code, title and message. Codes are NRY- followed by four digits. Cursors are opaque.

servers: []
security:
  - bearerAuth: []

tags:
  - name: host
    description: The host process itself — version, uptime, mode, store.
  - name: sessions
    description: Durable conversation containers. Archive, never destroy.
  - name: messages
    description: Submitting work into a session and interrupting it.
  - name: events
    description: The server-sent event stream every client consumes.
  - name: lanes
    description: Parallel tracks inside a session — main, subagent, side.
  - name: agents
    description: Named recipes — instructions, tools, model, policy. Read-only in v1.
  - name: ladder
    description: >-
      What a person can type: the skills and command files in force for one
      repository, and expanding one into text. Five origins merged, nearest
      winning a name, the repository's own rungs gated on trust.
  - name: permissions
    description: Pending permission asks, decisions, and the decision audit.
  - name: intercom
    description: Sessions on one machine finding and messaging each other.
  - name: packages
    description: The one thing a user installs — resources, Go code, or both.
  - name: extensions
    description: >-
      Host-side extensions and the operations each exposes over the wire.
      This is the generic lane a host extension uses to serve its own client
      half (a TUI component, a web panel) or any API-only consumer, without
      adding routes to this contract.
  - name: workflows
    description: Deterministic multi-agent orchestration runs.
  - name: providers
    description: Model suppliers, their auth state, and the model catalogue.
  - name: monitors
    description: >-
      Long-running watchers a session keeps beside its conversation — a test
      runner in watch mode, a build, a log being followed. Started by the
      model or by the person, always listed, always killable.
  - name: records
    description: The queryable local record of everything that happened.
  - name: environments
    description: >-
      Where a session's code lives and its commands run — this machine, or a
      container narya operates. A session that names none runs here.
  - name: schedules
    description: >-
      Work the host's own clock starts with nobody present — a repository, a
      prompt, a rule and what one fire may spend. Cancel, never destroy.
  - name: sharing
    description: >-
      Publishing a session from a developer's own home to the organisation's,
      and what is held back before a byte leaves the machine.
  - name: refinements
    description: >-
      What this owner has taught narya and allowed it to keep — distilled
      facts, and the skills, agents and commands the model wrote for itself.
      Propose, read, consent, roll back. Nothing here fires until a person
      answers.
  - name: platform
    description: >-
      Calls Lerian's control plane made to a home it hosts, as this home recorded them.

x-error-catalog:
  - code: NRY-0000
    httpStatus: 400
    trigger: The request itself is malformed — invalid JSON body, an unparseable parameter, or a value that does not bind to its declared type.
    resolution: Fix the request encoding; the error message names the offending part.
  - code: NRY-0001
    httpStatus: 422
    trigger: A request body failed validation.
    resolution: Fix the properties the error message names and retry.
  - code: NRY-0002
    httpStatus: 404
    trigger: The sessionId does not match any session on this host.
    resolution: List sessions to obtain a valid id; the session may have been created on another machine.
  - code: NRY-0003
    httpStatus: 409
    trigger: The target session is archived; archived sessions accept no new work.
    resolution: Branch the session at an entry to continue from its transcript, or target an active session.
  - code: NRY-0004
    httpStatus: 409
    trigger: The target cannot accept the request in its current state.
    resolution: Read the message and resolve the conflict it describes.
  - code: NRY-0006
    httpStatus: 409
    trigger: Two packages claim the same replaceable slot. The message names both claimants; nothing was installed.
    resolution: Disable or remove one claimant, or break the tie explicitly in configuration, then retry the install.
  - code: NRY-0007
    httpStatus: 409
    trigger: A package build failed. The previous binary is intact and still serving.
    resolution: Inspect the install job's error, fix or remove the offending package, and reinstall.
  - code: NRY-0008
    trigger: Carried on a turn-finished event when the turn died on a missing, expired or rejected supplier credential.
    resolution: Start the supplier's sign-in via POST /v1/providers/{name}/auth, complete it, and retry.
  - code: NRY-0009
    httpStatus: 404
    trigger: The addressed peer session is gone from this host.
    resolution: List peers to refresh the set of addressable sessions before sending again.
  - code: NRY-0010
    httpStatus: 422
    trigger: The workflow script failed validation before execution started.
    resolution: Fix the script errors named in the message and resubmit the run.
  - code: NRY-0011
    httpStatus: 401
    trigger: >-
      The request carried no valid token — none at all, or one that is malformed, expired, or signed by a key the configured issuer's key set does not hold — or it came from the socket's own OS user with no token, to an operation that leaves the machine. One code covers every cause on purpose, because the remedy is the same for all of them and a refusal naming WHICH would be an oracle for a caller probing what the host accepts.
    resolution: Sign in with `narya login`, then try again. A host that answers this to every token may have no identity provider configured, which the message says and `narya doctor` reports.
  - code: NRY-0012
    httpStatus: 500
    trigger: The local store is unavailable or failed its integrity check. The host refuses writes rather than continue on a damaged store.
    resolution: Check disk space and store integrity, resolve the cause, and restart the host. The message omits the store's own words; the host's log carries them.
  - code: NRY-0013
    httpStatus: 404
    trigger: >-
      This home has no enabled extension of that name, or the extension has one and does not expose the requested operation. A name that is not registered, a registration its owner has disabled, and an operation the extension does not serve are answered identically and on purpose: a refusal that told them apart would let a caller enumerate a home's registry by watching which refusals differ.
    resolution: List this home's extensions to see what is registered and whether it is enabled; ask the extension's author which operations it exposes. An extension is added by registering a program at an address, which its owner does over this contract.
  - code: NRY-0015
    httpStatus: 404
    trigger: The named resource does not exist on this host. Packages, package jobs, agent recipes, skills, commands, lanes, monitors, providers, schedules, workflow runs, inter-session asks and transcript entries all answer with this code.
    resolution: List the resource's collection to obtain a valid name or id.
  - code: NRY-0016
    httpStatus: 409
    trigger: A package with this name is already installed.
    resolution: Remove the existing package first, or install under a different name.
  - code: NRY-0017
    httpStatus: 409
    trigger: A synchronous enable or disable's binary swap succeeded, but confirming the resulting package row failed before the response could be sent.
    resolution: The change already applies to the binary now running; retry the request, or restart the host so startup reconciliation confirms the package row automatically.
  - code: NRY-0018
    trigger: Carried on a turn-finished event, never as an HTTP response — the turn could not reach any model supplier, because the session names no provider at all or names one this host has no record of. Creating a session has never required a usable model; running a turn does.
    resolution: Sign in with `narya auth login <provider>` — the host then runs on that provider's own default model, which is what makes a fresh install work with no configuration file at all — or set default_model in the host's config.toml; either way, send the message again. A session carrying no model adopts the resolved default on its next message. A changed config.toml takes effect once a person allows it in an attended conversation. The message names that file by its resolved path (NARYA_HOME, else $XDG_CONFIG_HOME/narya, else ~/.config/narya), which is the only copy this host reads. A session that names a provider this host does not know is never moved off it automatically — restore that provider in config.toml instead. `narya providers` lists what this machine can actually use.
  - code: NRY-0019
    trigger: Carried on a turn-finished event, never as an HTTP response — the turn failed for a reason with no more specific code. The message carries the cause with every credential-bearing part of a URL (userinfo, path and query) removed and the user's home directory replaced by ~. A subscriber may forward the message off the machine (the first-party notifier extension posts it to any https webhook the user configures), so treat any path it names as visible wherever those notifications land.
    resolution: Read the message.
  - code: NRY-0020
    httpStatus: 412
    trigger: A conditional write named an ifMatchesSha256 the stored value no longer hashes to — another client wrote the row after this caller last did. Nothing was written.
    resolution: Nothing to fix; the newer content stands. Re-read the resource if the caller needs to know what it now holds. A terminal clearing a draft it just sent treats this as success — the row it would have cleared belongs to another window's unsent text.
  - code: NRY-0021
    httpStatus: 500
    trigger: The host could not carry out the request on this machine — a process it was asked to run would not start, or the operation failed for a reason with no more specific code. Distinct from NRY-0012, which is the store specifically.
    resolution: Nothing about the request needs fixing; retry it.
  - code: NRY-0022
    httpStatus: 500
    trigger: A rewind that restores named files stopped part-way, with some of them already put back and the rest untouched. It acts one file at a time, so this is a real state rather than a theoretical one. The message says how many moved and names them.
    resolution: Nothing is lost — the whole working tree as it stood before the attempt is kept as a restore point, so a rewind to it puts the mixed state back exactly. Read the message for what already moved, then either retry (a retry usually finishes what the first attempt started) or resolve the cause it names.
  - code: NRY-0023
    httpStatus: 500
    trigger: A rewind of both axes branched the conversation and then failed to put the working tree back. The branch is real and holds the conversation from the chosen moment on; the files did not follow. The message names that session, and both transcripts record the same thing.
    resolution: Nothing is lost. Continue in the session the message names — it holds the conversation — and rewind the code alone from the original session at the same moment to finish the job, which is where the restore points live. Resolve the cause the message carries first if it names one you can act on.
  - code: NRY-0024
    httpStatus: 413
    trigger: >-
      A request body past the ceiling this host holds for its operation, refused at the door in front of every handler — or a transcript import that ran past one of four ceilings. Two are about WEIGHT — the request body past this host's limit, or an opencode export past the parser's own — and one is a single Claude Code line past the per-line limit, which one enormous embedded tool result reaches on its own in an otherwise ordinary file. The fourth is about SHAPE: the number of conversations a file folds out to, and the number of rows they cost. A fork copies the conversation it branched from, so a heavily branched transcript of a couple of hundred kilobytes can cost more rows than a flat one twenty times its size. Nothing was written in any of the four cases — the file is parsed whole and the cost is counted before a single row is created, so a refused import leaves no session behind.
    resolution: >-
      Read the message: it names which ceiling and carries the number. For the two whole-file ceilings, send a smaller file. For the per-line ceiling, a smaller file made of the same lines fails identically — the message names the line, and that one record is what has to go or shrink. For the shape ceiling, size is not the lever at all and a lighter file of the same shape trips it again: split the transcript at its fork points and import the branches separately. Distinct from NRY-0000 on the same operation, which means the file is the wrong shape or carries an unreadable line — that one is fixed, this one is split.
  - code: NRY-0026
    httpStatus: 409
    trigger: >-
      A stream resumed from a Last-Event-ID naming an event NEWER than anything this store holds — a well-formed cursor this store cannot honour. It is what a client holds after the store underneath it was replaced (an operator deleting a corrupt database restarts event ids at 1 while live clients hold cursors in the thousands). Deleting events never produces it: the newest id is the one most recently handed out, whether or not its event remains. Distinct from a cursor that is merely OLD, which is not refused at all: the server replays the events that remain past it, opening with a replay-gap frame when the stream forgot some of them, because everything the cursor asked for did once exist. Nothing was streamed — the refusal replaces the stream rather than ending one.
    resolution: >-
      Resume with no Last-Event-ID, which starts the stream live from the store's current edge, then re-sync state through the GET endpoints before trusting the stream again. Narya's own SSE client does this reset automatically.
  - code: NRY-0027
    httpStatus: 413
    trigger: >-
      A message whose attachments together weigh more than one supplier request can carry. Each file may be well under the per-file ceiling and the list well under the per-message count, and the total still be past what any provider accepts in one request — at which point the provider's own refusal is permanent rather than transient, and the message is already durable, so nothing but a rewind moves the conversation on. Refused before the message is admitted: nothing was written, and the bytes already stored for the files earlier in the list are removed with it. Distinct from NRY-0001 on the same operation, which means one named file could not be read at all.
    resolution: >-
      Send fewer files on this message, or smaller ones. The message names the ceiling in bytes. Splitting the files across several messages in the same conversation works — the ceiling is per message, not per session — though a conversation carrying many pictures will meet the model's context window sooner.
  - code: NRY-0028
    httpStatus: 403
    trigger: >-
      The caller is authenticated and their role does not carry the permission this operation needs. The message names the permission.
    resolution: >-
      An administrator of this organisation grants the named permission on the member's role in the Access Manager, where every other Lerian product's permissions are administered; it takes effect on the member's next token. Narya declares its own resources and actions there and holds no roles table of its own, so the change is made on that screen rather than anywhere in narya. One message on this code means something else and says so: when narya could not REACH the Access Manager to read the caller's grants it refuses rather than guesses, and the remedy is to try again once it is reachable — not to grant anything.
  - code: NRY-0029
    httpStatus: 503
    trigger: >-
      A publish this host recorded and could not deliver: the organisation's home did not take it. It is a statement about DELIVERY rather than a failure of the request — the share exists, every entry of it is owed on disk, and a later drain carries the work. The message names the share, so a person is told what is queued rather than told that sharing is broken. It is also what a REVOCATION answers when that home did not take it: there the meaning is the mirror image and the message says so — the local record is already revoked and this machine has stopped sending anything more for the share, and what has not happened is the deletion of the copy.
    resolution: >-
      Nothing. The work reconciles when the organisation's home answers again, complete and in order, unless the share expires first. `narya doctor` says how far behind the queue is. Re-publishing the same session is NOT the remedy and creates a second share: what was asked for is already durable. Distinct from NRY-0012, which is this home's own store refusing to write and therefore a publish that did not happen at all. On a revocation the remedy is the opposite and is the one thing to do: repeat the request once the home answers. A revocation is idempotent and is never queued, so nothing else drives it, and until it is taken the copy at the organisation's home is still readable there.
  - code: NRY-0030
    httpStatus: 403
    trigger: >-
      The token is valid and names an organisation this home does not serve. One organisation is one home, so a home is stamped with the organisation that claimed it and refuses every other — before any handler runs, on the local socket exactly as over a network. The socket's own OS user signed in to another organisation is not this code: the local road answers `401` `NRY-0011` with a sentence naming both, which only that user reads. This code's message NAMES NEITHER organisation, the home's nor the caller's: any holder of any valid token can present it to any host, so a refusal that named the home's would make every narya host a directory of customers. Distinct from NRY-0028 and never folded into it: that one is a permission a role does not carry, fixed by an administrator in the Access Manager, where this one is the wrong home entirely.
    resolution: >-
      Point NARYA_HOME at the home for the organisation the caller belongs to, or sign in to the organisation this home serves (`narya login`, which aligns the token to the home it is run against). A home is never re-stamped by a later sign-in: re-stamping would make a stolen laptop a tenancy transfer. A registered extension refused here is registered again by the owner of the home its organisation now uses, and redeployed with the secret that registration shows.
  - code: NRY-0031
    httpStatus: 410
    trigger: >-
      Entries addressed to a share that has been revoked at the organisation's home. The share id named a real share and that share is now a tombstone: its copied entries were deleted at the person's request and the row survives only to carry the disclosure history. It is the answer the inward publish operation gives to work that was queued before the revocation and offered after it.
    resolution: >-
      TERMINAL, AND NEVER RETRIED. Retrying is answered identically forever, and a retry that succeeded would undo a deletion somebody asked for — which is the whole reason a revocation carries a generation rather than only a flag. A client holding queued work for this share drops it and stops following the session. Sharing that session again is a NEW publish with a new share id; the revoked id is never reused, so a stale queue can never address the new share by accident.
  - code: NRY-0032
    httpStatus: 409
    trigger: >-
      An answer to a product session's question whose message the host refused to admit (a draining host, no model, a conflict on the lane). The body carries the refusal's own reason after the fixed sentence. The question is pending again by the time this is sent.
    resolution: >-
      Nothing is lost: the answer was not delivered and the question stands, so answer it again once the host takes messages. Distinct from NRY-0004 on this operation, which means the question is settled and there is nothing left to answer.

paths:
  /v1/host:
    get:
      operationId: getHost
      tags: [host]
      summary: Describe the host process
      description: >-
        Returns the host's version, uptime, mode and store path. This is the handshake operation clients call first, and it is gated like every other: a request without a valid token answers 401 NRY-0011, unless it is the socket's own OS user on a laptop. The socket's EXISTENCE is what tells a client a host is there; an exempt handshake would be a second trust regime. There is no shutdown operation — host lifecycle belongs to the daemon manager, outside this contract.
      responses:
        '200':
          description: Host metadata.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Host'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/describe-the-host-process
  /v1/doctor:
    get:
      operationId: getDoctorReport
      tags: [host]
      summary: Run the read-only health walk
      description: >-
        Runs `narya doctor`'s own walk over this installation — home directory, config, host lock, socket, store, sessions, package jobs, tool servers, credentials, binary and terminal — and answers both the rendered text and the structured lines behind it. Read-only throughout: every check reads, the store is opened read-only, and a finding names the command that repairs it rather than running one.

        It exists so a client does not have to compile the walk to offer `/doctor`. The walk lives with the engine, and the things a caller knows about itself that a host cannot — which binary is asking, which build produced it, what the caller's own terminal can render, and which desktop helpers the caller can actually deliver through — travel as parameters rather than being guessed at from the host's process. A host that is not running cannot answer this at all, which is the one case a client handles itself: it says so and names `narya doctor`, the command that walks a machine whose host is down.

        One line stays the HOST's own fact whatever a caller says: the keychain check describes the session the host process runs in, since that is the process that stores and reads a credential.
      parameters:
        - name: callerVersion
          in: query
          required: false
          schema:
            type: string
            maxLength: 128
          description: >-
            The version of the binary ASKING. The walk compares it against the
            answering host's own, which is what reports a host serving your
            sessions from a different build than the one on disk. Omitted, the
            host reports its own version and that comparison cannot fire.
        - name: callerGeneration
          in: query
          required: false
          schema:
            type: string
            maxLength: 128
          description: >-
            The build job that produced the asking binary, when there was one
            — an extension rebuild stamps it. Reported on the binary line.
        - name: callerColorProfile
          in: query
          required: false
          schema:
            type: string
            maxLength: 32
          description: >-
            What the CALLER's terminal renders, e.g. TrueColor or ANSI256.
            A daemon's own stdout is a pipe, so a host asked to detect this
            for a client would report the absence of the client's terminal.
            Omitted, the host detects its own.
        - name: callerNotifier
          in: query
          required: false
          schema:
            type: string
            maxLength: 128
          description: >-
            The platform notification program the CALLER resolved on its own
            $PATH, e.g. `notify-send` or `osascript`, or empty for none found.
            The caller is the process that delivers a banner, so this is a
            fact about it and not about the answering host: a host started by
            systemd or launchd carries a minimal PATH and no desktop session,
            and answering from its own walk reports "no platform notifier on
            this machine" to a person sitting at one. Omitted entirely — as
            `narya doctor` run against a host omits it — the host walks its
            own $PATH.
        - name: callerSoundPlayer
          in: query
          required: false
          schema:
            type: string
            maxLength: 128
          description: >-
            The audio player the CALLER resolved on its own $PATH, e.g.
            `paplay` or `afplay`, or empty for none found. A fact about the
            caller for callerNotifier's exact reason: the caller is the
            process that plays the sound. Omitted, the host walks its own
            $PATH.
      responses:
        '200':
          description: The health walk, rendered and structured.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DoctorReport'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/run-the-read-only-health-walk
  /v1/tool-servers:
    get:
      operationId: listToolServers
      tags: [host]
      summary: Report every declared external tool server's health and cost
      description: >-
        Answers, per declared MCP tool server, whether it is up, since when, how
        many consecutive connection attempts have failed and why the last one did —
        plus how many calls it has been asked to make and how many characters of
        model context its presence in the budgeted catalogue spends. Health is taken
        live off an MCP ping while answering, so a server holding an open socket and
        answering nothing reads as down rather than as connected. The whole sweep is
        bounded by the request: the pings go out in parallel, so eight silent
        endpoints cost one ping ceiling between them. A trusted repository's own
        servers (its `.mcp.json`) appear as further rows carrying `repository`, for
        every repository root that has servers live on this host right now, and for
        each of the caller's own environments whose container runs some.
      responses:
        '200':
          description: One entry per declared tool server, the person's own in name order, then each repository's by repository and name.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ToolServerHealth'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/report-every-declared-external-tool-server-s-health-and-cost
  /v1/environments:
    get:
      operationId: listEnvironments
      tags: [environments]
      summary: List the environments this host holds
      description: >-
        Environments in creation order, each with the image digest it actually runs and what its setup script did. This host's own machine is not one of them: it is what a session gets by naming nothing, and it has no lifecycle to list.
      responses:
        '200':
          description: The environments.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EnvironmentList'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-the-environments-this-host-holds
    post:
      operationId: createEnvironment
      tags: [environments]
      summary: Create an environment
      description: >-
        Provisions an environment under the identifier the caller names, on
        the one official image, and runs the caller's setup script inside it
        once. **Idempotent on that identifier** when the existing machine's
        setup state is recorded: such an id is answered 200 with the
        environment that exists, not 409 and not a second container — a
        client that lost the response to its first call may repeat it without
        paying twice. A setup-carrying create that meets a machine whose
        setup state was never recorded is refused rather than adopted: nobody
        can say whether that machine's toolchain exists. A setup script that
        fails or runs past the host's bound leaves the environment `unusable`
        carrying its own output as the cause, rather than a healthy-looking
        environment missing a toolchain.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EnvironmentCreate'
      responses:
        '200':
          description: >-
            The environment that already existed under this identifier.
            Nothing was provisioned and no setup script ran.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Environment'
        '201':
          description: The environment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Environment'
        '400':
          $ref: '#/components/responses/BadRequest'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/create-an-environment
  /v1/environments/{environmentId}:
    parameters:
      - $ref: '#/components/parameters/EnvironmentIdParam'
    get:
      operationId: getEnvironment
      tags: [environments]
      summary: Get an environment
      description: >-
        One environment with the image digest it runs, whether it is
        answering, and what its setup script did. This is how a client tells a
        session waiting on an unreachable environment from a session that is
        merely slow.
      responses:
        '200':
          description: The environment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Environment'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/get-an-environment
    delete:
      operationId: destroyEnvironment
      tags: [environments]
      summary: Destroy an environment
      description: >-
        Ends the environment and releases what it holds. Idempotent: an
        identifier this host does not have is 204, because the caller's
        intent — that it be gone — is satisfied. Sessions bound to it are not
        deleted and their transcripts are untouched; each reports its
        environment `gone` and fails the tool calls that need it, rather than
        silently falling back to this machine. On a hosted home a bound
        conversation's next message instead makes a container of its own,
        cloning again the repository the destroyed environment held.
      responses:
        '204':
          description: Destroyed.
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/destroy-an-environment
  /v1/github/repositories:
    get:
      operationId: listGitHubRepositories
      tags: [environments]
      summary: List the repositories this organisation selected on GitHub
      description: >-
        The repositories this organisation's installation of the Narya GitHub
        App carries, which is the set a person may name when creating an
        environment. **Served straight from GitHub on every request**, never
        from a copy narya keeps: the set is the customer's to change on GitHub
        at any moment, and a cache would drift from what they selected the
        instant they changed it.


        An organisation that has connected no GitHub, or whose installation
        its owner uninstalled or suspended on GitHub, is answered with an
        **empty page carrying a notice** naming the gesture that connects one,
        rather than an error or a bare empty list — either is a state with a
        remedy, not a failure. Every other cause fails
        closed: a host that cannot read what its organisation applies, and a
        GitHub that refuses, are both refusals naming what could not be done.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: >-
            One page of the repositories this organisation's installation
            carries, in GitHub's own order.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GitHubRepositoryPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-the-repositories-this-organisation-selected-on-github
  /v1/sessions:
    get:
      operationId: listSessions
      tags: [sessions]
      summary: List sessions
      description: >-
        Cursor-paginated list of sessions on this host, filterable by repository, environment and status. One query across every repository.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: repository
          in: query
          required: false
          description: >-
            Filter to sessions bound to this repository path; on a hosted home, relative to its workspace, as each session names it.
          schema:
            type: string
            maxLength: 4096
        - name: environment
          in: query
          required: false
          description: >-
            Filter to sessions bound to this environment. Sessions running on
            the host's own machine are bound to none and match no value of
            this filter; omit it to see them.
          schema:
            $ref: '#/components/schemas/EnvironmentId'
        - name: status
          in: query
          required: false
          description: Filter by session status.
          schema:
            type: string
            enum: [idle, running, waiting, archived]
        - name: search
          in: query
          required: false
          description: >-
            Filter to sessions whose title contains this text, matched
            case-insensitively. It exists so a client can reach a session by
            name past whatever prefix of the list it has read: a picker that
            filters the newest few hundred rows it holds cannot find a
            conversation from three months ago, and only the host can.
            Combines with the other filters.
          schema:
            type: string
            maxLength: 512
        - name: mine
          in: query
          required: false
          description: >-
            Restrict the listing to the conversations the caller owns. It names nobody and takes no name: the subject comes from the verified token and from nowhere else, which is what keeps it from being the owner parameter this contract deliberately does not have — an owner a caller can name is an owner anybody can be. It is not a permission and can only ever narrow. Spelled the same way, and meaning the same thing, as the `mine` on `searchRecords` and `listRecordFiles`. Combines with every other filter here.
          schema:
            type: boolean
      responses:
        '200':
          description: One page of sessions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-sessions
    post:
      operationId: createSession
      tags: [sessions]
      summary: Create a session
      description: >-
        Creates a durable session bound to a repository. The session exists before any turn runs; submitting the first message is a separate operation.

        A hosted home (one that names its own directories) runs every session inside a container. A session there that names no `environment` opens with none, and its first turn makes it one of its own: `conversation-<sessionId>`, from the home's sandbox image, owned by the caller; archiving the conversation or destroyEnvironment takes it away, its clone and branch included (a branch it pushed stays on GitHub). One that names a repository in `clone` gets that container when it is created rather than at its first turn, holding the repository's clone. A hosted home whose deployment names no sandbox image refuses that create 422 and nothing is created. On any home, `container` asks for that container at create, with nothing cloned: how a person's own home opens a conversation in one. A `worktree` for a session in a container is refused 422 on any home.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionCreate'
      responses:
        '201':
          description: The created session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/create-a-session
  /v1/sessions/{sessionId}:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: getSession
      tags: [sessions]
      summary: Get a session
      description: >-
        Returns one session with its status, model in use, token usage totals
        and cost.
      responses:
        '200':
          description: The session.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/get-a-session
    delete:
      operationId: archiveSession
      tags: [sessions]
      summary: Archive a session
      description: >-
        Archives the session. This is an archive, not a destroy: the
        transcript is durable product state and remains fully queryable
        through the records surface and exportable. An archived session
        accepts no new work; branch it to continue from any entry.

        It is ONE WAY, and there is no operation here that undoes it: once a
        session is archived, nothing can move it back to accepting work. What
        continues from it is a branch, which is a new session with a new id —
        so every reference to the archived id (a link, a script, a teammate's
        note) keeps pointing at a conversation that will never run again. A
        client that offers this to a person should ask them first.

        A session with a turn in flight is refused (409) rather than archived:
        that turn would finish into a session no write can move afterwards.
        A product session holding a confirmed change whose outcome is unknown
        is refused (409) until that change is sent again or ends; archiving
        one declines its pending product writes, so nothing of it is ever sent.

        On a hosted home, archiving the last session bound to a container the
        home made for a conversation deletes that container with its disks
        and its record; a branch made from the archive later starts in an
        empty one and is told so. An environment a client named is untouched.
      responses:
        '204':
          description: Archived. The transcript remains in the record.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/archive-a-session
  /v1/sessions/{sessionId}/model:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: setSessionModel
      tags: [sessions]
      summary: Switch the session's model
      description: >-
        Points the session at another model, and records that model as the
        caller's own default for every session they open afterwards unless the
        body says `sessionOnly`. No host
        restart and no new session: the next turn runs on the chosen model,
        and a turn already running keeps the model it started on — a switch
        never mutates a turn in flight. Note what that means for the message a
        person sends next while a turn is running: it is admitted as steering
        into that turn (see createMessage), so it is answered by the model the
        turn started on, not by the one just chosen. A client that tells them
        "the next message runs on it" is telling them something untrue. The
        switch is announced in the transcript as a system entry, like every
        other model change nobody typed — except when a turn is in flight,
        where an entry cannot be placed without separating a tool call from
        its result, which would fail that turn and quarantine the session on
        recovery; the switch still takes effect, and this response is the
        confirmation.


        The variant travels WITH the model as one choice, never as a setting
        beside it: a body naming no variant runs the chosen model at its own
        defaults, so switching model always clears a level the previous model
        was running at rather than carrying it onto one that may not offer it.
        The variant is recorded with the model as the caller's default for
        sessions they open afterwards, on the same terms the model itself is.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionModel'
      responses:
        '200':
          description: The session, now on the chosen model.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/switch-the-session-s-model
  /v1/sessions/{sessionId}/title:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: setSessionTitle
      tags: [sessions]
      summary: Rename the session
      description: >-
        Renames the session. Metadata only: no lane is touched, so a rename
        during a running turn is legal and changes nothing about that turn.
        A title of only whitespace is refused rather than stored — a name made
        of spaces is not a name, and a client rendering it would show a session
        with a blank identity instead of falling back to what it does know.
        Nothing about this operation generates a title: the new one is the
        caller's, exactly as SessionCreate's is.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionTitle'
      responses:
        '200':
          description: The session, under its new name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/rename-the-session
  /v1/sessions/{sessionId}/posture:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: setSessionPosture
      tags: [sessions]
      summary: Record whether anybody is watching this session
      description: >-
        States, as a recorded fact, whether somebody is there to answer a
        permission prompt raised in this session. `detached` hands the session
        to the host to keep working with nobody watching: from the next ask
        onwards, a prompt is refused and recorded with its reason instead of
        blocking a turn on a question nothing can answer. `attended` means the
        opposite, whoever opened the session; one that recorded neither reads
        by its clientKind (Posture).

        The host is already detached, so work outlives the terminal whether or
        not this is called — what this operation changes is what happens at the
        next permission prompt, and nothing else. It never touches a turn in
        flight, never cancels anything, and never changes which model or rules
        a session runs under.

        Setting the posture a session already holds is not an error: the state
        asked for holds, and the answer is the same as the call that got it
        there. This is the operation the clients' exit prompt performs when a
        person closes a terminal on a live turn and chooses to hand it over; a
        session left alone by any other exit — a crash, a closed lid, a kill —
        keeps the posture it had.

        `detached` is REFUSED with 422 in a repository nobody has trusted, and
        the message names the gesture that changes the answer. With nobody
        there to be asked, a session resolves onto the rule set that applies
        when no prompt can be shown, and in a directory nobody vouched for
        that set is wider than the untrusted one it would replace — so the
        handover would loosen what runs unasked rather than tightening it. The
        stored posture is left exactly as it was. A terminal client's session
        already recorded detached is re-checked when the host resumes it and
        demoted to `attended` if the directory is still not trusted; any other
        keeps it, and its turn is refused naming the trust gesture.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionPosture'
      responses:
        '200':
          description: The session, under the posture it now holds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/record-whether-anybody-is-watching-this-session
  /v1/sessions/{sessionId}/visibility:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: setSessionVisibility
      tags: [sessions]
      summary: Hold this session back from the organisation, or hand it back
      description: >-
        Marks one conversation `private`, or hands it back to `organisation`, which is what every session is until somebody says otherwise. Inside one organisation the team is the default and this mark is the only exception to it.

        IT TAKES EFFECT ON THE NEXT READ, from anywhere. Nothing caches the answer: every read of a session asks the stored mark at the moment it is served, so a session marked here disappears from a colleague's list and their open window's next request at once, without a restart and without anything being pushed to them.

        WHO MAY CALL IT: the session's owner, always — a person's own conversation is theirs to hold back — and a member who administers the organisation's membership, for anybody, because a departed colleague's work has to be able to come back to the team. Nobody else, and deliberately not the holder of `sessions:read-private`: that permission exists so a security question can be answered, and it does not extend to changing what everybody else may read.

        Marking a session the mark it already carries is not an error: the state asked for holds. Nothing is announced and nothing is written into the transcript — who may read a conversation is a fact about it rather than something that happened in it, and its position in a session list does not move.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionVisibility'
      responses:
        '200':
          description: The session, under the visibility it now holds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/hold-this-session-back-from-the-organisation-or-hand-it-back
  /v1/sessions/{sessionId}/style:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: setSessionStyle
      tags: [sessions]
      summary: Put this session into a named response style
      description: >-
        Points a session at one of the named response styles configuration
        declares, so every following answer is shaped by the text that style
        appends to the assembled system prompt. The empty string CLEARS,
        putting the prompt back byte-for-byte to what it was.

        It takes effect from the next turn and touches nothing else: not a turn
        in flight, not which model the session runs, not its rules. The style
        composes with an agent recipe rather than replacing one — the recipe
        supplies the identity, the style shapes how it answers — and it applies
        to every lane of the session, delegates included, because a delegate's
        answer lands in the same transcript a person reads.

        A NAME CONFIGURATION DOES NOT DECLARE IS REFUSED with 422, naming the
        file that would declare it, and the session keeps whatever style it
        had. Refused at the door rather than at prompt time on purpose: a
        session can then never hold a name that resolves to nothing. The names
        that exist are the person's own `[styles.*]` plus those of this
        session's repository once its directory is trusted, with the person's
        own winning a name both files declare.

        The change is ANNOUNCED — one line in the transcript saying the voice
        changed — because an answer that reads differently with nothing saying
        why is unreadable. Putting a session into the style it already holds is
        not an error and is silent: the state asked for holds, and a script run
        twice must not write the line twice. An archived session is refused, on
        the terms setSessionModel refuses one: a style decides how the next
        answer reads, and there is no next answer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionStyle'
      responses:
        '200':
          description: The session, in the style it now holds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/put-this-session-into-a-named-response-style
  /v1/sessions/{sessionId}/rules:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: allowForSession
      tags: [permissions]
      summary: Allow actions in this session without asking
      description: >-
        Holds one allow rule per entry for the rest of this session's life on
        this host, as answering a prompt "allow for this session" holds one: a
        rule in the grammar of a permissions.toml line whose effect is always
        allow, at the session scope: above the other scopes, under narya's floor
        on writes to its own files. Nothing is stored; they go when the host stops.
        `narya -p --allow` sends them before its prompt, which is how an
        unattended run does the one thing it would otherwise refuse.

        The session's owner alone may call it: a colleague's answer releases
        one call. A rule permissions.toml would refuse, or one granting what a
        deny in the operator's own permissions.toml refuses, is refused with
        422 naming it, and nothing in the list is held.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionAllowList'
      responses:
        '204':
          description: Held. The next permission check in this session reads them.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/allow-actions-in-this-session-without-asking
  /v1/sessions/{sessionId}/repository:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: moveSession
      tags: [sessions]
      summary: Move a session to another directory
      description: >-
        Points a live session at another directory. From the next turn onwards
        its tools are confined to the new root and its commands run there
        (in an environment they stay in its workspace, see below), and
        every surface that names the directory names this one. The
        CONVERSATION is untouched: not one entry is lost, and the move is
        written into it as a note saying where the session came from.

        WHAT IT COSTS, and it is the one thing nobody can get back: the
        session's restore points are DROPPED. Every one of them named a saved
        working tree in a store keyed to the checkout the session is leaving,
        so after the move not one of them could put a file back — kept, they
        would go on offering a moment the host cannot honestly reach. Going
        back to an earlier point in the CONVERSATION still works, because the
        transcript is intact; going back to the files as they were before the
        move does not. A client offering this gesture has to be able to say so
        before it calls. The first tool that changes a file in the new
        directory takes a fresh baseline on its own, and until then the
        session offers no restore point at all. A session working inside an
        environment is the exception: its tools stay in the environment's
        workspace, which no move changes, so it keeps every restore point.

        Trust is asked about the destination exactly as at session open: an
        untrusted directory is not refused, it runs untrusted. What does not
        survive the move is a terminal client's recorded HANDOVER — a session
        moved into a directory nobody has vouched for is taken back to
        `attended`, with the reason written into its conversation, because
        nobody being there in a directory nobody trusts is the hole this
        closes. A session nobody attends otherwise has its turns refused
        there, naming the trust gesture.

        Refused with 409 while a turn is in flight, rather than raced:
        dropping the restore points under a tool part-way through writing
        files would throw away the one baseline that could put them back.
        Interrupt the turn and call again. Refused with 409 for an archived
        session, and with 422 for a destination that is not an absolute path
        to a directory, or whose own project configuration will not load.

        Moving a session to the directory it already stands in is not an
        error: the state asked for holds, so nothing is appended, nothing is
        dropped, and no event is emitted.

        The directory is a working-directory LABEL and re-homing a session
        moves nothing about who owns it: ownership is the identity the host
        authenticated at creation, and this operation cannot change it. On a
        host that names its own directories the destination resolves under
        that host's workspace root, and one outside it is refused with the
        same 422 sentence createSession refuses it with.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionRepository'
      responses:
        '200':
          description: The session, working in the directory it now stands in.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/move-a-session-to-another-directory
  /v1/sessions/{sessionId}/viewed:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: acknowledgeSessionViewed
      tags: [sessions]
      summary: Record that a reader has looked at this session
      description: >-
        Stamps the moment a reader had this session in front of them. It is the
        watermark "what happened while you were away" is measured from: read
        against the session's updatedAt, it answers whether anything has
        happened here since somebody looked.

        The fact lives on the host rather than in the client that called this,
        which is the whole point of the operation. Two windows open on one
        session then agree about what has been seen, and a client that quits
        and comes back reads the same answer instead of starting from zero —
        the failure that closes is a background conversation which finished
        overnight and showed nothing the next morning.

        It takes no body and is idempotent: a client acknowledges every time it
        brings a conversation to the front, so saying it twice is the ordinary
        case. The stamp is the host's own clock, never a time the caller
        names — a client with a skewed clock would otherwise be able to declare
        activity read before it happened. An archived session is accepted: a
        conversation somebody retired and then read must be able to stop
        claiming news. Nothing else about the session moves, updatedAt
        included — a viewing is not something the conversation did, and bumping
        it would mark the session unread by the act of reading it.
      responses:
        '200':
          description: The session, carrying the watermark it now holds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/record-that-a-reader-has-looked-at-this-session
  /v1/sessions/{sessionId}/warming:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    put:
      operationId: setSessionWarming
      tags: [sessions]
      summary: Say whether this session pays to stay warm, and how much
      description: >-
        Records whether this session keeps its provider cache alive while
        nobody is using it, and the lifetime dollars it may spend doing so.
        Warming is off on every session until this is called: nothing enables
        it globally, and a daemon restart never enables, disables or changes
        what a session is allowed to spend.

        A warmed session is sent a minimal request on a schedule so that the
        stable part of its prompt — the tool definitions and the system
        prompt — stays cached across an idle gap far longer than the five
        minutes a provider holds it for free. Nothing is appended to the
        conversation: the request carries one inert line, the answer is capped
        at a single token, and neither is stored. Resuming a warmed session
        therefore pays cache-read rates for its prefix rather than full input
        rates.

        Two ceilings bound the spending and both STOP it rather than warn: the
        one named here, which is this session's lifetime allowance, and the
        daily warming allowance, `[warming] daily_ceiling_usd`, operator
        configuration reckoned per person. Warming does not run at all until
        an operator sets that above zero — there is no number narya can pick
        about a bill it does not pay. A warm is charged to whoever acted in
        the session's last round, else its owner, and counts against that
        person's allowance. Separately, a person whose paid work this UTC day
        has reached `[spend] daily_ceiling_usd` — the per-person cap on all
        paid work, whose absence means no cap — is not warmed either.

        A warm renews a cache its provider is asked to hold for an hour, so
        turning warming on is refused for a session whose provider cannot be
        asked for that hour: an anthropic-messages endpoint can and a
        registered supplier is handed the ask, while neither openai family
        can. A session whose model moves to such a provider later keeps its
        instruction and is never warmed or charged while it stays there.

        Setting what a session already holds is not an error, except that
        turning it on stays refused while its provider cannot hold the hour.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/Warming'
      responses:
        '200':
          description: The session, under the warming instruction it now holds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/say-whether-this-session-pays-to-stay-warm-and-how-much
  /v1/sessions/{sessionId}/branch:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: branchSession
      tags: [sessions]
      summary: Branch a session at an earlier entry
      description: >-
        Creates a new session continuing from the given entry of this
        session's transcript. The original branch remains intact — returning
        to a previous point never destroys what came after it. Branching an
        archived session is allowed; the transcript is durable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionBranch'
      responses:
        '201':
          description: The new session branched from the given entry.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/branch-a-session-at-an-earlier-entry
  /v1/sessions/{sessionId}/rewind:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: rewindSession
      tags: [sessions]
      summary: Go back to an earlier point, on a new branch
      description: >-
        Moves back to an earlier entry and continues from there — the conversation, the files, or both. Rewinding ALWAYS branches and never deletes: the timeline walked away from stays in the tree with its own tip, navigable and continuable, so going back is a way to stop adding to something rather than a way to lose it. Undoing a rewind is continuing the abandoned branch again, not a second mechanism.

        The same is true of the files. Before anything is restored, the working tree AS IT IS becomes its own restore point, so the state the rewind moved away from can be put back byte for byte. Nothing in the person's own git history sees any of it; the restore points live in narya's shadow repository beside the checkout. Outside a git repository narya keeps no file snapshots, so a scope that moves files is refused there with 422.

        scope has no default, and a request omitting it is refused. The two axes fail in opposite directions — rewinding the conversation without the code leaves the agent arguing with a working tree it cannot see, rewinding the code without the conversation throws away work the transcript still claims exists — so this operation never guesses.

        A scope that moves files is refused with 409 while a turn is in flight anywhere in this checkout, which includes another session standing in it: several sessions share one working tree and one set of restore points, and restoring under a live tool would destroy whatever it is part-way through writing. The refusal names the session that is working when the caller may read it. A scope that moves files acts on the session's own machine: a session whose environment this host does not hold, or one bound to none on a hosted home, is refused 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionRewind'
      responses:
        '200':
          description: >-
            The files were put back and this same session continues. Answered for a narrowed revert — paths or partEntryId — because putting one file back is not a reason to fork a conversation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '201':
          description: >-
            The new branch, and the session to continue in. scope=conversation and scope=both continue from the chosen entry; scope=code continues from the tip, carrying the whole conversation over the restored files.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/go-back-to-an-earlier-point-on-a-new-branch
  /v1/sessions/{sessionId}/entries:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: listSessionEntries
      tags: [sessions]
      summary: Read a session's transcript
      description: >-
        One page of the session's transcript, oldest first: the questions a
        person asked, the answers, and the tool calls and results between
        them. This is how a client that did not watch a session happen shows
        its conversation — a tab opening a session created hours ago, a
        switcher moving into another one. Replaying the event stream is not a
        substitute for it: entry-appended carries identity and nothing else,
        so a client reconstructing a conversation from events alone renders
        every answer with none of the questions.

        Each page carries lastEventId, the event-stream position it was read
        at. A client pages to the end and then subscribes to GET /v1/events
        for this session with Last-Event-ID set to the LAST page's value:
        every entry committed at or before that read is already in hand,
        every event after it arrives on the stream, and nothing is delivered
        twice. Entries and that position are read from one database
        snapshot, so no entry can slip between the two.

        A turn still in flight is deliberately not in it. The fragments of a
        message that has not committed as an entry are not transcript yet —
        while the answer streams, lastEventId stops just before that message,
        so a client attaching mid-turn is replayed the fragments already
        streamed and then receives the rest on the stream, and reads the
        entry the turn commits when it ends by asking for the entries after
        the last sequence it holds.

        nextCursor is always present: a transcript is append-only, so the
        cursor is a resume position rather than a promise that more exists
        already. hasMore is what says whether more exists right now.

        An assistant round's reasoning blocks are not served here, and neither
        are their signatures. They are the model's own scratchpad — rendering
        them in the assistant's voice claims the model said what it only
        considered — and each signature is opaque provider material that never
        leaves the host. What the entry DOES carry is a summary of the run
        (Entry.reasoning): what the model called it, how many blocks it took and
        how long it ran. Those are facts about the thinking rather than any of
        it, and they are what lets a client reading a session back draw the one
        collapsed line the live client draws, in the place the thinking
        happened. An entry that held nothing else is still listed, carrying no
        text, so a transcript's sequence numbering stays honest.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of transcript entries, oldest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/EntryPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-a-session-s-transcript
  /v1/sessions/{sessionId}/media/{mediaKey}:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/MediaKeyParam'
    get:
      operationId: getSessionMedia
      tags: [sessions]
      summary: Read one file a transcript row carried
      description: >-
        The bytes of one picture or converted document a session's transcript
        names, addressed by the mediaKey on an Entry.attachments record. This
        is the ONLY road to them: the transcript read carries what a picture
        IS and never any of it, so a client that wants to draw one asks here.

        It serves bytes rather than a path on purpose. The client may be on
        another machine than the engine, where a path names nothing —
        and even on one machine a path would invite a client to read a
        directory the host owns.

        The key is resolved through the TRANSCRIPT that names it, not by
        joining it onto a directory. So a key this session's conversation does
        not carry is a 404 whether or not a file of that name exists: one
        conversation's picture is not another's, and a home holds every
        session's media beside the database. The refusal says nothing else —
        there is no distinction to draw between "no such file" and "not
        yours", and drawing one would answer questions nobody may ask.

        Immutable once written, so the response may be cached for as long as
        the client likes. narya session purge destroys a conversation's media
        with it, after which its own keys answer 404.
      responses:
        '200':
          description: The stored file, with the media type the transcript recorded for it.
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-one-file-a-transcript-row-carried
  /v1/sessions/{sessionId}/summary:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: summarizeSession
      tags: [sessions]
      summary: Summarise this conversation, on demand
      description: >-
        Asks a model what this conversation set out to do, what it
        established, and where it stopped, and stores the answer on the
        session. It is what makes an abandoned branch's value survive without
        reading it again.

        It happens ONLY when somebody asks. Nothing summarises a branch
        because a rewind moved away from it: an automatic summary spends
        tokens on a timeline the person may be about to walk straight back
        into, at the exact moment they are trying to get somewhere else. Until
        they ask, a branch is recognised by its first and last message, which
        getSessionTree carries for exactly that reason.

        It costs tokens, on the model the session is pointed at, and the call
        is recorded in the session's usage like any turn. Asking twice
        overwrites and re-stamps: summaryAt is the answer to "was this written
        before the last thing that happened here", read against updatedAt.

        A conversation with no answer in it yet is refused rather than sent
        to a model with nothing to summarise. An archived session may be
        summarised — that is the case this exists for.

        One round at a time per session: asked again while a round is still
        running, this answers 409 rather than starting a second one. The two
        would read the same conversation and only the later answer would
        survive, so the duplicate is a model paid for nothing. Asking again
        after the first has RETURNED is allowed and overwrites, as above,
        unless it carries the idempotencyKey the stored summary was asked
        under: then it runs nothing and answers 200 with the session.
        It also answers 409, running nothing, when the caller's day has
        reached the home's daily spend ceiling.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SummaryCreate'
      responses:
        '200':
          description: The session, now carrying its summary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/summarise-this-conversation-on-demand
  /v1/sessions/{sessionId}/entries/{entryId}/label:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/EntryIdParam'
    put:
      operationId: setEntryLabel
      tags: [sessions]
      summary: Label one entry of a transcript
      description: >-
        Puts a short tag on one moment of a conversation, or takes the tag
        away by sending an empty one — setting and clearing are the same
        write, because changing a label and removing it are the same gesture
        from where a person stands.

        A label is what makes a branched family navigable by meaning rather
        than by id: "the version that worked", "before the refactor", "where
        it broke". One per entry, at most 128 characters, and it appears on
        the labels of the session's node in getSessionTree, which is also
        what that operation's label filter matches.

        The entry must belong to the named session. This is the only write in
        the transcript's surface that names an entry directly, so it is the
        only one that could be aimed at another conversation's entry, and one
        that is gets 404 rather than quietly succeeding.

        That 404 is deliberately not branchSession's 422 for the same fact.
        Here the entry is part of the ADDRESS: this session's entry with that
        id does not exist, which is what 404 says. branchSession carries the
        entry in its body, so the address (the session) does exist and it is
        the request that does not make sense against it, which is what 422
        says.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EntryLabel'
      responses:
        '204':
          description: The label was set, or cleared.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/label-one-entry-of-a-transcript
  /v1/sessions/{sessionId}/tree:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: getSessionTree
      tags: [sessions]
      summary: Read the branched family a session belongs to
      description: >-
        Every session joined to this one by branch provenance, root first,
        each node carrying the entry it branched at. A branch IS a session,
        so this is the shape listSessions cannot show: that flat list says
        which conversations exist and never that one continues another.

        It returns the WHOLE family, not the subtree below the named session.
        A person navigating a tree is standing on a branch, and the thing
        they most often want is the sibling they walked away from — which a
        subtree rooted where they stand would not contain.

        Each node carries the first and last thing said in it. That is how an
        unsummarised branch is recognised on sight, which is the point: no
        summary is ever produced because a branch was abandoned, so
        recognition has to work without one. Asking for a summary
        (summarizeSession) is one command away when it does not.

        A node the caller may not read, such as a colleague's private branch,
        keeps its place in the family and carries none of its words: no
        title, summary, excerpts or labels, and the label filter never matches
        it.

        A node whose parentSessionId is absent is a root. That includes a
        branch whose origin was purged: the provenance link is cleared rather
        than the branch taken away, so an orphaned branch becomes a root of
        its own family and stays fully readable.

        The label filter keeps a session carrying that label AND every
        ancestor of one, whether the ancestor carries it or not. A tree with
        holes in the middle is not a tree — dropping an unlabelled parent
        would draw its child hanging off a branch point that never existed.

        A family whose branch provenance runs deeper than the walk allows is
        refused (409) rather than answered with a tree that quietly stops
        partway. A truncated tree presented as whole would be missing exactly
        the branch the person is standing on.
      parameters:
        - name: label
          in: query
          required: false
          description: >-
            Keep only sessions carrying this exact entry label, plus their
            ancestors. Absent returns the whole family.
          schema:
            type: string
            maxLength: 128
      responses:
        '200':
          description: The branched family this session belongs to.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionTree'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-the-branched-family-a-session-belongs-to
  /v1/sessions/{sessionId}/usage:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: getSessionUsage
      tags: [sessions]
      summary: Read what one conversation cost, broken down by model
      description: >-
        What this conversation has spent, one row per model it was billed under. The session resource already reports the same money as two scalars — `usage.costUsd` over the whole conversation, and `cacheSpend`, which DECOMPOSES part of that same figure rather than adding to it. This is the third view of those same dollars and adds nothing to them either: a conversation that ran on three models reports one number there and says which model spent it here, and the rows sum to that number.

        The model is the one a round was BILLED under, which is the model that answered and not the one that was asked for. A round this host cannot price contributes its tokens and no dollars, so its group reports costComplete false and its costUsd is a floor.

        A colleague refused the transcript is refused this.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of the conversation's consumption.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionUsageReport'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-what-one-conversation-cost-broken-down-by-model
  /v1/sessions/{sessionId}/changes:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: listSessionChanges
      tags: [sessions]
      summary: Read what a session changed on disk
      description: >-
        The files this session's write, edit and delete tools changed, each
        with what it did to the file, how many lines moved, and the content
        the file held before the session first touched it. It is what a diff
        view of a session's own work stands on, and what a touched-files list
        shows counts from. Outside a git repository narya keeps no file
        snapshots, so the session's answer there is empty and a step's
        rows say nothing was kept.

        Every row is measured from FIRST touch. A file the session edited five
        times is one row comparing what it held before edit one against what
        is on disk now — not five rows, and not the last edit's delta. That is
        also why a file put back the way it was is absent from the answer: it
        changed nothing, and reporting it at zero lines either way would claim
        otherwise.

        The current content is deliberately not served. The host and its
        clients share a filesystem, so a client reads the file itself and gets
        the version it is about to let somebody edit, rather than a copy that
        was already stale when it was sent. What only the host has is the
        before side.

        Both sides are bounded at 1 MiB (1048576 bytes). Past that nothing was
        kept, and the row says so through beforeState rather than serving an
        empty file or a truncated one: beforeBytes still carries the real
        size, so a client can say how large the file it cannot diff is.
        linesAdded and linesRemoved are then absent, because a count nobody
        could compute is not zero.

        The answer is read on the session's own machine and never wakes it: a session whose container is not running, whose environment
        this host does not hold, or one bound to none on a hosted home, is
        refused 422.
      parameters:
        - name: path
          in: query
          required: false
          description: >-
            Filter to one file, by the absolute path this operation reports
            for it, and serve that file's before content. Without it the
            answer covers every file the session changed and carries no
            content at all — a change list stays cheap, and the one file a
            diff view is opening is the one it asks for. A path this session
            never changed is an empty answer, not an error.
          schema:
            type: string
            maxLength: 4096
        - name: entryId
          in: query
          required: false
          description: >-
            Answer for ONE step of the conversation instead of for the session:
            what the tool call recorded on this entry did to each file it
            mutated, measured from the restore point taken before that entry to
            the one taken at it. Both sides are then served (beforeContent and
            afterContent), because the after side is a moment that has since
            been edited over and nothing on disk remembers it.
            It exists because the session-scoped answer above is the wrong one
            under an old receipt: a file edited five times has one row carrying
            all five edits, so drawing it under the first receipt credits that
            call with every line that came after it. An entry whose step
            mutated nothing is an empty answer, not an error, and a step the
            host never snapshotted comes back with beforeState "too-large" —
            nothing was kept — rather than as an empty diff.
            path and entryId are not combined; entryId wins when both are
            given.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: The files this session changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileChangeSet'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-what-a-session-changed-on-disk
  /v1/sessions/{sessionId}/inputs/{entryId}:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/EntryIdParam'
    delete:
      operationId: cancelQueuedInput
      tags: [sessions]
      summary: Drop a message still waiting behind a running turn
      description: >-
        Withdraw one message a person queued while a turn was running, before
        the turn reaches it. Session.pendingInputs is what lists them; this is
        the only way one leaves that list other than by being delivered.
        It exists because a queued message is otherwise a decision nobody can
        take back: the words are already accepted, the turn that will receive
        them may run for minutes, and the person who typed them has since
        changed their mind. Both clients show the count and the queue; neither
        could do anything about it.
        The entry STAYS in the transcript, carrying an excludedReason saying it
        was withdrawn. A message a person typed is not unsaid by dropping it,
        and a transcript that lost the row would disagree with their own memory
        of the session — but no model ever receives it, on this turn or any
        later one.
        A message already delivered is a 404, exactly as an entry that does not
        exist is: both mean there is nothing here still waiting.
      responses:
        '200':
          description: The session, with the withdrawn message gone from its queue.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/drop-a-message-still-waiting-behind-a-running-turn
  /v1/sessions/{sessionId}/monitors:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: listMonitors
      tags: [monitors]
      summary: List the session's running monitors
      description: >-
        Every watcher this session is running right now, in the order they
        were started, each saying what it runs and who turned it on. This is
        what a status line counts and a session panel lists; a client that
        has been watching the event stream already knows, and this read is
        how one that just attached catches up.
      responses:
        '200':
          description: The session's running monitors.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MonitorList'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-the-session-s-running-monitors
    post:
      operationId: startMonitor
      tags: [monitors]
      summary: Start a monitor
      description: >-
        Runs command under the session's repository and streams what it says
        into the conversation as it happens — batched on the pauses between
        bursts, so one test failure is one piece of news rather than forty
        lines. The monitor is announced in the transcript, listed until it is
        stopped, and killed with the host. A name already in use in this
        session is refused rather than replaced. It runs on
        the session's own machine, in its tree, and never wakes it: a
        session whose container is not running, whose environment this host
        does not hold, or one bound to none on a hosted home, is refused 422
        and nothing starts.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MonitorStart'
      responses:
        '201':
          description: The monitor, now running.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Monitor'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/start-a-monitor
  /v1/sessions/{sessionId}/monitors/{name}:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/MonitorNameParam'
    delete:
      operationId: stopMonitor
      tags: [monitors]
      summary: Stop a monitor
      description: >-
        Ends the named watcher's whole process tree and records the stop in
        the transcript. Returns once the process is gone.
      responses:
        '204':
          description: Stopped. The process is gone.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/stop-a-monitor
  /v1/sessions/{sessionId}/prompt:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: getAssembledPrompt
      tags: [refinements]
      summary: Read the exact system prompt this session's next turn will send
      description: >-
        The assembled system prompt, verbatim, plus the ledger's accounting
        beside it: which learned facts fired into it, and which the budget
        for this model's context window could not fit. It exists so a person
        debugging a strange behaviour can read what the model is actually
        being told instead of reasoning about it.


        HOW THE PROMPT IS COMPOSED IS NOT ON THIS SURFACE, and the omission is
        deliberate rather than an oversight: no combinator order, no wrapper
        template, no budget formula and no selection rule. A client renders
        what it is given and never recomputes it.


        A session whose environment cloned a repository carries that clone's
        instruction files, read on its container and never by waking it. A session whose container is not running is refused 422
        before the prompt is built: asleep when the container is gone, as
        narya's idle cut leaves it; unreachable once a read has met it stopped
        outside narya, until the next turn starts it again, and when its
        runtime does not answer or this host is not attached to it. Those files are marked unread in the prompt only when
        the container was running as the read began and a read of them then
        failed.


        The prompt is that of a message naming no agent, so a session opened
        under one reports its agent's instructions, tools and model. When that
        agent no longer resolves here, or names a model this host cannot
        serve, the read is refused 422 in the sentence the turn would be
        refused with.
      responses:
        '200':
          description: The prompt as assembled, with the ledger's accounting.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AssembledPrompt'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-the-exact-system-prompt-this-session-s-next-turn-will-send
  /v1/drafts:
    get:
      operationId: getRepositoryDraft
      tags: [sessions]
      summary: Get the caller's saved composer draft for a repository
      description: >-
        Returns the live draft text the caller last saved for repo's composer: a debounced autosave written as they type,
        restored when a client opens a session against this repository so a
        killed terminal never costs unsent work. The draft is the caller's
        own: every window the caller opens on the same repository shares it
        (last write wins), and a colleague on the same home never reads it.
        An empty string when the caller never saved one — not an error.
      parameters:
        - name: repository
          in: query
          required: true
          description: Absolute path of the repository whose draft to read.
          schema:
            type: string
            maxLength: 4096
      responses:
        '200':
          description: The saved draft.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RepositoryDraft'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/get-the-repository-s-saved-composer-draft
    put:
      operationId: putRepositoryDraft
      tags: [sessions]
      summary: Save the caller's composer draft for a repository
      description: >-
        Upserts the caller's own live draft text for repo's composer. Called
        debounced on every edit; the client also calls this with an empty
        string on submit, so nothing is left behind for a message that was
        actually sent. The caller's windows on the same repository share this
        one draft — the later save always wins — and a colleague's save never
        reaches it. A clearing write names the
        content it expects to be clearing via ifMatchesSha256, so a submit in
        one window can never erase text another window typed into the same
        repository.
      parameters:
        - name: repository
          in: query
          required: true
          description: Absolute path of the repository whose draft to save.
          schema:
            type: string
            maxLength: 4096
        - name: ifMatchesSha256
          in: query
          required: false
          description: >-
            Optional precondition: apply this write only while the caller's draft
            currently stored for repository is still the one whose text hashes
            to this digest — lowercase hex SHA-256 over the text's UTF-8 bytes,
            where a repository with no stored draft hashes as the empty string.
            412 and no write when it no longer matches, which means another
            client wrote the row after this one last did. Debounced saves omit
            it (last write wins is what shares one composer across
            windows); the terminal sends it only when CLEARING a draft it just
            submitted or discarded, naming the text it itself last stored — so
            the clear reaches its own text and never a sibling window's unsent
            draft.
          schema:
            type: string
            pattern: '^[0-9a-f]{64}$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RepositoryDraft'
      responses:
        '204':
          description: Saved.
        '400':
          $ref: '#/components/responses/BadRequest'
        '412':
          $ref: '#/components/responses/PreconditionFailed'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/save-the-repository-s-composer-draft
  /v1/repository-trust:
    get:
      operationId: getRepositoryTrust
      tags: [sessions]
      summary: Read whether a repository's instruction files may be loaded
      description: >-
        Reports the one answer stored for repo: whether its own instruction
        files (AGENTS.md, or CLAUDE.md when there is no AGENTS.md, plus
        docs/PROJECT_RULES.md) may enter the system prompt, together with
        which of those files the repository actually carries right now.
        `unknown` means nobody has been asked — an interactive client asks
        once and records the answer here; a non-interactive run loads
        nothing and says so. Both `unknown` and `refused` keep every
        repository-supplied file out of the prompt: a repository somebody
        cloned does not get to write into the model's instructions unasked.
        The answer is stored per canonical repository path and by that path
        alone — a worktree, or a subdirectory opened as its own session,
        answers for itself.
      parameters:
        - name: repository
          in: query
          required: true
          description: >-
            Absolute path of the repository to report on. On a hosted home it
            resolves under the home's workspace, and a path outside it is
            refused 422.
          schema:
            type: string
            maxLength: 4096
      responses:
        '200':
          description: The repository's trust answer and the files it would load.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RepositoryTrust'
        '400':
          $ref: '#/components/responses/BadRequest'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/read-whether-a-repository-s-instruction-files-may-be-loaded
    put:
      operationId: putRepositoryTrust
      tags: [sessions]
      summary: Record a repository's instruction-file answer
      description: >-
        Stores the answer a person gave for repo, replacing any earlier one. Only a real answer may be written: `unknown` is the absence of an answer, and recording it would be a claim nobody made. Trusting takes effect on the next turn — instruction files are read as each turn is built, so nothing needs restarting. A `trusted` answer is REFUSED, and nothing is stored, when the repository's own `.narya/config.toml` will not load, declares a rule that would grant what this machine's owner denied, or names an instruction file the repository does not carry: 422, with the path in the message. Those are the refusals opening a session in that directory already makes, made here because this answer is what arms the file — and because a stored answer can only be replaced, never withdrawn. A `refused` answer arms nothing. On a hosted home every answer is refused 422 and nothing is stored: a session there is trusted by the repository its container cloned.
      parameters:
        - name: repository
          in: query
          required: true
          description: >-
            Absolute path of the repository being answered for.
          schema:
            type: string
            maxLength: 4096
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RepositoryTrustAnswer'
      responses:
        '204':
          description: Recorded.
        '400':
          $ref: '#/components/responses/BadRequest'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

      x-mint:
        href: /pt/reference/platform/narya/record-a-repository-s-instruction-file-answer
  /v1/sessions/{sessionId}/messages:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: createMessage
      tags: [messages]
      summary: Submit a user message
      description: >-
        Submits a message into the session's main lane. The response is 202
        with a turn id only: results never arrive on this response — deltas,
        tool activity and completion stream over GET /v1/events. Steering is
        this same operation: a message posted while the session is running is
        steering by default, queued and taken into account between complete
        tool batches without discarding work already done; queueKind follow-up
        instead starts a fresh turn after turn-finished. Entry admission and
        its queue operation record commit atomically before anything executes.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageCreate'
      responses:
        '202':
          description: >-
            Accepted. The turn's results stream over the event stream; this
            body carries only the turn id to correlate them.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TurnAccepted'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          description: >-
            The message's attachments together weigh more than one supplier
            request can carry (NRY-0027). Nothing was admitted; the bytes stored
            for the files earlier in the list were removed with the refusal.
            The same status answers a request body past this operation's own
            ceiling (NRY-0024), which the host refuses at the door before any
            handler reads it; the code says which of the two happened.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ProductNotLoaded'

      x-mint:
        href: /pt/reference/platform/narya/submit-a-user-message
  /v1/sessions/{sessionId}/interrupt:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: interruptSession
      tags: [messages]
      summary: Interrupt the session's main lane
      description: >-
        Stops work in progress on the session's main lane within one action.
        Work already completed is preserved; the interrupted turn's record
        stays durable. Completion of the interrupt is observable as a
        turn-finished event with stopReason interrupted. Queued steering and
        follow-up inputs are not cleared or started; the session becomes
        waiting when any remain, otherwise idle.
      responses:
        '202':
          description: Interrupt accepted; the agent stops within one action.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/interrupt-the-session-s-main-lane
  /v1/events:
    get:
      operationId: streamEvents
      tags: [events]
      summary: Stream events (SSE)
      security:
        - bearerAuth: []
        - extensionSignature: []
      description: >-
        The global event firehose as server-sent events (text/event-stream). Every client consumes this one stream; events are encoded once per update and fanned out per connection, and a slow client is dropped rather than blocking the others. Filter to one session with the sessionId query parameter. Each SSE frame's id field carries the event id; on reconnect, send it in the Last-Event-ID request header to resume from the first event after it — delivery survives client restarts. Event ids are monotonic integers. The data field of each frame is one JSON Event envelope, discriminated by its type property.

        A FINISHED TURN'S EVENTS ARE FORGOTTEN. About thirty seconds after a turn ends its events are deleted, and a start of the host deletes each finished turn's events older than thirty seconds, however recently that turn ended; the transcript is the record of the turn. A replay from a Last-Event-ID below anything this stream has forgotten begins with one replay-gap frame with no SSE id line, so it moves no cursor, followed by the events that remain. A client that receives it re-reads the transcript (listSessionEntries) from what it already holds and resumes the stream from that page's lastEventId.

        A REPLAY MAY BE SERVED IN BLOCKS. When the backlog past Last-Event-ID is long, the server writes a bounded block of frames and then ends the connection rather than materialising the whole backlog onto it. The block-end is an ordinary end of connection carrying no marker: a client resumes as it would from any drop, sending the last id it received as Last-Event-ID, and continues from exactly there. Nothing is lost and nothing is duplicated — a client that already skips ids at or below the last one it delivered needs no change at all, and a replay short enough to fit in one block is served exactly as before.

        A CURSOR THIS STORE CANNOT HONOUR IS REFUSED, not served. A Last-Event-ID naming an event newer than anything the store holds gets 409 (NRY-0026) instead of an open stream: the replay past it is empty by definition and every live event is below it, so a 200 there is a connection that can never carry a frame — silent for the life of the process, with nothing timing out to say so. It is what a client holds after the store underneath it was replaced. A cursor that is merely OLD is unaffected: it replays the events that remain past it, opening with a replay-gap frame when the stream forgot some of them. The remedy is to resume with no Last-Event-ID, never to retry the same cursor.
      parameters:
        - name: sessionId
          in: query
          required: false
          description: >-
            Only stream events for this session. One this home holds no
            session by, or another member's product session, answers 404.
          schema:
            type: string
            format: uuid
        - name: hostEvents
          in: query
          required: false
          description: >-
            When sessionId narrows the stream, also deliver the events that
            belong to no session at all — things true of the whole machine,
            like an installed package's files changing under everybody
            (ladder-changed). Off by default, so an existing consumer's
            stream is unchanged. Ignored without sessionId, where the
            unfiltered firehose already carries them.
          schema:
            type: boolean
            default: false
        - name: Last-Event-ID
          in: header
          required: false
          description: >-
            Resume the stream from the first event after this id, as defined
            by the SSE specification.
          schema:
            type: string
      responses:
        '200':
          description: >-
            An open server-sent event stream. Each frame's data field is one
            Event envelope.
          content:
            text/event-stream:
              schema:
                $ref: '#/components/schemas/Event'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/stream-events-sse
  /v1/sessions/{sessionId}/lanes:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: listLanes
      tags: [lanes]
      summary: List a session's lanes
      description: >-
        Lists the parallel tracks inside the session: the main lane, subagent
        lanes carrying delegated work, and side-conversation lanes — each
        with its status and what it is currently doing.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of lanes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LanePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-a-session-s-lanes
    post:
      operationId: createLane
      tags: [lanes]
      summary: Open a lane
      description: >-
        Opens a parallel track inside the session. kind side opens a side
        conversation that runs without pausing or contaminating the main
        work; kind subagent delegates to the named agent recipe, running
        concurrently with other delegated work. The initial message starts
        the lane's first turn; its results stream over the event stream.

        A session holds at most one OPEN side conversation. A side
        conversation is a conversation, so a follow-up question belongs in
        the lane that already has one rather than in a new lane that never
        saw the exchange: list the session's lanes, find the side lane whose
        status is not closed, and submit the question to it
        (createLaneMessage). Asking for a second one answers 409 and names
        the lane that holds it. Closing the open one and opening another is
        how a fresh side conversation is started.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LaneCreate'
      responses:
        '201':
          description: The opened lane.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Lane'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ProductNotLoaded'

      x-mint:
        href: /pt/reference/platform/narya/open-a-lane
  /v1/sessions/{sessionId}/lanes/{laneId}:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/LaneIdParam'
    delete:
      operationId: closeLane
      tags: [lanes]
      summary: Close a lane
      description: >-
        Closes a side-conversation or subagent lane. The main conversation is
        returned to unchanged: nothing discussed in the lane enters the main
        context unless the user asks for it. The lane's transcript stays in
        the durable record. The main lane cannot be closed. Closing a lane stops
        its turn and every agent delegated beneath it, at any depth, whose
        reports then wake nobody.
      responses:
        '204':
          description: Closed. The lane's transcript remains in the record.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/close-a-lane
  /v1/sessions/{sessionId}/lanes/{laneId}/messages:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/LaneIdParam'
    post:
      operationId: createLaneMessage
      tags: [lanes]
      summary: Submit a message to a lane
      description: >-
        Submits a message into a specific lane — the side conversation, or a
        subagent lane. Same 202 contract as the session-level message: a turn
        is accepted and its results stream over events, never on this
        response. One case answers 204 instead: a lane STOPPED on a question
        takes the message as the answer to that question, which unblocks the
        agent already running on it and opens no turn of its own.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MessageCreate'
      responses:
        '202':
          description: Accepted; results stream over the event stream.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TurnAccepted'
        '204':
          description: >-
            The message answered the question this lane was stopped on. The
            agent carries on inside the turn it was already running, so there
            is no new turn to correlate and no body to return.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          description: >-
            The message's attachments together weigh more than one supplier
            request can carry (NRY-0027). Nothing was admitted; the bytes stored
            for the files earlier in the list were removed with the refusal.
            The same status answers a request body past this operation's own
            ceiling (NRY-0024), which the host refuses at the door before any
            handler reads it; the code says which of the two happened.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ProductNotLoaded'

      x-mint:
        href: /pt/reference/platform/narya/submit-a-message-to-a-lane
  /v1/sessions/{sessionId}/lanes/{laneId}/interrupt:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/LaneIdParam'
    post:
      operationId: interruptLane
      tags: [lanes]
      summary: Interrupt a lane
      description: >-
        Stops work in progress on one lane within one action, without
        touching the session's other lanes.
      responses:
        '202':
          description: Interrupt accepted; the lane stops within one action.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/interrupt-a-lane
  /v1/sessions/{sessionId}/lanes/{laneId}/promote:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/LaneIdParam'
    post:
      operationId: promoteLane
      tags: [lanes]
      summary: Stop waiting on a delegated lane
      description: >-
        Releases the delegation that is waiting on this lane, without
        stopping the work. The agent that asked for the delegate carries on
        immediately; the delegate keeps running and its report is delivered
        into the conversation when it finishes. Answers 409 when nothing is
        waiting on the lane — the delegation was started in the background,
        or it has already reported back.
      responses:
        '202':
          description: Released; the delegate keeps working.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/stop-waiting-on-a-delegated-lane
  /v1/sessions/{sessionId}/lanes/{laneId}/compact:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/LaneIdParam'
    post:
      operationId: compactLane
      tags: [lanes]
      summary: Summarise this lane's conversation now
      description: >-
        Summarises the older part of this lane's conversation on demand,
        instead of waiting for the host to decide the next round no longer
        fits. It is the same summarisation the automatic path runs, so it
        costs one model round and the `compaction` event it publishes carries
        the reason `manual`.

        Lane-scoped because a window belongs to a lane: the conversation and
        each delegation beside it carry their own, and the host has no opinion
        about which of them a session-wide gesture would mean.

        The paid gesture of the pair. `prune` frees old tool output for
        nothing and destroys no reasoning; this one throws conversation away
        and pays a round to do it, so a client offering both should offer
        pruning first.

        Answers 409 when a turn is running on the lane — rebuilding the
        window under a round in flight would race the turn that owns it —
        409 when there is nothing left to summarise, 409 when narya does not
        know this model's context window yet: the catalogue states none and
        no refusal from the provider has taught one, and 409, running
        nothing, when the caller's day has reached the home's daily spend
        ceiling. The token figure a
        client is showing goes UNKNOWN rather than moving: nobody can say
        what the rebuilt window costs until a provider counts it, so the
        meter refills on the next metered round.

        A session whose environment cloned a repository carries that clone's
        instruction files in the window, read on its container, which a
        summary starts again when it was stopped for idleness. One this host
        is not attached to, or one that came back without its workspace, is
        refused 422; one this host could not start answers 500. The summary
        runs on the model the lane's next turn runs on, so a lane whose agent
        no longer resolves is refused 422, as that turn would be.
      responses:
        '202':
          description: >-
            Summarised; the `compaction` event names the summary entry that
            replaced the history it covers.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/summarise-this-lane-s-conversation-now
  /v1/sessions/{sessionId}/lanes/{laneId}/prune:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
      - $ref: '#/components/parameters/LaneIdParam'
    post:
      operationId: pruneLane
      tags: [lanes]
      summary: Set aside this lane's old tool output
      description: >-
        Takes the model-facing text of old tool results out of this lane's
        window — a file read from forty rounds ago that is re-sent verbatim on
        every round since. It sends no model round, so it costs nothing, and
        it summarises nothing away: the conversation keeps every word, the
        transcript keeps every result, and only what the model re-reads
        shrinks.

        The cheap gesture of the pair, and the one to reach for first. What is
        set aside stays out of the window durably, so this is a decision
        rather than a hint the next round recomputes.

        The newest results are never taken — an agent that loses the result it
        is about to act on has lost the plot — and neither is anything small
        enough that the trade is not worth a permanent hole in what the model
        remembers.

        How many results were set aside travels on the `context-pruned`
        event, not in this response: a second client watching the same lane
        must learn the same fact through the same channel, whether the prune
        came from here or from the host's own threshold.

        Answers 409 when a turn is running on the lane, 409 when nothing in
        the window is eligible, and 409 when narya does not know this model's
        context window yet. The window is that of the model the lane's next
        turn runs on, read where that turn reads it: in a session whose
        environment cloned a repository, on its container, which a prune
        starts again and is refused over as a summary is. A lane whose agent
        no longer resolves is refused 422, as that turn would be.
      responses:
        '202':
          description: >-
            Pruned; the `context-pruned` event carries how many results were
            set aside.
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/set-aside-this-lane-s-old-tool-output
  /v1/agents:
    get:
      operationId: listAgents
      tags: [agents]
      summary: List agent recipes
      description: >-
        Lists the named agent recipes available on this host — name,
        description, model, allowed tools, mode and where each came from. Recipes come from configuration and packages;
        read-only in v1, so there is no create or update operation.
      parameters:
        - $ref: '#/components/parameters/AgentRepositoryParam'
        - $ref: '#/components/parameters/AgentSessionParam'
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of agent recipes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AgentPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-agent-recipes
  /v1/agents/{name}:
    parameters:
      - $ref: '#/components/parameters/AgentNameParam'
      - $ref: '#/components/parameters/AgentRepositoryParam'
      - $ref: '#/components/parameters/AgentSessionParam'
    get:
      operationId: getAgent
      tags: [agents]
      summary: Get an agent recipe
      description: >-
        Returns one named agent recipe. For a session whose container cannot
        be read right now, a name none of the caller's own recipes carries is
        refused 422 in words saying the clone's recipes appear once it can be
        read.
      responses:
        '200':
          description: The agent recipe.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Agent'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/get-an-agent-recipe
  /v1/skills:
    get:
      operationId: listSkills
      tags: [ladder]
      summary: List installed skills
      description: >-
        Lists the skills in force for work in this repository — name, the one
        line that says when to reach for one, the file it lives in and the
        rung it came from. Five origins are merged, nearest winning a name,
        so a skill listed here is the one that will actually load; the ones a
        nearer rung shadows are simply absent. The bodies are never here:
        progressive disclosure means a catalogue carries names, and
        expandSkill is what reads one. Notices carries whatever the loaders
        complained about while reading — a package skill something nearer
        displaces, a file with no description — for a client to show once.
      parameters:
        - $ref: '#/components/parameters/LadderRepositoryParam'
        - $ref: '#/components/parameters/LadderSessionParam'
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of installed skills.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SkillPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-installed-skills
  /v1/skills/{name}/expansion:
    parameters:
      - $ref: '#/components/parameters/SkillNameParam'
    post:
      operationId: expandSkill
      tags: [ladder]
      summary: Expand one skill into text
      description: >-
        Reads one skill's instructions and returns them framed the way the
        model's own skill tool frames them — whose they are and which
        directory their relative paths resolve against — with whatever the
        person typed after the name appended. The file is read NOW, through
        the confinement its origin was discovered with, so a skill edited
        since it was listed is returned as edited and a project skill whose
        path has since become a symlink out of the checkout is refused. A
        body that cannot be read comes back as a sentence saying so rather
        than as an error: the caller asked for text to put in front of a
        person, and text is what it gets.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExpansionRequest'
      responses:
        '200':
          description: The expanded text.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Expansion'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/expand-one-skill-into-text
  /v1/commands:
    get:
      operationId: listCommands
      tags: [ladder]
      summary: List slash commands
      description: >-
        Lists every command file that can be expanded for work in this
        repository — an installed package's declared commands, the person's
        own under $NARYA_HOME/commands and ~/.claude/commands, and, only in a
        trusted repository, the project's own .claude/commands and
        .narya/commands. Name, description, argument hint, and where the file
        came from; never the body, which expandCommand is for. Notices
        carries the loaders' own complaints.
      parameters:
        - $ref: '#/components/parameters/LadderRepositoryParam'
        - $ref: '#/components/parameters/LadderSessionParam'
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of command files.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CommandPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-slash-commands
  /v1/commands/{name}/expansion:
    parameters:
      - $ref: '#/components/parameters/CommandNameParam'
    post:
      operationId: expandCommand
      tags: [ladder]
      summary: Expand one command file into text
      description: >-
        Expands one command file and returns the text: its shell blocks run,
        their output spliced in where they stood, and the arguments
        substituted into what that produced. The order is deliberate and is
        not the caller's to choose — a pasted issue body carrying a backtick
        block must never become a block.
        Shell blocks run only in a repository somebody has trusted, and never
        at all for a command a package shipped; a block that did not run is
        replaced by a sentence saying why. They run in the named repository's
        own directory, and only when that repository is trusted — an
        untrusted checkout is never a working directory. One expansion's
        blocks share a ten-second deadline, so this call can take that long.
        A name two command files both claim is refused with 409 naming both,
        never awarded to one of them: listCommands lists every claimant for
        exactly that reason, and there is never an implicit winner.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExpansionRequest'
      responses:
        '200':
          description: The expanded text.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Expansion'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/expand-one-command-file-into-text
  /v1/permissions:
    get:
      operationId: listPermissions
      tags: [permissions]
      summary: List permission asks
      description: >-
        Lists permission asks, by default the pending ones — actions waiting
        on a human decision. Pending asks also arrive as permission-asked
        events; this list is the poll/recovery path for clients that
        reconnect.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: status
          in: query
          required: false
          description: Filter by ask status.
          schema:
            type: string
            enum: [pending, decided, expired]
            default: pending
      responses:
        '200':
          description: One page of permission asks.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermissionPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-permission-asks
  /v1/permissions/{permissionId}/decision:
    parameters:
      - $ref: '#/components/parameters/PermissionIdParam'
    post:
      operationId: decidePermission
      tags: [permissions]
      summary: Decide a permission ask
      description: >-
        Allows or denies a pending ask, optionally remembering the decision for the session or permanently. A denial is reported to the agent with its reason and the agent continues rather than crashing. Every decision is recorded in the audit. Deciding an already-decided ask is a conflict.


        A PRODUCT WRITE (action `product`, its `productCall` the exact call) is decided by its owner alone, once per call: `remember` other than `none` is refused 422. A deny sends nothing; the decision goes into the transcript, and a message given with it starts the next turn as the person's message. An allow records the approval, then sends the stored call once, with this request's bearer and the ask's idempotency key, whatever happens to this request meanwhile, records how it ended (`outcome`), and starts the next turn with the result, as this request's person. A recorded decision answers its record even when its conversation cannot take that turn now (the host is shutting down): its result, or its decline with any words given, reach the conversation at the person's next message, once, and deciding it the same way again tells them nothing twice. A second decision while the send runs is 409. A write proposed before another of its owner's writes to the same endpoint (the same method and path, the path as built compared without its query, trailing slash or letter case, and each id in one spelling) was approved is stale: allowing it is 409, it ends declined with nothing sent, and its conversation is told to read the current state before proposing it again. An approved write whose outcome is unknown can only be allowed again, for 15 minutes from its approval, which sends it again under the same key; a deny is 409. A re-send starts only while the product tree's timeout ends inside those minutes; later, allowing it is 409, sends nothing, and the write ends when those minutes do. Allowing a write already settled sends nothing and tells its result again only to a conversation never told it. A host shutting down before the send sends nothing (500 NRY-0021). A product that refuses this person's sign-in (401) wrote nothing with that send, and the answer is 401 NRY-0011: on the first send the approval is taken back and the write is pending again; on a re-send the write keeps its approval and its unknown outcome. A product whose tools this home has not loaded yet is 503, with nothing sent.


        CREDENTIALS. An allow of a write whose `productCall.secrets` names credentials carries the values the person typed on its card in `secrets`, by property name. Before the approval is recorded or a re-send starts, a name the call does not list, a required one left out, an empty value or one longer than its `maxLength` is 422 naming the properties and never a value, and nothing is recorded or sent. A re-send asks for them again, with the same names as the last recorded send (`outcome.entered`), or 422. `secrets` on a deny, or on an ask that is not a product write, is 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PermissionDecisionRequest'
      responses:
        '200':
          description: >-
            The recorded decision. This operation answers a PENDING ask and
            409s anything else, but for a product write, whose record it answers
            again when the write is allowed once more after its send settled or
            declined once more after its decline, so `decidedBy` and
            `decidedAt` are always present in this response. They are optional on the shared schema
            because the audit listing reports `expired` entries too, which
            nobody decided — a client reading only this operation may treat
            both as guaranteed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermissionDecisionRecord'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          description: >-
            A product write's product has no tools loaded on this home yet
            (NRY-0021): nothing was sent, and allowing it again sends it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

      x-mint:
        href: /pt/reference/platform/narya/decide-a-permission-ask
  /v1/permissions/decisions:
    get:
      operationId: listPermissionDecisions
      tags: [permissions]
      summary: List the decision audit
      description: >-
        Cursor-paginated audit of every permission decision ever taken on
        this host — by a human or by policy, including denials during
        unattended work. Every decision is recorded and reviewable
        afterwards.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: sessionId
          in: query
          required: false
          description: Filter to decisions taken in one session.
          schema:
            type: string
            format: uuid
        - name: decision
          in: query
          required: false
          description: >-
            Filter to one outcome. `deny` is the audit's own question — what
            was refused, and why — and `expired` is the finding when somebody
            asks why an action did not happen and the answer is that nobody
            answered.
          schema:
            type: string
            enum: [allow, deny, expired]
        - name: since
          in: query
          required: false
          description: >-
            Only decisions whose `requestedAt` is at or after this instant.
            It is the half of "what was denied last week and why" that the
            `decision` filter alone cannot answer: without it an operator
            pages backwards from now until `requestedAt` drops out of the
            window they care about, which is a paging exercise rather than
            one call. Served off the same index as the cursor.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          description: >-
            Only decisions whose `requestedAt` is at or before this instant.
            Combines with `since` to bound a window from both ends; either
            may be given alone. A window whose `until` precedes its `since`
            is a malformed request rather than an empty page, because an
            empty page reads as "nothing was denied".
          schema:
            type: string
            format: date-time
      responses:
        '200':
          description: One page of decision records.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PermissionDecisionPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-the-decision-audit
  /v1/questions:
    get:
      operationId: listQuestions
      tags: [permissions]
      summary: List the questions waiting on a person
      description: >-
        The questions the agent has asked and nobody has answered yet — the recovery road for a client that was not attached when `question-asked` went out.


        It is the road `listPermissions` already is for the other kind of ask, and it exists for the same reason: a question is durable state on the host and the event announcing it is long past by the time a client attaches, which reads the transcript and then subscribes after the position that read returned. A question is not a transcript entry, so a client with no retained event cursor has nothing else to reconstruct it from.


        ONLY PENDING QUESTIONS, and there is no status filter. An answered question is retired by `tool-call-finished` for the call it was blocking, and the decision audit never carries a question, so the only thing there is to list is what somebody still has to answer.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: sessionId
          in: query
          required: false
          description: >-
            Filter to the questions standing on one conversation, which is what a client attaching to a session asks for.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: One page of questions waiting on an answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QuestionPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-the-questions-waiting-on-a-person
  /v1/questions/{questionId}/answer:
    parameters:
      - $ref: '#/components/parameters/QuestionIdParam'
    post:
      operationId: answerQuestion
      tags: [permissions]
      summary: Answer a question the agent asked
      description: >-
        Records what the person chose for one `ask_user` call and releases the tool call that has been blocked on it.


        One answer per question, in the order the questions were asked. The indices are into the option list exactly as question-asked sent it — the engine ordered those options, so a client that reordered them would send back the wrong choice. Answering a question that is already answered, or one a host closed when its turn was interrupted, is a conflict: the tool it was blocking is no longer waiting.


        A question is not a permission and this is not decidePermission: no rule can be written about a question, nothing is remembered, and the decision audit never carries one.


        On a product session nothing is blocked: the `ask_user` call finished when it asked and its turn ended there, and the question stands, a restart included, until it is answered here or the person sends a message on its lane instead, which closes it. Answering it starts the next turn with the answer as the person's message, called as this request's person; the `204` is that turn accepted, and a refusal to admit it answers as createMessage would. The message rides the question's id as its idempotency key, so answering again after a refusal, or a second press, sends the recorded answer once rather than answering `409`. A refusal that leaves no message behind reopens the question and answers `409` `NRY-0032` instead of the message road's own status: the answer was not delivered and the question is pending again, which a client must not read as the question being settled.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/QuestionAnswerRequest'
      responses:
        '204':
          description: >-
            Answered. On a session with a blocked tool call, that call is
            running again, and its close arrives as tool-call-finished for the
            call question-asked named. On a product session nothing was
            blocked: the answer is the person's message, and this `204` is the
            next turn accepted.
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          $ref: '#/components/responses/ProductNotLoaded'

      x-mint:
        href: /pt/reference/platform/narya/answer-a-question-the-agent-asked
  /v1/peers:
    get:
      operationId: listPeers
      tags: [intercom]
      summary: List addressable peer sessions
      description: >-
        Lists the sessions on this machine that are addressable by name for
        inter-session messaging, with what each is working on and its
        liveness. A session nothing is serving is listed as gone, never as
        available, and addressing it is refused (NRY-0009).
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of peers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PeerPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-addressable-peer-sessions
  /v1/sessions/{sessionId}/inbox:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: sendInboxMessage
      tags: [intercom]
      summary: Send a message to a session's inbox
      description: >-
        Delivers a message from one session to another on the same machine.
        Delivery is guaranteed: a message to a busy session is
        queued and delivered when it becomes free, never lost, and delivery
        survives the recipient restarting. kind message is fire-and-forget
        with a delivery receipt; kind ask returns an askId as soon as the
        question is delivered or queued, and nothing waits on it — the asking
        session carries on, and the answer, whenever one is given, arrives in
        its transcript and on the event stream as an intercom-message event
        carrying the same askId (also readable at GET /v1/asks/{askId}); kind
        answer resolves an earlier ask (askId required) and is posted to the
        ASKING session's inbox, which is the session that reads it. An ask
        stays pending until answered and takes at most one answer.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/InboxMessageCreate'
      responses:
        '202':
          description: >-
            Accepted for delivery — delivered now, or durably queued until
            the recipient is free.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InboxReceipt'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/send-a-message-to-a-session-s-inbox
  /v1/asks/{askId}:
    parameters:
      - $ref: '#/components/parameters/AskIdParam'
    get:
      operationId: getAsk
      tags: [intercom]
      summary: Get an inter-session ask
      description: >-
        Returns the state of a question one session asked another, including
        the answer once given. The answer also arrives in the asking session's
        transcript and on the event stream as an intercom-message event.
      responses:
        '200':
          description: The ask and, when answered, its answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Ask'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/get-an-inter-session-ask
  /v1/packages:
    get:
      operationId: listPackages
      tags: [packages]
      summary: List installed packages
      description: >-
        Lists installed packages — resource-only and code-carrying — with
        their enabled state and source.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of packages.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PackagePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-installed-packages
    post:
      operationId: installPackage
      tags: [packages]
      summary: Install a package
      description: >-
        Installs a package from a registry reference or a local path. The
        response is 202 with an install job, because a package carrying Go
        code triggers a rebuild and binary swap; poll the job (or watch
        events) for progress. Resource-only packages complete near-instantly.
        Conflicts — two packages claiming one slot — surface asynchronously,
        on the failed job's error envelope, as NRY-0006 naming both
        claimants (enable/disable are the one synchronous exception: they
        report a slot conflict as a direct 409 instead, since they have no
        job to poll). A failed build never leaves the user without a
        working agent: the previous binary stays in place (NRY-0007). A hosted home refuses it 422: a package
        is compiled and run on the host's own machine, and an extension
        registered at an HTTPS address is the hosted way.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PackageInstall'
      responses:
        '202':
          description: Install job started. Poll it for progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PackageJob'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/install-a-package
  /v1/packages/classification:
    get:
      operationId: classifyPackageSource
      tags: [packages]
      summary: Say what a source is, and whether installing it needs trust
      description: >-
        Reads a source off the host's disk — a directory, a single .go file or a Go module reference — and reports what kind of package it is and whether installing it needs an acknowledgement. Nothing is installed, nothing is written, and a module reference is NOT downloaded: nothing may fetch a package's code in order to decide whether to ask permission to fetch its code, so a reference is reported as unread rather than described.

        It exists because the decision belongs to the host and the QUESTION belongs to a surface. A client has to know whether to ask before it can ask. installPackage still enforces the same rule server-side regardless of what any caller did with this answer.

        A local path must be sent ABSOLUTE. A relative one resolves against the HOST's working directory, which is not the caller's, and the artifact the answer describes would not be the one the caller meant.

        A hosted home refuses it 422: a package is compiled and run on the host's own machine, and an extension registered at an HTTPS address is the hosted way.
      parameters:
        - name: source
          in: query
          required: true
          schema:
            type: string
            maxLength: 1024
          description: An absolute local path, or a registry/module reference.
      responses:
        '200':
          description: What that source is.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PackageClassification'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/say-what-a-source-is-and-whether-installing-it-needs-trust
  /v1/packages/jobs/{jobId}:
    parameters:
      - $ref: '#/components/parameters/JobIdParam'
    get:
      operationId: getPackageJob
      tags: [packages]
      summary: Get a package job
      description: >-
        Returns the progress of a package install or removal job:
        resolving, building, swapping, done or failed. A failed job carries
        the build error and guarantees the previous binary is intact.
      responses:
        '200':
          description: The job's current state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PackageJob'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/get-a-package-job
  /v1/packages/{name}:
    parameters:
      - $ref: '#/components/parameters/PackageNameParam'
    delete:
      operationId: removePackage
      tags: [packages]
      summary: Remove a package
      description: >-
        Removes an installed package. Removing a code-carrying package
        triggers a rebuild, so the response is 202 with a job; resource-only
        removals complete immediately with the job already done. A failed
        rebuild leaves the previous binary — including the package — in
        place. A hosted home refuses it 422: a package
        is compiled and run on the host's own machine, and an extension
        registered at an HTTPS address is the hosted way.
      responses:
        '202':
          description: Removal job started. Poll it for progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PackageJob'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/remove-a-package
  /v1/packages/{name}/disable:
    parameters:
      - $ref: '#/components/parameters/PackageNameParam'
    post:
      operationId: disablePackage
      tags: [packages]
      summary: Disable a package
      description: >-
        Disables a package without removing it. Disabling a code-carrying
        package rebuilds without it; a failed rebuild leaves the previous
        binary intact (NRY-0007). In the rare case the rebuild's binary
        swap succeeds but confirming the resulting package row does not,
        the response is 409 NRY-0017 instead: the change already applies
        and resolves automatically on the next host restart. A hosted home refuses it 422: a package is compiled
        and run on the host's own machine, and an extension registered at an
        HTTPS address is the hosted way.
      responses:
        '200':
          description: The package, now disabled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Package'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/disable-a-package
  /v1/packages/{name}/enable:
    parameters:
      - $ref: '#/components/parameters/PackageNameParam'
    post:
      operationId: enablePackage
      tags: [packages]
      summary: Enable a package
      description: >-
        Re-enables a disabled package. Enabling a code-carrying package
        rebuilds with it, synchronously: unlike install, a slot conflict
        here answers directly with 409 NRY-0006 naming both claimants,
        since there is no job to poll. A failed build leaves the previous
        binary intact (NRY-0007). In the rare case the rebuild's binary
        swap succeeds but confirming the resulting package row does not,
        the response is 409 NRY-0017 instead: the change already applies
        and resolves automatically on the next host restart. A hosted home refuses it 422: a package is compiled
        and run on the host's own machine, and an extension registered at an
        HTTPS address is the hosted way.
      responses:
        '200':
          description: The package, now enabled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Package'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/enable-a-package
  /v1/extensions:
    get:
      operationId: listExtensions
      tags: [extensions]
      summary: List this home's registered extensions
      description: >-
        Every extension this home has registered, in name order, with where it
        points, which points it sits on, which slots it claims, what was
        granted on it, whether it is enabled and who registered it when.
        **No response here ever carries a secret** — only the registration and
        the rotation that mint one do. Any member of the organisation may read
        this: the registry is what makes "what is reading our conversations"
        answerable, and one only its author could read would disclose nothing
        to the people whose prompts are crossing. An extension Lerian installed
        on a home it hosts is marked `installedByOperator`, which is how a
        customer learns it happened. Client-side extensions (pure
        TUI components) do not appear here — they live in the client binary.
      responses:
        '200':
          description: Every registered extension, in one answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExtensionPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-this-home-s-registered-extensions
    post:
      operationId: registerExtension
      tags: [extensions]
      summary: Register an extension
      description: >-
        Makes a program at an HTTPS address a principal of this home: it may then observe the event stream, transform what the agent assembles, and occupy the slots it claims. It transforms at the decision points named under `points`, of which it may name only `before-tool-call`, `before-model-request` and `after-tool-call`, and of the slots it may claim only `model-supplier` and `tool-server`, the two this host serves from a registered extension: any other point or slot is refused 422 naming it, because it would be stored and never called. **This response carries the registration's secret**, and it is not stored anywhere a read can reach — a caller that loses it rotates rather than re-reads. Requires `extensions:manage`, which is what "the home's owner" means in the mechanism; an ordinary member is answered 403 NRY-0028.


        The endpoint must be `https://` with no exception, loopback included, and the address it resolves to is judged before anything is stored: a loopback or private address is refused unless an operator wrote that range into this host's configuration, and a link-local, metadata, unspecified, multicast or NAT64-wrapped address is refused with no configuration able to admit it. A second claimant on an exclusive slot is answered 409 NRY-0006 naming the one that already holds it — **unless this host's `[extensions] prefer` configuration already names one of the two contesting registrations as that slot's winner.** That entry is the explicit tie-break, and it is the only thing that admits a second claimant here: without it, no winner may be picked implicitly, so the registration is refused. Where it is present the second registration is stored, the named registration occupies the slot, and the other one stays registered, stays enabled, keeps every other slot it claims and simply stops occupying the contested one. Revoking the occupant returns the slot to the survivor.


        **A slot this write claims is stored CLAIMED AND UNSERVED (a `model-supplier` given `models` in this body serves them already), and this write reaches the endpoint NOT AT ALL.** A discovery call made here would be signed under the secret this response has not handed over yet, which an extension that verifies signatures cannot check. Register in ONE write, take the secret from this response, configure the far side with it, and send `refreshModels: true`. On an enabled row — which is what this write leaves behind unless it asked for otherwise — that update reads `/models` under a secret both sides hold by then, and is the same road that re-reads the list afterwards.


        **Until that read the slot is claimed and offers no model.** It is in `slots`, `models` is ABSENT — the property is omitted from the response rather than served as an empty array — and no picker, no turn and no spend ceiling can select anything from it: the catalogue skips a claimed-and-unserved row exactly as it skips a disabled one, so nothing half-registered is selectable. What this write still decides is everything this home can decide alone — the arity above, and a 409 for a supplier whose models would answer under a provider name this host's catalogue already carries. Stating `models` on this write is the one way to register a supplier already serving, because those are on the row rather than at the far side.


        **A `tool-server` slot is stored the same way, for the same reason.** The `/tools` listing a tool name is judged against would be signed under that same unhanded secret, so it is not read here either. The listing is read on the first write that follows under a secret both sides hold — a change whose resulting row is enabled and that moves the `endpoint` or claims the slot — and on the tool sweep's own clock regardless. Until a list is read this home serves none of that registration's tools, and a name it would contest is refused on the write that reads it rather than on this one.


        An operator install rides this same operation under the plane's operator credential rather than a token carrying `extensions:manage`. A home holding no operator credential — every BYOC and every local deployment — cannot authenticate such a caller at all.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExtensionRegistration'
      responses:
        '201':
          description: >-
            The registration, with its secret. The secret appears here and in
            the rotation's response and nowhere else, ever.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExtensionRegistered'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            The caller's role does not carry `extensions:manage` (NRY-0028), or
            an operator install named an endpoint the hosted configuration does
            not.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: >-
            This home already registers that name (NRY-0004), or the registration claims an exclusive slot somebody else holds (NRY-0006, naming both) with no `[extensions] prefer` entry naming which of the two occupies it, or it would sit at the same position as another registration on a decision point they share (NRY-0004, naming both and the point). A tool name this host already serves is refused on the write that reads the `/tools` listing, and never on this one, which reads no listing. The order extensions run in is the owner's and is declared, so a tie is an order nobody declared: two extensions rewriting one tool call compose, and which goes first decides what the other is handed. A tool name has one owner for the same reason: there is never an implicit winner, and a source cannot take a name by registering.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/register-an-extension
  /v1/extensions/{publisher}/{name}:
    parameters:
      - $ref: '#/components/parameters/ExtensionPublisherParam'
      - $ref: '#/components/parameters/ExtensionNameParam'
    get:
      operationId: getExtension
      tags: [extensions]
      summary: Read one registered extension
      description: >-
        One registration, never its secret. Readable by any member, for the
        listing's reason.
      responses:
        '200':
          description: The registration.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Extension'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/read-one-registered-extension
    patch:
      operationId: updateExtension
      tags: [extensions]
      summary: Re-point, re-order, re-grant or disable a registered extension
      description: >-
        Changes the endpoint, the points, the slots, the position, the grants
        or the enabled flag. Requires `extensions:manage`. **Widening a grant
        additionally requires the caller to hold that permission themselves**,
        so nobody hands an extension reach they do not have; narrowing one
        never does. **Moving the endpoint requires every permission the row
        grants**, since the new address receives them all.


        **Four things here can reach the extension, and a disable outranks
        all four.** A moved `endpoint`, an explicit `refreshModels`, a
        `tool-server` slot this update claims that the row did not already
        hold, and — belonging to that slot alone — an `enabled` going from
        `false` to `true` on a row that already holds it. And then only for
        what the row has to answer for. A moved `endpoint` reads BOTH, the model
        list if the row claims `model-supplier` and the tool listing if it
        claims `tool-server`, because a new address is a new answer to both
        questions; `refreshModels` reads the model list alone; a newly-claimed
        `tool-server` slot, or an enable of a row already holding it, reads the
        tool listing alone — an enable being the moment this home starts
        serving that registration's tools, and the one road onto that which
        nothing else here checks. **Claiming `model-supplier` reaches it not at all
        by itself**: the claim alone stores that slot claimed and unserved, and
        `refreshModels` is what reads its catalogue, so an extension that
        verifies signatures is never called under a secret it does not hold
        yet. Everything else — a narrowed grant, a moved position, a changed
        point list, another slot, the switch itself — is a write against this
        home's own row that completes whether or not anything answers there.


        **Each of those reads FAILS CLOSED.** A model list or a tool
        listing this host asked for and could not read — a refused connection, a
        timeout, a non-`200`, a body that does not parse — answers `422` and
        changes nothing, rather than storing a claim it could not check. By this
        point the far side has had the secret since the registration response,
        so a refusal here is never a trap. **A
        moved `endpoint` is the one read that can fail for a passing reason**:
        it is an address this host has not reached before, so a far side that
        is not up yet refuses it — nothing is stored, the row keeps the
        endpoint it already had, and the move is made again once the new
        address answers. Either way the refusal names the remedy: check the
        endpoint answers `/tools` — or `/models` for a supplier — under the
        secret it holds, then make the write again.


        **Carry `refreshModels: true` in the same body to claim the supplier
        slot and read its catalogue in one write**, so long as the row that
        update produces remains enabled. The flag outranks every other field the
        body carries but never the switch, so `{"slots": ["model-supplier"],
        "refreshModels": true}` does call the endpoint — the shortest correct
        road once the far side already holds the secret — while that same body
        carrying `"enabled": false` calls nothing at all and stores the claim
        unserved. Where the read does happen it is all or
        nothing: if it fails, the whole update answers 422 and **nothing is
        stored**, so the slot is never left claimed behind a read that did not
        happen.


        **When the row this update produces is switched off, nothing is asked
        of that address, however the body is written.** `{"enabled": false,
        "refreshModels": true}` and a disable that also moves the endpoint
        store the change and call nothing: the moment an owner most needs to
        disable an extension is the moment it has stopped answering, and a
        veto its subject can refuse by going quiet is not one. What such a
        body asked for is stored rather than performed — the new address and
        the newly claimed slot are on the row — and **re-enabling does not
        read the model list either**, because switching a row back on says
        nothing about what that address now serves. Send `refreshModels:
        true`, with the enable or after it, to read the list at the address
        the row now carries. The tool listing is the exception: an enable of a
        row holding `tool-server` reads it, as above.


        On a row Lerian installed (`installedByOperator`), `enabled` is the
        only field this may touch, in both directions. Off is the owner's
        standing veto and on again is allowed because re-enabling
        Lerian's own endpoint discloses nothing that was not disclosed when
        Lerian installed it. Every other field is refused: an owner who could
        re-point that endpoint could aim an operator-marked, Lerian-audited
        registration at an address of their own.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExtensionUpdate'
      responses:
        '200':
          description: The registration as it now stands, without its secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Extension'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            The caller's role does not carry `extensions:manage`, or the change
            widens a grant the caller does not hold or moves the endpoint of a
            row granting one, or it touches a field other than `enabled` on an
            operator-installed row (NRY-0028).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            The change claims an exclusive slot somebody else holds (NRY-0006,
            naming both) with no `[extensions] prefer` entry naming which of
            the two occupies it, or it moves this registration onto a
            position another one already occupies on a decision point they
            share (NRY-0004, naming both and the point), or — on a change whose
            resulting row is enabled and that moves the endpoint, claims the
            `tool-server` slot the row did not already hold, or switches on a
            row already holding that slot, the three changes that read the tool
            listing — the extension publishes a tool under a name this host
            already serves (NRY-0004, naming the tool). A refresh reads the
            model list, not the tools, and a disable reads nothing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/re-point-re-order-re-grant-or-disable-a-registered-extension
    delete:
      operationId: revokeExtension
      tags: [extensions]
      summary: Revoke a registered extension
      description: >-
        Takes the registration away with everything on it, including the models
        a picker was offering. Requires `extensions:manage`. It takes effect on
        the next transformation rather than at the next restart, so an owner can
        stop a misbehaving extension while a turn is waiting on it. The audit
        rows stay: a registry that cannot say who installed what is a registry
        nobody can be accountable for.


        **A row Lerian installed cannot be revoked here.** The owner's power
        over it is the standing veto — `enabled: false` through the update
        operation — and that split is what keeps an operator install an
        exception rather than a door.
      responses:
        '204':
          description: The registration is gone.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            The caller's role does not carry `extensions:manage`, or the row is
            operator-installed and may only be disabled (NRY-0028).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/revoke-a-registered-extension
  /v1/extensions/{publisher}/{name}/secret:
    parameters:
      - $ref: '#/components/parameters/ExtensionPublisherParam'
      - $ref: '#/components/parameters/ExtensionNameParam'
    post:
      operationId: rotateExtensionSecret
      tags: [extensions]
      summary: Rotate a registered extension's secret
      description: >-
        Mints a new secret and returns it once. The response names when the old one stops being accepted, which is also when an event stream it signed ends. Requires `extensions:manage` and every permission granted on the registration, because whoever holds the secret reaches what it is granted.


        **A row Lerian installed cannot be rotated here**, for the reason it cannot be revoked: the secret is what Lerian authenticates with, and the owner's power over such a row is the standing veto.
      responses:
        '200':
          description: >-
            The new secret, and when the superseded one stops being accepted.
            The secret appears here and in the registration's response and
            nowhere else, ever.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExtensionSecret'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            The caller's role does not carry `extensions:manage` or a permission
            granted on the registration, or the row is operator-installed and
            may only be disabled (NRY-0028).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/rotate-a-registered-extension-s-secret
  /v1/extensions/{name}/invoke:
    parameters:
      - name: name
        in: path
        required: true
        description: The extension's namespaced name, publisher/extension form.
        schema:
          type: string
          pattern: '^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$'
          examples: [lerian/ring-review]
    post:
      operationId: invokeExtension
      tags: [extensions]
      summary: Invoke an operation a registered extension exposes
      description: >-
        The generic request lane for extension-defined behaviour, and the near half of the registered extension's own `invoke` operation: a client reaches an extension THROUGH this host, needing nothing but the extension's name and never holding its secret. The host signs the forwarded call under the registration's secret, judges the address on the way out and refuses a redirect; the payload is the extension's own contract and the host only routes it.


        **The address is configuration, not a credential.** `listExtensions` shows `endpoint` to every member of the organisation on purpose — a registry that hid where an extension points would answer "what is reading our conversations" with less than the truth — so what this lane withholds is the secret and the signature, not the URL. Knowing the address buys nothing without the secret: every call in the extension contract is signed, and an extension is required to refuse anything that does not verify.


        **An ordinary member's token is enough.** Invoking is using, and only registering is owning — `extensions:manage` gates the writes that create, re-point, revoke and re-order a registration.


        The forward runs under the same deadline a transformation gets.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ExtensionInvokeRequest'
      responses:
        '200':
          description: The operation's result, as the extension defined it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ExtensionInvokeResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: >-
            No enabled extension of that name, or no such operation on it
            (NRY-0013). The three causes are answered identically so a caller
            cannot enumerate this home's registry by watching which refusals
            differ.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/invoke-an-operation-a-registered-extension-exposes
  /v1/workflows/runs:
    get:
      operationId: listWorkflowRuns
      tags: [workflows]
      summary: List workflow runs
      description: >-
        Cursor-paginated list of workflow runs, filterable by status.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: status
          in: query
          required: false
          description: Filter by run status.
          schema:
            type: string
            enum: [pending, running, completed, failed, stopped]
      responses:
        '200':
          description: One page of workflow runs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowRunPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-workflow-runs
    post:
      operationId: startWorkflowRun
      tags: [workflows]
      summary: Start a workflow run
      description: >-
        Starts a deterministic orchestration of multiple agents from a script
        — by path or inline source — with arguments. The script runs in the
        host's confined runtime: no filesystem, no network, no module
        loading; only the agent API the host provides. Script validation
        failures return 422 NRY-0010 before anything executes. Progress
        streams as workflow-progress events.

        A hosted home runs every session inside a container. A run there
        that names no `environment` opens its session as createSession does:
        with a container of its own made at its first turn, or, naming a
        repository in `clone`, made with that clone before the run starts.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WorkflowRunCreate'
      responses:
        '202':
          description: Run accepted and starting; watch events or poll the run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/start-a-workflow-run
  /v1/workflows/runs/{runId}:
    parameters:
      - $ref: '#/components/parameters/RunIdParam'
    get:
      operationId: getWorkflowRun
      tags: [workflows]
      summary: Get a workflow run
      description: >-
        Returns a run's status with per-step progress and partial results. A
        failed step never silently disappears from the result.
      responses:
        '200':
          description: The run with per-step progress.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/get-a-workflow-run
  /v1/workflows/runs/{runId}/stop:
    parameters:
      - $ref: '#/components/parameters/RunIdParam'
    post:
      operationId: stopWorkflowRun
      tags: [workflows]
      summary: Stop a workflow run
      description: >-
        Stops a run mid-flight. The response reports what completed before
        the stop: finished steps keep their results, running steps are marked
        stopped, unstarted steps are marked skipped. Stopping an
        already-finished run is a conflict.
      responses:
        '200':
          description: The stopped run, reporting what completed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/stop-a-workflow-run
  /v1/workflows/runs/{runId}/resume:
    parameters:
      - $ref: '#/components/parameters/RunIdParam'
    post:
      operationId: resumeWorkflowRun
      tags: [workflows]
      summary: Resume a workflow run
      description: >-
        Resumes a run that failed or was stopped, carrying on from the step it
        died on. The script is re-executed from the top — a program has no
        continuation to restore across a process death — but every step the
        run already settled answers from the record instead of running again,
        so a run that died at step 14 of 21 pays only for the steps that are
        left and writes the same step structure an uninterrupted run would
        have written. Completed and failed steps both answer from the record;
        a step that was merely interrupted holds no settled answer and runs.
        The source re-executed is the source stored when the run started,
        never a file on disk, so editing the script between attempts cannot
        change the structure. Only failed and stopped runs are resumable:
        resuming a pending, running or completed run is a conflict.
      responses:
        '202':
          description: >-
            Run accepted and resuming. It reads pending, as a started run
            does, until its runner picks it up; watch events or poll the run.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WorkflowRun'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/resume-a-workflow-run
  /v1/schedules:
    get:
      operationId: listSchedules
      tags: [schedules]
      summary: List the schedules this host's clock owes
      description: >-
        Cursor-paginated list of every schedule on this host, newest first,
        cancelled and completed ones included. What a schedule is owed and
        when is on the row: nextRunAt is the instant the clock is working
        towards, and lastSkippedAt with lastSkipReason is why a due fire was
        turned down.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of schedules.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SchedulePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-the-schedules-this-host-s-clock-owes
    post:
      operationId: createSchedule
      tags: [schedules]
      summary: Ask the host's clock to start work nobody will be present for
      description: >-
        Records a schedule: a repository, a prompt, a rule saying when it
        fires, and what one fire may spend. The host's own clock fires it
        into a new session that runs unattended — every fire opens its own
        session and nothing is ever appended to an earlier one.

        Everything this host could not honour is refused HERE, because
        creation is the only moment somebody is present to answer. A rule
        under the five-minute floor, an instant already behind the clock, and
        a repository nobody has vouched for are all 422: a schedule accepted
        into an untrusted directory would wake at three in the morning, be
        refused at everything it tried, and leave an empty morning behind.

        A hosted home runs every session inside a container. A schedule there
        that names no `environment` fires each time into a container of the
        fired session's own, made at its first turn as a conversation's is,
        holding a fresh clone of the repository it names in `clone`; a clone
        or setup that fails ends that turn, and the session's next turn
        clones again. A named environment is read again
        at every fire: one destroyed since, or no longer the owner's, makes
        that fire open no session, and the reason is written on the
        schedule's `lastError`.

        The clock itself is not on this surface. Due-ness, the sweep's
        cadence and how a ceiling is spent are the host's own facts; a client
        asks for a schedule and reads what the host decided.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScheduleCreate'
      responses:
        '201':
          description: The schedule as stored, with the first instant it is owed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Schedule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/ask-the-host-s-clock-to-start-work-nobody-will-be-present-for
  /v1/schedules/{scheduleId}:
    parameters:
      - $ref: '#/components/parameters/ScheduleIdParam'
    get:
      operationId: getSchedule
      tags: [schedules]
      summary: Get a schedule
      description: >-
        Returns one schedule with its rule, its ceiling and the history of
        what its fires did: the session the last one created, when a fire was
        refused and why, and how a fire that never reached a session was
        interrupted.
      responses:
        '200':
          description: The schedule.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Schedule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/get-a-schedule
    delete:
      operationId: cancelSchedule
      tags: [schedules]
      summary: Cancel a schedule
      description: >-
        Stops the clock firing this schedule. This is a cancel, not a
        destroy: the row stays readable here and in the listing, carrying
        every fire it made and every refusal it recorded, because the
        sessions it created are read backwards from it.

        It is ONE WAY, and nothing here undoes it — a schedule that should
        run again is created again, with a new id. Cancelling a schedule that
        is already cancelled is not an error: the state asked for holds. A
        fire already in flight is not recalled; what stops is every fire
        after it.
      responses:
        '200':
          description: The schedule, cancelled and still readable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Schedule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/cancel-a-schedule
  /v1/schedules/{scheduleId}/budget:
    parameters:
      - $ref: '#/components/parameters/ScheduleIdParam'
    put:
      operationId: setScheduleBudget
      tags: [schedules]
      summary: Say what one fire of this schedule may spend
      description: >-
        Records the dollars ONE fire of this schedule may spend. It is a hard
        stop rather than a price: the turn that fire opens carries this
        number, and it ends on the first supplier round that reaches it,
        keeping whatever text arrived and running none of the tool calls that
        round was still asking for. The honest limit is one supplier round of
        overshoot, plus up to two dropped rounds the turn continued past, whose
        price is never known; a round's price is known only once it has ended,
        and it bounds only what narya itself meters — a tool that reaches a
        paid service of its own bills somewhere this host never looks.

        Zero is zero and never "unlimited": a schedule with nothing to spend
        does not fire, and records that on its own row rather than going
        quiet. The number takes effect on the NEXT fire; one already running
        keeps the ceiling it started under. It is measured in the price of the
        model the fire's turn runs on, the one its agent names if it names
        one; on a model this host has no price for, the fire runs with no
        ceiling and the schedule's notice says so.

        A second ceiling bounds what one person's whole UTC day may cost
        across all the paid work narya runs for them, schedules included. That
        one is operator configuration rather than part of any schedule, and
        unset by default, which is no daily cap; either being reached stops
        the fire instead of warning about it. While it is set, a fire on a
        model this host has no price for is refused, because none of its
        rounds could be measured against it.

        An absent or null `ceilingUsd` clears the ceiling: the schedule's fires
        run with none, as one created without a number does. Setting what a
        schedule already holds is not an error.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScheduleBudget'
      responses:
        '200':
          description: The schedule, under the ceiling it now holds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Schedule'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/say-what-one-fire-of-this-schedule-may-spend
  /v1/refinements:
    get:
      operationId: listRefinements
      tags: [refinements]
      summary: List what narya has been taught
      description: >-
        Cursor-paginated list of this owner's refinements, newest first, paged on (createdAt, id) — two facts consented in the same second would otherwise repeat one and lose the other across a page boundary. Rolled-back rows are in it: walking backwards from a strange behaviour to the fact that caused it is what the row is for.


        Each row carries its provenance, its state, how often it has fired and — for a capability that executes — its script's HASH. Not the script's text, which is on getRefinement: a listing that carried every authored program's source is a listing nobody can page.
      parameters:
        - name: scope
          in: query
          required: false
          description: Only refinements kept at this reach.
          schema:
            $ref: '#/components/schemas/RefinementScope'
        - name: kind
          in: query
          required: false
          description: Only refinements of this kind.
          schema:
            $ref: '#/components/schemas/RefinementKind'
        - name: state
          in: query
          required: false
          description: Only refinements in this state.
          schema:
            $ref: '#/components/schemas/RefinementState'
        - name: repository
          in: query
          required: false
          description: >-
            Only refinements kept for this checkout. It narrows repository
            scope and never widens it: the owner's global facts are not in a
            repository and are therefore not in this answer. On a hosted home
            it resolves under the home's workspace, and a path outside it is
            refused 422.
          schema:
            type: string
            maxLength: 4096
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of refinements.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefinementPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/list-what-narya-has-been-taught
    post:
      operationId: proposeRefinement
      tags: [refinements]
      summary: Teach narya something, and ask a person to allow it
      description: >-
        Proposes ONE refinement. It creates a pending item — a one-item round
        on the consent surface, provenance `api` and the calling actor beside
        it — and it activates nothing at all. The prompt this host assembles
        is byte-identical before and after this call, and that rule is pinned
        by a test rather than stated here.


        SEEDING IS NEVER ACTIVATING, and this operation is the one that must
        never become a back door: what a client proposes passes exactly the
        human consent anything the loop proposes passes. A
        person answers it in a client through
        `POST /v1/refinements/rounds/{roundId}/answer`, and the round the
        answer names is the one this call returns.


        At least one citation is required. An edit citing no turn never
        becomes a row — evidence is a structural control and not a request
        for good behaviour.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefinementProposal'
      responses:
        '202':
          description: >-
            Parked, waiting on a person. The round id is what an answer is
            posted against; nothing is in the ledger yet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefinementRound'
        '400':
          $ref: '#/components/responses/BadRequest'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/teach-narya-something-and-ask-a-person-to-allow-it
  /v1/refinements/{refinementId}:
    parameters:
      - $ref: '#/components/parameters/RefinementIdParam'
    get:
      operationId: getRefinement
      tags: [refinements]
      summary: Read one refinement whole
      description: >-
        The whole row: its text, its citations, the authored script IN FULL
        where there is one, and the append-only log of everything that has
        happened to it. The log is what answers "who taught narya this and
        who took it away" — nothing in it is ever rewritten.
      responses:
        '200':
          description: The refinement, with its script and its event log.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Refinement'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-one-refinement-whole
  /v1/refinements/{refinementId}/rollback:
    parameters:
      - $ref: '#/components/parameters/RefinementIdParam'
    post:
      operationId: rollbackRefinement
      tags: [refinements]
      summary: Undo what narya learned
      description: >-
        Undoes a refinement and records that it did. THIS IS ALSO THE DELETE:
        "archive, never destroy" holds here, so the row stops firing, keeps
        its provenance and its evidence, and stays in the listing — walking
        backwards from a strange behaviour to the fact that caused it is
        exactly what the row is for.


        With no `toEventId` the whole history is inverted, back to the
        creation, and the row lands in `rolled-back`. Naming an event undoes
        down to and including that event, which is also how a rollback is
        itself undone: undo and redo are one verb aimed at different events.


        It answers 200 with the row as the rollback left it rather than 204,
        so `narya learned rollback --json` has something to print.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefinementRollback'
      responses:
        '200':
          description: The refinement, as the rollback left it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Refinement'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/undo-what-narya-learned
  /v1/refinements/{refinementId}/promote:
    parameters:
      - $ref: '#/components/parameters/RefinementIdParam'
    post:
      operationId: promoteRefinement
      tags: [refinements]
      summary: Ask to widen what a fact applies to
      description: >-
        Asks a person to widen one repository-scoped fact to everywhere this
        owner works, and WRITES NOTHING. A fact was allowed in one checkout;
        "everywhere I work" is a different claim, so it is put to a person
        again with the fact's own text, its provenance, its citations and —
        for a capability that executes — its program and the hash it was
        allowed under.


        It answers 202 with the round that is now standing, exactly as
        `proposeRefinement` does, and the widening happens when somebody
        answers that round through `answerRefinementRound`. There is no
        destination to name: the ledger keeps two scopes, so widening has one
        direction.


        Its sibling `demoteRefinement` is the other direction and does not
        ask, because a restriction composes where a permission never does.
      responses:
        '202':
          description: The round now standing for a person to answer.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefinementRound'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/ask-to-widen-what-a-fact-applies-to
  /v1/refinements/{refinementId}/demote:
    parameters:
      - $ref: '#/components/parameters/RefinementIdParam'
    post:
      operationId: demoteRefinement
      tags: [refinements]
      summary: Narrow a fact to one checkout
      description: >-
        Narrows one of this owner's global facts to the single repository it
        should keep applying in, and does it ON THE SPOT: nothing is asked,
        because narrowing what narya may do is never the dangerous direction
        and making cleanup expensive is how a ledger nobody prunes gets built.
        The log is the undo.


        IT IS ALSO HOW A WIDENING IS UNDONE, in one gesture rather than two:
        the general row narrows back, the row it superseded stays exactly
        where it is, and the ledger keeps saying where the fact was first
        learned. Rolling the row back instead takes the fact away entirely,
        which is the delete.


        It answers 200 with the row as the narrowing left it rather than 204,
        so `narya learned demote --json` has something to print.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefinementDemotion'
      responses:
        '200':
          description: The refinement, as the narrowing left it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Refinement'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/narrow-a-fact-to-one-checkout
  /v1/refinements/rounds:
    get:
      operationId: listRefinementRounds
      tags: [refinements]
      summary: List the rounds waiting on a person
      description: >-
        The rounds standing on the consent surface, newest first. It is the
        recovery road for a client that was not attached when
        `refinement-proposed` went out — a round proposed by last night's
        scheduled fire is answered the next morning, and an
        announcement nobody heard must not be the only way to find it.


        A round is NEVER expired by the boot-recovery pass, unlike a
        permission or a question: nothing is blocked behind it, so a host
        restarting leaves it exactly where it was.
      parameters:
        - name: status
          in: query
          required: false
          description: >-
            Which rounds to list. `pending` is the default and the one a
            client draws an affordance from; `answered` is the audit of rounds
            already decided.
          schema:
            type: string
            enum: [pending, answered]
            default: pending
            x-enum-varnames:
              - RefinementRoundStatusPending
              - RefinementRoundStatusAnswered
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of rounds.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefinementRoundPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-the-rounds-waiting-on-a-person
  /v1/refinements/rounds/{roundId}:
    parameters:
      - $ref: '#/components/parameters/RoundIdParam'
    get:
      operationId: getRefinementRound
      tags: [refinements]
      summary: Read one round whole, to answer it
      description: >-
        Every item of one round, at the indices an answer names them by: the
        kind, the reach it asks for, the title, the body IN FULL, the turns it
        stands on, the definition it would replace, and — where narya authored
        a program — that program IN FULL.


        NOTHING HERE IS ELIDED, and that is the operation's whole reason.
        An authored script has to be shown to be READ rather
        than only named: a person cannot consent to what they were not shown,
        and what they consent to is what the confined runtime later runs, byte
        for byte. A client that drew a digest, a length or a "show more" would
        be offering approval over text nobody read.


        An item's `kind`, `scope` and `action` are the MODEL's own words and
        are deliberately not the ledger's enums: the review keeps them
        untyped, and they become typed at the moment a person's answer turns a
        proposal into a row. A client displays them and never resolves them.
      responses:
        '200':
          description: The round, with every body and every script in full.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefinementRoundDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-one-round-whole-to-answer-it
  /v1/refinements/rounds/{roundId}/answer:
    parameters:
      - $ref: '#/components/parameters/RoundIdParam'
    post:
      operationId: answerRefinementRound
      tags: [refinements]
      summary: Answer one round of proposed refinements
      description: >-
        Records what a person decided about one round and writes what they allowed. The consent unit is the ROUND — the whole proposed diff, with per-item marks inside it — because one ask per item at volume is reflex approval and makes the gate decorative.


        The marks name items by INDEX, into the item list exactly as the round sent it. The engine ordered those items, so a client that reordered them would post somebody's approval onto a different edit with a straight face — `answerQuestion`'s own indexing rule, for the same reason. `accept-round` carries no marks at all: saying "all of it" must not require enumerating the round back.


        A rejection writes no refinement, and the message a person types is not decoration: it reaches the next review as a correction, so the same fact is not proposed again tomorrow night.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RefinementRoundAnswer'
      responses:
        '200':
          description: >-
            Answered, with what the answer left behind: the rows written, and
            every accepted mark this host would not write, by name.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RefinementRoundOutcome'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/answer-one-round-of-proposed-refinements
  /v1/providers:
    get:
      operationId: listProviders
      tags: [providers]
      summary: List model providers
      description: >-
        Lists registered model providers with their authentication state and
        the models each serves. Registering a new provider is configuration,
        not API surface, in v1.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: sessionId
          in: query
          required: false
          description: >-
            List what this conversation may run. A private one, or one this
            host cannot read, leaves out a registered supplier whose
            registration lacks sessions:read-private, since its rounds would
            be refused.
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: One page of providers.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-model-providers
  /v1/providers/{name}/auth:
    parameters:
      - $ref: '#/components/parameters/ProviderNameParam'
    post:
      operationId: startProviderAuth
      tags: [providers]
      summary: Start provider sign-in
      description: >-
        Starts the provider's sign-in flow. For browser-based providers the
        response carries a URL for the client to open; completion is
        reported on the returned auth session status and reflected in the
        provider's authState. Credentials are stored so another user on the
        machine cannot read them. Expired credentials prompt
        re-authorisation via this same operation — they never silently fail.


        The body chooses between the two methods. `key` carries a key the
        person typed and answers once the host has probed it against the
        provider's own API — so this call is as slow as that probe, and a
        rejected key is never stored. `subscription` runs the provider's
        OAuth flow inside the host and answers as soon as there is a URL to
        open, leaving the flow running; the client polls getProviderAuth
        for its outcome. A sign-in already in flight for this provider is
        never started a second time: a `subscription` request is answered
        with the attempt as it stands, and a `key` request is refused with
        409 — its outcome would otherwise overwrite the running flow's
        record. A hosted home refuses `subscription` with 422: its flow
        opens a browser on the home's own machine, so a hosted home signs a
        provider in with `key`.


        A sign-in that FAILED is a 202 whose status is `failed`, not an
        error response: the request was served, the sign-in was not.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ProviderAuthStart'
      responses:
        '202':
          description: >-
            Sign-in started, finished, or failed. When browserUrl is present,
            open it to complete the flow.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderAuthSession'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/start-provider-sign-in
    get:
      operationId: getProviderAuth
      tags: [providers]
      summary: Read the provider's sign-in in flight
      description: >-
        The state of the sign-in startProviderAuth last began for this
        provider: still pending with the URL to open, complete, or failed
        with the reason. This is how a client learns the outcome of a
        browser or device-code flow the host is driving on its behalf — the
        provider's own authState says whether a credential exists, this says
        what happened to the last attempt to get one. 404 when this host has
        no attempt on record for the provider.
      responses:
        '200':
          description: The last sign-in attempt for this provider.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProviderAuthSession'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'
      x-mint:
        href: /pt/reference/platform/narya/read-the-provider-s-sign-in-in-flight
    delete:
      operationId: signOutProvider
      tags: [providers]
      summary: Sign out of a provider
      description: >-
        Removes the provider's stored credential from this machine's
        keychain. Idempotent: a provider that holds no credential answers
        204 too. An environment variable naming a key is never touched —
        nothing this operation can do removes it — so a provider covered by
        one goes on reading as authenticated afterwards.
      responses:
        '204':
          description: The credential is gone.
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/sign-out-of-a-provider
  /v1/models:
    get:
      operationId: listModels
      tags: [providers]
      summary: List models across providers
      description: >-
        Flat, cursor-paginated list of every model available across all
        providers, with capability and cost metadata — the catalogue a client
        offers when switching model mid-session.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of models.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-models-across-providers
  /v1/models/recent:
    get:
      operationId: listRecentModels
      tags: [providers]
      summary: List recently chosen models
      description: >-
        The models the caller chose in a client, most recent first, bounded — the
        shortlist a model picker offers above the full catalogue so somebody
        alternating between two models never scrolls for either. The head of
        this list is also the default a session the caller opens with no
        explicit model adopts. A model no longer in the catalogue is absent here rather than
        listed as unusable.
      responses:
        '200':
          description: The recent models, most recent first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ModelPage'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-recently-chosen-models
  /v1/records/search:
    get:
      operationId: searchRecords
      tags: [records]
      summary: Search the sessions you can read
      description: >-
        Full-text search over three session kinds: the caller's own, the
        ones marked for the organisation, and — when the caller holds the
        read-private permission — every session this host has. A share
        adds none: a colleague named on a share of a private session may
        read that session's record and only the entries the share has
        delivered, up to its bound, while a hit here is an un-redacted
        excerpt from any matching entry and cannot honour that bound, so
        the session is left out. Results carry the session, an entry
        excerpt with the match, and its timestamp. A session outside the
        three kinds is absent rather than refused — it is not counted, and
        the page cursor does not step over it.
      parameters:
        - name: q
          in: query
          required: true
          description: The search query.
          schema:
            type: string
            minLength: 1
            maxLength: 1024
        - name: repository
          in: query
          required: false
          description: >-
            Only matches from sessions bound to this repository path; on a
            hosted home, relative to its workspace, as each session
            names it. It is the same column `listSessions` filters on and it
            carries no authority of its own: it narrows what the caller may already read
            and can never widen it. A repository nobody worked in is an empty
            page rather than an error, and so is one whose conversations this
            caller may not read.
          schema:
            type: string
            maxLength: 4096
        - name: since
          in: query
          required: false
          description: >-
            Only matches in transcript entries written at or after this
            instant. It is the half of "that error, some time in August" that
            paging cannot answer: without it a reader pages back from now
            until the dates fall out of the window they meant. Served off the
            same index the page cursor already walks.
          schema:
            type: string
            format: date-time
        - name: until
          in: query
          required: false
          description: >-
            Only matches in transcript entries written at or before this
            instant. Combines with `since` to bound a window from both ends;
            either may be given alone. A window whose `until` precedes its
            `since` is a malformed request naming both parameters rather than
            an empty page, because an empty page reads as "nobody ever said
            it".
          schema:
            type: string
            format: date-time
        - name: kind
          in: query
          required: false
          description: >-
            Restrict the search to these parts of a conversation — repeat the
            parameter to name several (`kind=user-message&kind=summary`).
            Omitted, every kind answers, which is what this operation has
            always done. It is how a person searches what they SAID without
            wading through everything the agent read. A value outside the
            vocabulary is a malformed request naming the parameter, and so is
            naming the parameter with nothing in it: asking for no kinds at
            all is a mistake rather than a query. Read
            `FindableEntryKind` before relying on a narrowing — two of its
            values are narrower than their names suggest.
          schema:
            type: array
            items:
              $ref: '#/components/schemas/FindableEntryKind'
        - name: mine
          in: query
          required: false
          description: >-
            Restrict the answer to the conversations the caller owns. It names nobody and takes no name: the subject comes from the verified token and from nowhere else, which is what keeps it from being the owner parameter this contract deliberately does not have — an owner a caller can name is an owner anybody can be. It is not a permission and can only ever narrow. Combines with every other filter here.
          schema:
            type: boolean
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of matches.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecordSearchPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/search-all-past-sessions
  /v1/records/files:
    get:
      operationId: listRecordFiles
      tags: [records]
      summary: List sessions that touched a file
      description: >-
        Answers "which sessions touched this file", over the same three
        session kinds searchRecords covers: the caller's own, the ones
        marked for the organisation, and every session when the caller
        holds the read-private permission. A share adds none here either:
        what a share delivers is transcript entries, and a file listing
        reports the file changes, which a share does not carry at all. An
        unknown path, and a path touched only by sessions outside those
        three kinds, both return an empty page rather than an error.
      parameters:
        - name: path
          in: query
          required: true
          description: The file path to look up.
          schema:
            type: string
            maxLength: 4096
        - name: mine
          in: query
          required: false
          description: >-
            Restrict the answer to the conversations the caller owns. It names nobody and takes no name: the subject comes from the verified token and from nowhere else, which is what keeps it from being the owner parameter this contract deliberately does not have — an owner a caller can name is an owner anybody can be. It is not a permission and can only ever narrow. Combines with every other filter here.
          schema:
            type: boolean
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of sessions that touched the file.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecordFilePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-sessions-that-touched-a-file
  /v1/records/usage:
    get:
      operationId: getRecordUsage
      tags: [records]
      summary: Report consumption over a period
      description: >-
        Token and cost consumption between two instants, over the whole home, grouped by the dimension you ask for. Every member of the organisation reads the same total, including the rounds of conversations their owners marked private: what the organisation spends on its own tools is not the content of a conversation, so no clause narrows these rows the way the search and file reads on this tag are narrowed. The parts add up to the whole.

        Marking a conversation private hides its words, not its money. Its rounds are in this report, on the line of whoever acted in them, for every member. A person choosing that mark is entitled to know exactly what it does and does not cover.

        A person is named by the opaque subject the identity provider issued, never an email address and never a display name. A person's line is what they acted in — the turns they started, the summaries and compactions they asked for, the schedules they own — whoever's conversation it ran in.

        Grouping by repository is the one dimension whose key could be content: a repository name belongs to the conversation that ran in it. So every conversation the caller may not read lands in ONE group keyed `private`, which is a real line with real money in it and no name. Nothing is dropped, so the parts still sum to the whole. Ids are not content, so the conversation and person dimensions carry their real keys.

        It reports the usage rows this home still holds. Those rows ride their conversation, so purging a conversation takes its cost out of every total computed afterwards. Warming charges and schedule-fire amounts are separate ledgers and are not in this figure.

        PAGING IS OPT-IN ON THIS OPERATION, which is the one place the shared `limit` parameter's own default of 25 does not apply. Send neither `cursor` nor `limit` and the answer carries every group and no `nextCursor` — the whole period, as this report has always answered. Name either one and paging applies, with 25 as the default size. The exception exists because a total silently cut at a page is a bill that reads as complete and is not.
      parameters:
        - name: from
          in: query
          required: true
          description: Start of the period, inclusive.
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          required: true
          description: End of the period, exclusive.
          schema:
            type: string
            format: date-time
        - name: groupBy
          in: query
          required: false
          description: >-
            Grouping dimension. model is the default and the answer an older client that sends none keeps getting. day is the UTC day the round was stamped with, which is the store's own clock and not the caller's. repository is read off the conversation each round ran in, and person is who acted in it. A dimension this version does not serve is refused naming itself.
          schema:
            type: string
            enum:
              - model
              - day
              - repository
              - conversation
              - person
            default: model
            x-enum-varnames:
              - GetRecordUsageParamsGroupByModel
              - GetRecordUsageParamsGroupByDay
              - GetRecordUsageParamsGroupByRepository
              - GetRecordUsageParamsGroupByConversation
              - GetRecordUsageParamsGroupByPerson
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: The usage report.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageReport'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/report-consumption-over-a-period
  /v1/platform/accesses:
    get:
      operationId: listPlatformAccesses
      tags: [platform]
      summary: List the calls Lerian's platform made to this home
      description: >-
        The calls Lerian's control plane made to this home on its own credential, as this home recorded them, newest first. Each row names the operation, when it ran, who the plane said it was, and how it ended. A row carries no session, repository, title or argument.


        Any member of this home may read this list, and no permission is needed. Lerian's platform credential cannot read it.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of platform accesses, newest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlatformAccessPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: >-
            Lerian's platform credential asked for this listing, which it cannot read (NRY-0028).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/every-time-lerian-s-platform-reached-this-home
  /v1/sessions/import:
    post:
      operationId: importSession
      tags: [records]
      summary: Import a transcript from another harness
      description: >-
        Reads a transcript written by another harness and creates the
        conversation, or the branched family, it describes. Exactly two
        foreign formats are accepted: Claude Code's JSONL transcripts and
        opencode's export JSON. The raw file is the request body.

        Nothing is imported silently. Every record the read did not carry is
        counted and named in a system entry at the head of the imported
        session — including sub-agent records, which are skipped as a group
        because a subagent's words are not the agent's.

        A file whose content is not the format named is refused (400) rather
        than parsed, and a file with an unreadable line is refused naming
        that line rather than importing half a conversation.

        A hosted home runs every session inside a container. An import there
        that names no `environment` opens its conversations as createSession
        does: each with a container of its own made at its first turn, or,
        naming a repository in `clone`, every conversation the file creates
        in one container holding that clone, named after the first.
      parameters:
        - name: format
          in: query
          required: true
          description: Which foreign transcript shape the body carries.
          schema:
            type: string
            enum: [claudecode, opencode]
        - name: repository
          in: query
          required: false
          description: >-
            Absolute path the imported session is bound to, required unless
            `clone` is given and refused (422) beside it. Never taken from the
            file: a foreign transcript's own working directory may not exist
            on this machine, and a session bound to a path that is not there
            has a broken diff view and broken tools from birth.
          schema:
            type: string
            maxLength: 4096
            x-go-type-skip-optional-pointer: true
        - name: clone
          in: query
          required: false
          description: >-
            A repository cloned into the imported conversations' own container
            before they exist, exactly as createSession's `clone`: on a hosted
            home, without `environment`, and their repository. The clone's own
            `.narya/setup.sh` prepares it.
          schema:
            type: string
            maxLength: 200
            pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$'
        - name: environment
          in: query
          required: false
          description: >-
            Bind the imported session, and every branch the file creates
            beside it, to this environment, which must already exist
            (createEnvironment). Absent means the host's own machine on a
            person's own home, and a container of each conversation's own on
            a hosted home (createSession).
          schema:
            $ref: '#/components/schemas/EnvironmentId'
        - name: clientKind
          in: query
          required: false
          description: >-
            The kind of client importing, stamped on every conversation the
            file creates as createSession's `clientKind` is: `api` when absent,
            so nobody attends them. `narya import` names `inline`, since a
            person continues what they imported.
          schema:
            $ref: '#/components/schemas/ClientKind'
        - name: X-Narya-Source
          in: header
          required: false
          description: >-
            What to call the file this transcript came from. The body is a
            stream with no name of its own, and the name is repeated in the
            system entry at the head of the imported session — six weeks
            later "imported from somewhere" answers a worse question than
            the filename does. Only the base name is kept, cut to 128 bytes.
            Absent, the report names the format and stops rather than
            inventing a path.
          schema:
            type: string
            maxLength: 128
      requestBody:
        required: true
        content:
          application/x-ndjson:
            schema:
              type: string
              description: A Claude Code transcript, one JSON object per line.
          application/json:
            schema:
              type: object
              additionalProperties: true
              description: >-
                An opencode export, one JSON object. Declared as the object it
                is rather than as a string: a generated client marshals a
                string body into a QUOTED JSON string, so every typed caller
                sent this host a JSON string where it reads an object, and the
                import refused a file that was never wrong. The shape inside is
                opencode's, not this host's, so nothing here constrains it.
      responses:
        '201':
          description: >-
            The first session of what was created. A forked transcript also
            created the branches hanging off it, reachable through the
            session tree.


            A file carrying SEVERAL conversations it does not join to each
            other creates one session per conversation, and nothing joins
            them because nothing in the file joined them — so getSessionTree
            on this session does not reach the others. That is an ordinary
            file: a concatenation of two transcripts, a hand-edited one, or
            one that begins mid-conversation because its opening lives in
            another file. The system entry at the head of every session
            created this way says how many there were and names the file they
            came from, which record search indexes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Session'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          description: >-
            The transcript is larger than this host will import, or the request
            body carrying it is past this operation's own ceiling (NRY-0024),
            which the host refuses at the door before any handler reads it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/import-a-transcript-from-another-harness
  /v1/sessions/{sessionId}/export:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: exportSession
      tags: [records]
      summary: Export a session transcript
      description: >-
        Human-readable export of one session's transcript, selected by
        content negotiation on the Accept header: text/markdown for reading,
        application/jsonl for one JSON entry per line greppable without
        Narya. This replaces the affordance of a file-based transcript
        format readable with less and grep. Works on archived sessions.

        Two independent content toggles select what the export carries, both
        off by default. Stripping is never silent: an omitted reasoning
        block leaves a line saying how many blocks were left out, and an
        omitted tool result leaves the tool it answered, whether it failed,
        and the byte count it would have carried — so a reader can tell "the
        agent ran nothing" from "you asked me not to show it".
      parameters:
        - name: includeReasoning
          in: query
          required: false
          description: >-
            Carry the model's own reasoning blocks. Off by default: an
            export is normally read by a person following the conversation.
          schema:
            type: boolean
            default: false
        - name: includeToolOutput
          in: query
          required: false
          description: >-
            Carry the machine channels: what each tool answered, and what
            each background watcher printed. Off by default, and independent
            of includeReasoning — an audit of what the agent DID wants this
            and not the reasoning, and a study of how it THOUGHT wants the
            opposite. Both channels are text produced outside narya rather
            than conversation, which is why one parameter governs both; a
            monitor's NOTICES (started, exited, stopped, throttled) are
            narya's own sentences about a process and are always carried.
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: The exported transcript in the negotiated format.
          content:
            text/markdown:
              schema:
                type: string
                description: The transcript rendered as Markdown.
            application/jsonl:
              schema:
                type: string
                description: The transcript as one JSON object per line.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/export-a-session-transcript
  /v1/sessions/{sessionId}/share:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    post:
      operationId: publishSession
      tags: [sharing]
      summary: Publish this session to the organisation's home
      description: >-
        Copies this session, already redacted, to the organisation's home, where the team reads it. The developer's home is where the work happens and the organisation's is where it is read, so this is a one-way copy addressed by a share id and never a synchronisation of two equal stores.

        Read `GET /v1/sessions/{sessionId}/share/preview` first and show a person the document: it is the last moment before the bytes leave their machine.

        THE MODE IS WHERE THE PUBLISH STOPS, and there are two. `once` copies the entries that exist and is finished. `sync` leaves the share subscribed, so entries said afterwards ride the same queue — one road with two stopping rules rather than a streaming mechanism beside a batch one.

        THE SHARE ID IS DRAWN AND CARRIES NOTHING. It is unrelated to the session id by construction, so a share link that leaks says nothing about the machine it came from.

        Publishing the same session twice creates a second share with its own id; a revoked share is never resumed, and re-sharing after a revocation is always a new one.

        AN UNREACHABLE ORGANISATION'S HOME IS 503 NRY-0029 AND IS NOT A FAILURE. The share is recorded and every entry is owed on disk before anything is sent, so an organisation's home that did not take it means the work is queued: a later drain carries it, complete and in order, and the message names the share. Nothing about local work is blocked by it.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SessionSharePublish'
      responses:
        '201':
          description: The share this publish created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionShare'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            This home has no control plane configured, so there is no
            organisation home to publish to (NRY-0004). Refused before
            anything is recorded, so publishing again once `plane_url` is set
            creates the one share asked for.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          description: >-
            The share was recorded and its entries are queued; the
            organisation's home has not taken them yet (NRY-0029). A statement about
            delivery rather than about the request — the message names the
            share, and a later drain carries the work.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

      x-mint:
        href: /pt/reference/platform/narya/publish-this-session-to-the-organisation-s-home
    get:
      operationId: listSessionShares
      tags: [sharing]
      summary: The live shares of this session, and who each one is for
      description: >-
        What this session has been published as, from the publishing end: one row per share that is still live, carrying who it is for.

        IT IS THE WAY IN TO CHANGING THAT AUDIENCE. A share is retargeted by its id (`PUT /v1/shares/{shareId}/recipients`) and a person has a conversation rather than a 26-character id, so this is where a client learns which shares exist and what each one currently names. A client holds no share id of its own between runs and derives nothing.

        REVOKED AND EXPIRED SHARES ARE NOT HERE. Their entries have been deleted and their audience can never change again, so a row for one would be an offer to retarget a conversation that no longer exists.

        WHO MAY READ IT is narrower than who may read the session: whoever opened the session, or a member carrying `members:manage`. A colleague who may read the conversation may not read the list of the colleagues it was addressed to — that list is a statement about people rather than about the work, which is the line the disclosure audit beside it already holds and the reason a recipient's own read of a copy never reports who else was named. A caller who may not is answered 403 NRY-0028.

        A SHARE THIS CALLER MAY NOT CHANGE IS NOT HERE EITHER, so no row is ever offered that `PUT /v1/shares/{shareId}/recipients` would refuse. The rows are the shares this caller may settle: their own, and every one of them for a member carrying `members:manage`. A session somebody ELSE published — which an unmarked session allows, since it is the organisation's already — therefore lists nothing for the person whose session it is, rather than a row naming the colleagues another member addressed their work to. An empty list is "nothing here for you to change" and not "this was never published".
      responses:
        '200':
          description: The live shares of this session, newest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionShareList'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/the-live-shares-of-this-session-and-who-each-one-is-for
    delete:
      operationId: revokeSession
      tags: [sharing]
      summary: Revoke every share of this session, deleting what was copied
      description: >-
        Stops sharing this session and DELETES what was copied to the
        organisation's home. Not a flag that hides it there, and not a client
        that stops drawing it: the copied entries are removed from the
        organisation's store.

        IT ANSWERS AFTER THE DELETION HAS HAPPENED, not after accepting a
        request to perform one. A revocation that is queued is a revocation
        that has not occurred yet, and the person asking for one is usually in
        a hurry. So 204 means the data is gone; 503 NRY-0029 means the
        organisation's home did not answer, this machine has stopped sending
        anything more for the share, and the copy is still there until the
        request is repeated.

        THE SHARE RECORD SURVIVES AS A TOMBSTONE, and only the record: it
        carries the disclosure history, which is the one thing that has to
        outlive the data it describes. It also makes the revocation win
        against work queued before it — entries offered for a tombstoned
        share are refused 410 NRY-0031 forever, so a developer who was
        offline for a week cannot replay a conversation into a share somebody
        revoked on Tuesday.

        EVERY SHARE OF THE SESSION STOPS. Publishing twice creates two shares,
        and a person revoking a conversation means the conversation.

        RE-SHARING AFTERWARDS IS A NEW SHARE ID. A revoked id is never reused,
        because an id that came back to life would make the audit ambiguous
        about which disclosure it is recording.

        Revoking twice is not an error, and neither is revoking a session
        nobody published: the state asked for is the state it is in. The
        instant recorded stays that of the first revocation.
      responses:
        '204':
          description: >-
            Every share of this session is revoked and what was copied for it
            has been deleted at the organisation's home.
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: >-
            This session has been published and this home has no control
            plane configured, so the organisation's home cannot be asked to
            delete it (NRY-0004). Refused before anything is revoked here.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          description: >-
            The organisation's home did not answer (NRY-0029), so its copy of
            this session has NOT been deleted. This machine has stopped
            sending anything further for the share; repeating the request once
            the home answers completes the deletion.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

      x-mint:
        href: /pt/reference/platform/narya/revoke-every-share-of-this-session-deleting-what-was-copied
  /v1/sessions/{sessionId}/share/preview:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: previewSessionShare
      tags: [sharing]
      summary: What publishing this session would send, and what it would hold back
      description: >-
        Exactly what would leave this machine if the session were published to the organisation's home, computed before anything leaves it. The entries carry the text as it would be sent — already redacted — and withheld names every place a rule held something back, so a person decides against the document rather than against a promise.

        A MATCHED RULE REDACTS IN PLACE AND NEVER DROPS. A withheld span is replaced by a marker naming the rule that took it, and the entry keeps its id, its kind and its place in the transcript — a reader can tell a withheld line from a line nobody wrote, which a gap in the sequence cannot say.

        The rules are the host's, configured under [sharing.redaction], and every one of them is on unless somebody turned it off. A client computes none of it and holds no rule of its own.
      responses:
        '200':
          description: What a publish of this session would send, and what it withheld.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SessionSharePreview'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/what-publishing-this-session-would-send-and-what-it-would-hold-back
  /v1/sessions/{sessionId}/share/disclosures:
    parameters:
      - $ref: '#/components/parameters/SessionIdParam'
    get:
      operationId: listSessionDisclosures
      tags: [sharing]
      summary: Who has opened this shared session, and when
      description: >-
        The disclosure audit: the recorded reads of this session by somebody other than its owner, newest first. It is what answers "who read this conversation" after the fact, and it is the one part of a share that outlives the data — revoking a share deletes what was copied and leaves this history standing, because an audit that went with the conversation could not say who had already read it.

        WHAT IS IN IT, AND WHAT DELIBERATELY IS NOT. A member's reads of their OWN sessions are not recorded: logging them would make this the largest thing in the store and would answer a question nobody asked. Reads of a session nobody published are not recorded either — there is no share for them to be recorded against. Nor are the reads that answer across conversations — a page of listSessions or listWorkflowRuns, getRecordUsage, listRecordFiles, a searchRecords excerpt: one search can touch every session here, and opening the conversation behind a row is the read that is recorded.

        The entry names what was reached in the words of the road that served it — the session itself, an entry page, one media key — so a picture fetched out of a transcript is its own line rather than being folded into the conversation it came from. Reading this audit is NOT itself recorded: this list is about who opened the conversation, and mixing reads of the list into it would answer a second question in the same column.

        WHO MAY READ IT is narrower than who may read the session: its owner, or a member carrying `members:manage`. A colleague who may read the conversation may not read the list of everyone who has — that list is a statement about people rather than about the work, and the permission that governs who belongs to the organisation is the one that governs it. The private mark is deliberately not consulted here, for the reason marking one is not: somebody auditing who read a departed colleague's private session must not have to be able to read the conversation first, which is exactly the case the permission exists for. A caller who may not audit is answered 403 for every session that exists and 404 for one that does not, which is the pair every read of a session already answers.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of accesses, newest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisclosurePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/who-has-opened-this-shared-session-and-when
  /v1/shares:
    get:
      operationId: listReceivedShares
      tags: [sharing]
      summary: List the shared sessions this home holds
      description: >-
        The RECIPIENT's end of publishing: the
        conversations colleagues have published to this home, newest first.

        IT IS ADDRESSED BY SHARE ID AND NOT BY SESSION ID, here and on every
        road of this group. A published copy is not a session at this home:
        the entries arrived redacted from somebody else's machine and no
        session of that id exists here, by design. The session id a row
        reports is provenance about the machine it came from, and asking this
        host about it answers nothing.

        WHO MAY SEE WHAT IS DECIDED HERE AND NOT BY A CLIENT. A share
        published to the whole organisation is readable by any member of it,
        which is every actor this home admits — one organisation is one home.
        A share naming colleagues is readable by exactly those colleagues and
        by whoever published it. This page is that rule asked once per row:
        the shares a caller may not read are simply absent, because a listing
        that refused would show a person nothing rather than their own.

        A page can therefore come back shorter than its limit, and the cursor
        still leads where it led: a client pages until nextCursor is absent.

        Shares whose expiry has passed and shares somebody revoked are not
        here. The conversation they carried has been deleted and the record
        survives only to say who had read it.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of the shares this home holds for this caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReceivedSharePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/list-the-shared-sessions-this-home-holds
  /v1/shares/{shareId}/entries:
    parameters:
      - $ref: '#/components/parameters/ShareIdParam'
    post:
      operationId: receiveShareEntries
      tags: [sharing]
      summary: Take a batch of a shared session's entries
      description: >-
        The INWARD half of publishing, served by the organisation's home and
        called by a developer's. It takes redacted entries for one share and
        is the only way anything of a shared session arrives here.

        IT IS THIS CONTRACT AND NOT A SECOND PROTOCOL. The organisation's home
        is a narya host, so a publish is an operation on the contract this
        product already serves, authenticated by the same bearer on the same
        terms as every other. A caller reaches it with the publishing person's
        own token: the owner recorded here is that verified actor and never a
        field in this body.

        IDEMPOTENT PER ENTRY ID. A sending home cannot tell a delivery whose
        answer was lost from one that never happened, so it re-offers
        everything it has not seen acknowledged; an entry id this share
        already holds is taken as already held rather than written twice. A
        retry after a partial failure therefore completes rather than
        duplicating, and it may safely re-send the whole batch.

        THE FIRST DELIVERY IS WHAT ANNOUNCES THE SHARE. There is no earlier
        registration and deliberately no second operation for one, which is
        why the body carries the session the entries came from and the mode
        the publish is in alongside them.

        A REVOKED SHARE IS 410 NRY-0031, TERMINALLY. Its entries were deleted
        at somebody's request and the row survives only as a tombstone, so
        work queued before the revocation and offered after it is refused
        rather than accepted — a delivery that succeeded here would undo a
        deletion. The refusal is answered identically forever and a caller
        drops the work rather than retrying it.

        The redaction happened at the home the entries came from, which is the
        only place a *before* exists. Nothing here can widen what was sent,
        and nothing here is asked to.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareDelivery'
      responses:
        '204':
          description: >-
            Every entry in the batch is now held for this share — including
            any this home already had, which is what makes a retry safe.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          description: >-
            The share has been revoked at this home (NRY-0031). Terminal: the
            refusal never changes, and the caller drops the work rather than
            retrying it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/take-a-batch-of-a-shared-session-s-entries
    get:
      operationId: listShareEntries
      tags: [sharing]
      summary: Read a shared session's copied entries
      description: >-
        The conversation itself, as this home holds it: the redacted entries
        that crossed, in the order they were said.

        ALREADY REDACTED, AND THE ORIGINAL NEVER EXISTED HERE. Every rule ran
        at the home the entries were published from, which is the only place a
        *before* exists. Nothing at this end can widen what was
        sent, and nothing at this end is asked to.

        THE SAME AUDIENCE RULE THE LISTING APPLIES, and a share this caller
        may not read answers 404 NRY-0015 rather than 403 — the same answer a
        share id this home has never seen gets, so the two cannot be compared
        to learn that somebody's conversation is here. A revoked share and one
        whose expiry has passed answer identically, for the same reason.

        EVERY SERVED READ IS RECORDED, unless the reader is the person who
        published it. The disclosure entry is written before a byte is served
        and a read whose record cannot be written is not served at all, which
        is what makes "who opened this" a trustworthy answer rather than a
        best-effort one.

        Cursor pagination, oldest first, on the publishing home's own
        numbering — a conversation is read forwards.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of this share's conversation, oldest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ShareEntryPage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-a-shared-session-s-copied-entries
  /v1/shares/{shareId}:
    parameters:
      - $ref: '#/components/parameters/ShareIdParam'
    delete:
      operationId: revokeShare
      tags: [sharing]
      summary: Delete everything this home holds for one share
      description: >-
        The INWARD half of revoking, served by the organisation's home and called by a developer's, exactly as the entries operation beside it is — the organisation's home is a narya host, so a revocation is one more operation on this contract rather than a second protocol.

        IT DELETES AND THEN ANSWERS. The copied entries are gone from this home's store when this returns, which is what lets the developer's home answer its own caller truthfully rather than reporting a queued intention.

        THE RECORD SURVIVES AS A TOMBSTONE, carrying the disclosure history and the revocation, so any entry offered for this share afterwards is refused 410 NRY-0031 — terminally, however long afterwards.

        A SHARE THIS HOME HAS NEVER SEEN IS STILL TOMBSTONED. A home learns a share exists from its first delivery, so a person who publishes and immediately revokes can have that delivery in flight while this is being served; writing the tombstone anyway is what refuses it, rather than letting a deletion turn back into a live share.

        Idempotent: the second call changes nothing and answers the same way, and the instant recorded stays that of the first revocation.

        WHO MAY REVOKE A LIVE SHARE THIS HOME HOLDS: the person who published it, or a member carrying `members:manage`. Everybody else is answered 403 NRY-0028. A share already revoked answers 204 to anybody, because revoking it again changes nothing.
      responses:
        '204':
          description: >-
            Nothing of this share's conversation is held here any more, and
            the tombstone that refuses further entries is in place.
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/delete-everything-this-home-holds-for-one-share
    get:
      operationId: getReceivedShare
      tags: [sharing]
      summary: Read one shared session's record
      description: >-
        One share this home holds, as a recipient sees it: who published it,
        which session on which machine it came from, how far the copy reaches
        and when it stops.

        It is the same audience rule, the same 404 and the same disclosure
        record as the entries beside it. Reading the record of a published
        conversation is a read of that conversation's existence, and at this
        home its existence is the thing a share id would otherwise confirm.

        WHO ELSE A SHARE NAMES IS NOT REPORTED HERE. The list of recipients is
        a statement about people rather than about the work: whoever published
        it holds it at their own home, and who has actually opened the copy is
        the disclosure audit's answer.
      responses:
        '200':
          description: The share, as this home holds it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReceivedShare'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/read-one-shared-session-s-record
  /v1/shares/{shareId}/recipients:
    parameters:
      - $ref: '#/components/parameters/ShareIdParam'
    put:
      operationId: setShareRecipients
      tags: [sharing]
      summary: Settle who a published share is for
      description: >-
        Changes who may read a conversation that has already been published, without republishing it. The share keeps its id, its expiry and its position: this is the audience and nothing else.

        A REPLACEMENT, which is what makes adding and removing ONE act. The list sent is the list the share names afterwards, so a colleague added and a colleague dropped happen in one request and the two can never disagree about who is on it. Absent or empty hands the share back to the whole organisation, which is the ordinary publish.

        WHOEVER ENTERS RECEIVES THE WHOLE CONVERSATION, from its first entry: the organisation's home holds the copy as it has been delivered, so a colleague added today opens everything sent before today. WHOEVER LEAVES LOSES IT AT ONCE — the next read answers 404 exactly as it answers a share id nobody minted, and the live view stops being written to them, because both ask the audience of the share as it stands rather than as it stood when they connected.

        WHO MAY CHANGE IT: the person who published the share, or a member carrying `members:manage`. Everybody else is answered 403 NRY-0028, the holder of `sessions:read-private` included — being able to read a conversation is not being able to decide who else may.

        A REVOKED OR EXPIRED SHARE IS OVER AND IS NOT RETARGETED. Its entries have been deleted, so an audience written onto it would describe a conversation that no longer exists.

        IT TRAVELS TO THE ORGANISATION'S HOME AND ANSWERS AFTER IT HAS, for the reason a revocation does: the home that decides who opens the copy is the home that holds it, so a change recorded only here is a colleague still reading a conversation they were taken off. 503 NRY-0029 means the local record is settled and this machine has told nobody. Asking again re-attempts the organisation's home and is what completes it — the same request carrying the same list is sent on again rather than recognised as already done here, because what this home holds says nothing about what the other one holds. Served AT the organisation's home — which is where the copy is — it is the local write alone, and there is nowhere further for it to go.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareRecipients'
      responses:
        '204':
          description: >-
            The share is for exactly the colleagues named, at this home and at
            the organisation's.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'
        '503':
          description: >-
            The organisation's home did not answer (NRY-0029), so the copy it
            holds is still for whoever it was for. This home's record is
            settled; repeating the request once the home answers re-attempts
            it and completes it.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

      x-mint:
        href: /pt/reference/platform/narya/settle-who-a-published-share-is-for
  /v1/shares/{shareId}/audience:
    parameters:
      - $ref: '#/components/parameters/ShareIdParam'
    put:
      operationId: receiveShareAudience
      tags: [sharing]
      summary: Take who the copy of a share is for
      description: >-
        The INWARD half of settling a share's audience, served by the
        organisation's home and called by a developer's — the twin of the
        entries and revoke operations beside it, and for their reason: the
        organisation's home is a narya host, so this is one more operation on
        this contract rather than a second protocol.

        IT IS ITS OWN OPERATION AND NOT THE ONE A PERSON USES. A home serving
        this writes the list and stops, because it holds the copy and has no
        far end of its own to tell. One road serving both halves would make a
        home that is both ends of a publish have to recognise its own call,
        and by the time that call arrives the list is identical on both sides
        of it — so the only fact available to recognise it by is the one fact
        that says nothing.

        SETTLING THE SAME LIST TWICE IS NOT AN ERROR, and it is not skipped
        either: a home repeating this is a home whose first attempt it cannot
        know the fate of, and the state asked for is the state it gets.

        WHO MAY CALL IT is who may change an audience anywhere — the person
        who published the share, or a member carrying `members:manage` — and
        the call travels as the person ASKING rather than as the person who
        published. That is what lets a departed colleague's conversation be
        narrowed at all: nobody holds a credential for somebody who has left,
        so a call that had to travel as the author would wait for a sign-in
        that never comes.

        A share this home holds no copy of answers 404, and so does a revoked
        or expired one: there is no audience here for the list to apply to,
        which is the state the caller asked for.
      parameters:
        - name: narrowOnly
          in: query
          required: false
          description: >-
            Keep only the colleagues the copy already names AND the list
            names, and add nobody: the write a home makes on its own after a
            delivery, which may take people off a copy and never put one back.
            Nobody in common leaves the share's author alone. Off by default:
            the list sent is the whole list, as a person's retarget sends it.
          schema:
            type: boolean
            default: false
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ShareRecipients'
      responses:
        '204':
          description: >-
            The copy this home holds is for exactly the colleagues named.
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '413':
          $ref: '#/components/responses/RequestBodyTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/take-who-the-copy-of-a-share-is-for
  /v1/shares/{shareId}/disclosures:
    parameters:
      - $ref: '#/components/parameters/ShareIdParam'
    get:
      operationId: listShareDisclosures
      tags: [sharing]
      summary: Who has opened this shared copy, and when
      description: >-
        The disclosure audit at the home where the shared copy lives, addressed by the share id its reads are addressed by.

        IT IS THE SAME AUDIT the session-addressed operation serves, asked from the receiving end. A copy here has no session row to ask about, so a read of it is recorded against the share and read back by it.

        THE PAGE IS THIS COPY'S ALONE and carries no access to any other, which is where it differs from the session-addressed road: who may read a copy here is decided against the share the caller named, so the page is keyed by that same share. A home holding two copies of one conversation keeps two audits, and reading one says nothing about the other.

        WHO MAY READ IT IS NARROWER THAN WHO MAY READ THE CONVERSATION. Whoever published the share reads the list, and so does a member administering the organisation; everybody else is refused, the holder of sessions:read-private included, because a list of everyone who has read a conversation is a statement about people rather than about the work.

        A REVOKED SHARE STILL ANSWERS, which is what the tombstone is for: revoking deletes the conversation and leaves standing the history of who had read it. No retention window reaches these rows.

        Reading the audit is not itself recorded.
      parameters:
        - $ref: '#/components/parameters/CursorParam'
        - $ref: '#/components/parameters/LimitParam'
      responses:
        '200':
          description: One page of the audit, newest first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DisclosurePage'
        '400':
          $ref: '#/components/responses/BadRequest'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          $ref: '#/components/responses/InternalServerError'

      x-mint:
        href: /pt/reference/platform/narya/who-has-opened-this-shared-copy-and-when
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: >-
        Enforced on every transport, with no exempt operation. A person's request must carry a JWT issued by the configured identity provider, which the host verifies itself against that issuer's key set: signature, issuer, expiry, and the person and organisation it names. Requests without a valid one receive 401 NRY-0011 — except a request with no token on a laptop's socket from the OS user the kernel names as the host's own, which is that machine's user.
    extensionSignature:
      type: apiKey
      in: header
      name: Narya-Signature
      description: >-
        THE SECOND KIND OF CALLER, and the only operation it reaches is streamEvents. A registered extension holds no person's token: it signs its request under the secret this home minted for it when the owner registered it.

        FOUR HEADERS TRAVEL TOGETHER and this scheme names only the first, because OpenAPI's apiKey has room for one. `Narya-Signature` carries the algorithm name, a comma, and the MAC in lower-case hex; `Narya-Timestamp` the seconds since the epoch the call was signed at; `Narya-Request-Id` a value unique per call, which is the nonce a receiver refuses a second time; and `Narya-Extension` the publisher/name of the registration, which says which secret to verify under and is deliberately outside the signed bytes.

        WHAT IS SIGNED is HMAC-SHA256 over five parts, each terminated by a newline, in this order: the method upper-cased, the path including its query string, the timestamp, the request id, and the lower-case hex SHA-256 of the body (of the empty string when there is none). Nothing else.

        HOW IT IS REFUSED. A signed call that names any operation other than streamEvents is 403 NRY-0028. A signed call to streamEvents that this host cannot resolve to a registered, signature-verified extension is 401 NRY-0011, with one sentence for every cause so a refusal cannot be used to ask whether a registration exists. A verified call whose registration is disabled is 403 NRY-0028. The acceptance window is sixty seconds, and a request id already accepted inside it is refused as a replay.

  parameters:
    CursorParam:
      name: cursor
      in: query
      required: false
      description: Opaque pagination cursor from a previous page's nextCursor or prevCursor.
      schema:
        type: string
        maxLength: 1024
    ExtensionPublisherParam:
      name: publisher
      in: path
      required: true
      description: >-
        The publisher half of the extension's namespaced name.


        The name is `publisher/extension` and travels as TWO path segments
        rather than one. The single-segment spelling is not broken — a
        generated client percent-encodes a path parameter's slash and the
        router decodes it back — so this is a deployment choice rather than a
        repair: an encoded slash is normalised or rejected by most reverse
        proxies a hosted home sits behind, and two segments read the way the
        name reads, `/v1/extensions/lerian/redactor`.
      schema:
        type: string
        pattern: '^[a-z0-9][a-z0-9-]*$'
        examples: [lerian]
    ExtensionNameParam:
      name: name
      in: path
      required: true
      description: The extension half of the namespaced name.
      schema:
        type: string
        pattern: '^[a-z0-9][a-z0-9-]*$'
        examples: [redactor]
    LimitParam:
      name: limit
      in: query
      required: false
      description: Maximum items per page.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25
    SessionIdParam:
      name: sessionId
      in: path
      required: true
      description: The session's id.
      schema:
        type: string
        format: uuid
    EntryIdParam:
      name: entryId
      in: path
      required: true
      description: The transcript entry's id.
      schema:
        type: string
        format: uuid
    MonitorNameParam:
      name: name
      in: path
      required: true
      description: >-
        The monitor's name, unique within its session. It travels as one path
        segment, so its shape is bounded to what a path segment carries
        unchanged: a "/" would address no route, and a "?" or "#" would end the
        segment early and address a different watcher. The host refuses a name
        outside this shape when the monitor is started, so no monitor that
        exists can fail to be addressed here.
      schema:
        type: string
        minLength: 1
        maxLength: 64
        pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$'
    EnvironmentIdParam:
      name: environmentId
      in: path
      required: true
      description: The environment's identifier.
      schema:
        $ref: '#/components/schemas/EnvironmentId'
    LaneIdParam:
      name: laneId
      in: path
      required: true
      description: The lane's id.
      schema:
        type: string
        format: uuid
    PermissionIdParam:
      name: permissionId
      in: path
      required: true
      description: The permission ask's id.
      schema:
        type: string
        format: uuid
    QuestionIdParam:
      name: questionId
      in: path
      required: true
      description: The question's id, as question-asked announced it.
      schema:
        type: string
        format: uuid
    AskIdParam:
      name: askId
      in: path
      required: true
      description: The inter-session ask's id.
      schema:
        type: string
        format: uuid
    JobIdParam:
      name: jobId
      in: path
      required: true
      description: The package job's id.
      schema:
        type: string
        format: uuid
    RunIdParam:
      name: runId
      in: path
      required: true
      description: The workflow run's id.
      schema:
        type: string
        format: uuid
    ScheduleIdParam:
      name: scheduleId
      in: path
      required: true
      description: The schedule's id.
      schema:
        type: string
        format: uuid
    RefinementIdParam:
      name: refinementId
      in: path
      required: true
      description: The refinement's id.
      schema:
        type: string
        format: uuid
    RoundIdParam:
      name: roundId
      in: path
      required: true
      description: >-
        The refinement round's id, as proposeRefinement returned it or as
        refinement-proposed announced it.
      schema:
        type: string
        format: uuid
    AgentNameParam:
      name: name
      in: path
      required: true
      description: The agent recipe's name.
      schema:
        type: string
        pattern: '^[^\s/\\]+$'
        maxLength: 128
    AgentRepositoryParam:
      name: repository
      in: query
      required: false
      description: >-
        Absolute path of the repository to resolve recipes for. A
        repository's own .claude/agents and .narya/agents are read only when
        it is named here AND its instruction files have been trusted; with
        no repository named, the answer carries the built-ins, the user's
        own recipes and the installed packages' only. On a hosted home it
        resolves under the home's workspace, and a path outside it is
        refused 422.
      schema:
        type: string
        maxLength: 4096
    AgentSessionParam:
      name: sessionId
      in: query
      required: false
      description: >-
        The session to resolve recipes for, in place of repository: its own
        checkout's recipes as repository would, or, for a session working in
        an environment, those of the repository that environment cloned, read
        on its machine, and never the host checkout's. A session whose
        environment cloned nothing gets none. The read never starts a
        container: one that cannot be read right now is answered without the
        clone's recipes, saying so in notices. Naming both sessionId and
        repository is refused 400; a session that does not exist is 404, and
        one the caller may not read 403.
      schema:
        type: string
        format: uuid
    LadderRepositoryParam:
      name: repository
      in: query
      required: false
      description: >-
        Absolute path of the repository to resolve skills and command files
        for. A repository's own .claude and .narya directories are read only
        when it is named here AND it has been trusted; with no repository
        named, the answer carries the user's own files and the installed
        packages' only. It also decides where a command's shell blocks run;
        a hosted home runs none. On a hosted home it resolves under the
        home's workspace, and a path outside it is refused 422.
      schema:
        type: string
        maxLength: 4096
    LadderSessionParam:
      name: sessionId
      in: query
      required: false
      description: >-
        The conversation to resolve skills and command files for, in place of
        a repository: its own repository, or, for one whose code lives in an
        environment, the repository that environment cloned, read inside it.
        An environment that cloned nothing contributes no repository rung,
        and neither does this host's checkout. A listing never starts a
        stopped container: it answers without the clone's rung and says so
        in notices. Naming both sessionId and repository is refused 400; a
        conversation the caller may not read is refused as reading it is. A listing serves names rather than the conversation, so its
        disclosure audit does not record it.
      schema:
        type: string
        format: uuid
    SkillNameParam:
      name: name
      in: path
      required: true
      description: The skill's name, as listSkills reports it.
      schema:
        type: string
        pattern: '^[^\s/\\]+$'
        maxLength: 128
    CommandNameParam:
      name: name
      in: path
      required: true
      description: The command's name — what "/" reaches it by.
      schema:
        type: string
        pattern: '^[^\s/\\]+$'
        maxLength: 128
    PackageNameParam:
      name: name
      in: path
      required: true
      description: The package's name.
      schema:
        type: string
        maxLength: 256
    ProviderNameParam:
      name: name
      in: path
      required: true
      description: The provider's name.
      schema:
        type: string
        pattern: '^[a-z0-9][a-z0-9-]*$'
        maxLength: 128
    MediaKeyParam:
      name: mediaKey
      in: path
      required: true
      description: >-
        The name EntryAttachment.mediaKey carries, addressing one stored file
        inside one session's media directory. It travels as a single path
        segment and its shape is bounded to what a path segment carries
        unchanged, for MonitorNameParam's reason: a "/" would address no route,
        and a "?" or "#" would end the segment early. The host composes these
        names itself when it stores the bytes, so no key that exists can fail
        to be addressed here — and a key outside this shape did not come from a
        transcript this host wrote.
      schema:
        type: string
        minLength: 1
        maxLength: 128
        pattern: '^[A-Za-z0-9_][A-Za-z0-9._-]*$'
    ShareIdParam:
      name: shareId
      in: path
      required: true
      description: >-
        The share's id, as `SessionShare.shareId` reports it: 26 characters of
        RFC 4648 base32 drawn from a cryptographic source, carrying no
        encoding of anything. It is NOT a uuid and is deliberately unrelated
        to the session id it was published from — a share id travels, so an id
        derived from a session would make every leaked link a statement about
        the machine it came from.
      schema:
        type: string
        minLength: 26
        maxLength: 26
        pattern: '^[A-Z2-7]{26}$'

  responses:
    BadRequest:
      description: Malformed request — invalid parameter, cursor, or JSON body.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Unauthorized:
      description: >-
        Authentication required — a request carrying no valid identity token (NRY-0011).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Forbidden:
      description: >-
        The caller is authenticated and is not allowed this operation (NRY-0028 or NRY-0030).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    NotFound:
      description: The addressed resource does not exist on this host.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    Conflict:
      description: >-
        The resource's current state rejects the request — archived session,
        busy lane, slot conflict, failed build, already-final state.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    ProductNotLoaded:
      description: >-
        A product session's product has no tools loaded on this home yet
        (NRY-0021): nothing was admitted, and sending it again does.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    PreconditionFailed:
      description: >-
        A conditional write's precondition no longer holds — the resource
        changed since the caller last read it (NRY-0020). Nothing was written.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RequestBodyTooLarge:
      description: >-
        The request body weighs more than this operation accepts (NRY-0024).
        The host holds one ceiling per operation and refuses at the door, in
        front of every handler: a body whose declared Content-Length is past
        the ceiling is refused before a byte of it is read, and a body that
        declares no length is read only as far as the ceiling and refused
        there. Nothing was read past that point and nothing was written.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    UnprocessableEntity:
      description: >-
        The request was well-formed but failed validation.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    InternalServerError:
      description: The host failed — including a store that refuses writes (NRY-0012).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  schemas:
    Error:
      type: object
      description: >-
        The single error envelope every operation returns. Codes are NRY- followed by four digits and are catalogued in the top-level x-error-catalog extension.
      required: [code, title, message]
      properties:
        code:
          type: string
          pattern: '^NRY-[0-9]{4}$'
          description: Machine-readable error code from the NRY catalogue.
        title:
          type: string
          maxLength: 256
          description: Short human-readable summary of the error class.
        message:
          type: string
          maxLength: 4096
          description: Specific, actionable description of what went wrong.
        fields:
          type: object
          description: Per-field validation problems.
          additionalProperties:
            type: string
      examples:
        - code: NRY-0002
          title: Session not found
          message: No session with id 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d exists on this host.

    Host:
      type: object
      description: The host process's identity and operating mode.
      required: [version, uptimeSeconds, mode, storePath]
      properties:
        version:
          type: string
          description: The host binary's version.
        uptimeSeconds:
          type: integer
          minimum: 0
          description: Seconds since the host started.
        mode:
          type: string
          enum: [local]
          description: >-
            How the host serves this API. This host answers `local`.
        storePath:
          type: string
          description: Absolute path of the local store.
        pid:
          type: integer
          minimum: 1
          description: >-
            The serving process's own id, so a client can cross-check the
            machine's host registration file against the host that actually
            answers. That file is a claim written by a process which may be
            long dead, and its pid is the part that rots: the operating system
            hands a dead host's number to an unrelated live program, and a
            client trusting the file alone would report a host as serving when
            nothing is. Optional because a host older than this field cannot
            answer it; absent means the cross-check cannot be made, which is
            not the same as a mismatch.
        signIns:
          type: array
          description: >-
            Sign-ins the host is waiting on a PERSON to complete right now — an OAuth-protected tool server whose authorization page
            nobody has visited yet. Present on this operation, rather than only
            on the per-server tool-server readout, because this is the cheap
            handshake every client already polls: the URL has to reach whoever
            is at a terminal within seconds of the host asking for it, and a
            client cannot be asked to ping every tool server to discover that
            somebody is being waited on. Empty or absent means nobody is
            waiting.
          items:
            $ref: '#/components/schemas/SignInWaiting'
        diagnostics:
          type: array
          description: >-
            What went wrong while this host loaded, in the order it was
            discovered: a config key nothing reads, an [extensions] tie-break
            naming a slot no installed package claims, a package whose manifest
            could not be read and whose hooks were therefore skipped. Every
            entry is non-fatal BY CONSTRUCTION — anything fatal fails
            composition and the process never starts, so there is no severity
            to carry and no client decision to make about one.

            Host-scoped, not session-scoped: these are facts about the process,
            settled once before it served anything and unchanged for its whole
            life, which is why they ride the handshake rather than a session's
            own response. A client shows them once per connection, because the
            host that a shell spawns has its stderr on /dev/null and every
            interactive client covers the terminal the operator would otherwise
            read them on. Empty or absent means the host loaded cleanly.

            Bounded on the host side: at most 64 lines of at most 512 runes
            each are retained, with overflow announced in the final line, so
            a loader gone wrong cannot bloat every handshake. The host's own
            stderr always carries the full text.
          maxItems: 64
          items:
            type: string
            maxLength: 512
      examples:
        - version: 1.0.0
          uptimeSeconds: 86400
          mode: local
          storePath: /Users/dev/.config/narya/narya.db

    DoctorReport:
      type: object
      description: >-
        One health walk over an installation: the aligned text `narya doctor`
        prints, plus the same lines structured so a caller can gate on them
        without parsing prose.

        healthy, failures and `narya doctor`'s exit code are one decision
        expressed three times, never three that can disagree: healthy is
        failures == 0, and the command's exit code is its projection. Warnings
        are compatible with healthy — a warning is something that works today
        and will bite you later — which is why the counts are carried
        separately and a stricter consumer can gate on warnings itself.
      required: [text, version, home, healthy, findings, warnings, failures, checks]
      properties:
        text:
          type: string
          description: >-
            The walk as `narya doctor` prints it: one aligned
            name/status/detail line per check, each finding's remedy indented
            underneath, and a closing verdict. Carried so a client renders the
            same walk a terminal does rather than re-implementing the layout
            and drifting from it.
        version:
          type: string
          description: The version the walk reports for the binary it describes.
        home:
          type: string
          description: The resolved narya home directory this walk describes.
        healthy:
          type: boolean
          description: Nothing is broken — no fail-class line. This is what exit 0 means.
        findings:
          type: integer
          minimum: 0
          description: Every line that is not ok — warnings plus failures.
        warnings:
          type: integer
          minimum: 0
        failures:
          type: integer
          minimum: 0
        checks:
          type: array
          description: Every line of the walk, in the order it was checked.
          items:
            $ref: '#/components/schemas/DoctorCheck'
      examples:
        - text: "narya doctor — /Users/dev/.config/narya\n\nhome  ok  ...\n\nno findings\n"
          version: 1.0.0
          home: /Users/dev/.config/narya
          healthy: true
          findings: 0
          warnings: 0
          failures: 0
          checks:
            - name: home
              status: ok
              detail: /Users/dev/.config/narya

    DoctorCheck:
      type: object
      description: >-
        One line of the health walk: what was checked, how it looks, and —
        when it does not look right — what to do about it.
      required: [name, status, detail]
      properties:
        name:
          type: string
          description: What was checked, e.g. home, config, store, terminal.
        status:
          type: string
          enum: [ok, warn, fail]
          description: >-
            ok is a FACT about this installation, never a finding. warn means
            it works today and will bite you later. fail means it needs fixing
            now, and is the only class that makes `narya doctor` exit 1.
        detail:
          type: string
          description: What the check found, in one line.
        remedy:
          type: string
          description: >-
            The command that repairs this finding. Doctor names a repair; it never runs one.

    SignInWaiting:
      type: object
      description: >-
        One tool server sign-in waiting on a person: the page to open,
        when the host started waiting, and when it stops. The wait is on a
        HUMAN timescale and is deliberately not the connection attempt's own
        ceiling — a handshake bound measured in seconds is right for a socket
        and absurd for somebody opening a browser and passing MFA.
      required: [server, url, since, expiresAt]
      properties:
        server:
          type: string
          description: >-
            The tool server this sign-in belongs to. A repository's own server is named `<name> in <repository>`, since each repository signs in on its own.
        url:
          type: string
          format: uri
          description: >-
            The authorization page a person must open. The host is listening on
            the loopback redirect that page comes back to, so completing it in
            any browser on this machine connects the server — no restart, and
            no further command.
        since:
          type: string
          format: date-time
          description: When the host started waiting for this sign-in.
        expiresAt:
          type: string
          format: date-time
          description: >-
            When this sign-in window closes. After it, the server goes down
            with "nobody completed the sign-in" as its reason and is asked again
            at most once per window.
      examples:
        - server: github
          url: https://github.test/login/oauth/authorize?client_id=narya&state=b7c1
          since: 2026-08-14T09:12:00Z
          expiresAt: 2026-08-14T09:17:00Z

    ToolServerHealth:
      type: object
      description: >-
        One external tool server's health and cost. tools is how many of
        its tools are in the registry right now — a server that went down has
        none, because its tools leave the registry rather than erroring on call.
      required: [name, status, tools, attempts, calls, catalogueBytes]
      properties:
        name:
          type: string
          description: The name the server was declared under, or `.mcp.json` for a repository whose file does not read or parse, which starts none.
        status:
          type: string
          description: >-
            waiting-for-sign-in is a connection attempt held open for a PERSON: the server demanded OAuth, the host put an authorization URL
            in front of whoever is watching, and nothing else is wrong. It is
            reported separately from connecting because the two need opposite
            reactions — connecting resolves itself, this one resolves only when
            somebody opens signInUrl — and because the alternative was a
            five-minute "connecting" whose eventual failure read "context
            canceled" and never named the sign-in.
          enum: [connecting, connected, down, waiting-for-sign-in]
        since:
          type: string
          format: date-time
          description: >-
            When the server entered this status — for waiting-for-sign-in, when
            the host started waiting on the person.
        tools:
          type: integer
          minimum: 0
        attempts:
          type: integer
          minimum: 0
          description: Consecutive failures since this server last connected.
        calls:
          type: integer
          format: int64
          minimum: 0
          description: Tool calls this server has been asked to make.
        catalogueBytes:
          type: integer
          minimum: 0
          description: Characters of model context this server's catalogue entry spends.
        pingMs:
          type: integer
          minimum: 0
          description: >-
            Round trip of the live MCP ping taken while answering. Absent when
            the server is not connected, or when the ping failed — in which case
            lastError says so.
        lastError:
          type: string
          description: >-
            Why the last attempt, session or ping failed; on a connected repository server, which of its tools
            its repository's sessions are not offered because another repository's server of the name defines them differently.
        repository:
          type: string
          description: >-
            The repository root a repository's own server serves (its `.mcp.json`),
            or the environment whose container runs it, for a conversation's clone;
            absent for a server the person declared in config.toml.
        signInUrl:
          type: string
          description: >-
            The authorization page a person must open to finish this server's
            sign-in. Present only while status is waiting-for-sign-in.
        signInExpiresAt:
          type: string
          format: date-time
          description: >-
            When this sign-in window closes. Present only while status is
            waiting-for-sign-in.
      examples:
        - name: matcher
          status: down
          since: 2026-08-13T09:12:00Z
          tools: 0
          attempts: 14
          calls: 0
          catalogueBytes: 0
          lastError: 'connect: executable file not found in $PATH'

    ToolCallReceiptPayload:
      type: object
      description: >-
        One tool call a model-authored program made from inside itself.
        It is the display channel of that call's three-channel result and
        nothing else: the model text and the structured value stayed inside the
        program and never re-enter model context. parentToolCallId is the
        run_program call the receipt belongs to, which is what a client
        collapses the line under.
      required: [turnId, parentToolCallId, tool, status]
      properties:
        turnId:
          type: string
          format: uuid
        parentToolCallId:
          type: string
          description: The program's own tool-call id.
        tool:
          type: string
          description: The tool the program called.
        status:
          type: string
          enum: [ok, error]
        title:
          type: string
          description: >-
            The one line a client renders — verb, object and outcome. Bounded by
            the host, because a receipt is one line and a tool's title can carry
            its caller's own argument.
        fileTouches:
          type: array
          description: >-
            The file rows this call produced, so a touched-files panel is TOLD
            what was touched rather than inferring it from a tool name. At most
            128 rows; fileTouchesTruncated says when there were more.
          items:
            $ref: '#/components/schemas/FileTouch'
        fileTouchesTruncated:
          type: boolean
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          parentToolCallId: call_07
          tool: edit
          status: ok
          title: Edit internal/wire/sse.go
          fileTouches:
            - path: /Users/dev/repos/midaz/internal/wire/sse.go
              op: edit

    ToolCallReceiptEvent:
      description: Envelope for tool-call-receipt.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: tool-call-receipt
            payload:
              $ref: '#/components/schemas/ToolCallReceiptPayload'
      examples:
        - id: '1007'
          type: tool-call-receipt
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:40Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            parentToolCallId: call_07
            tool: read
            status: ok
            title: Read a.md

    FileTouch:
      type: object
      description: One file a tool call read or changed.
      required: [path, op]
      properties:
        path:
          type: string
          maxLength: 4096
        op:
          type: string
          enum: [read, write, edit, delete]
      examples:
        - path: /Users/dev/repos/midaz/internal/wire/sse.go
          op: edit

    TokenUsage:
      type: object
      description: >-
        Token consumption totals and their cost. costUsd is priced from the
        catalogue's published rates for the model that ran, and is absent
        whenever no rate is known for it — a model configured by hand
        against a private endpoint, say. Absent means unknown; a false zero
        would mean free. On a session total it is absent unless every turn
        in the session could be priced.
      required: [inputTokens, outputTokens]
      properties:
        inputTokens:
          type: integer
          minimum: 0
          description: Input tokens consumed.
        outputTokens:
          type: integer
          minimum: 0
          description: Output tokens produced.
        cacheReadTokens:
          type: integer
          minimum: 0
          description: Tokens served from the provider's prompt cache.
        cacheWriteTokens:
          type: integer
          minimum: 0
          description: Tokens written to the provider's prompt cache.
        costUsd:
          type: number
          minimum: 0
          description: Total cost in US dollars.
      examples:
        - inputTokens: 152000
          outputTokens: 8400
          cacheReadTokens: 120000
          cacheWriteTokens: 9000
          costUsd: 1.87

    Entry:
      type: object
      description: >-
        One row of a session's append-only transcript. kind names what the
        row records, and the field carrying its content follows from kind:
        text for the message kinds, toolCall for a tool call, toolResult for
        that call's outcome. An entry whose content this surface does not
        serve — an assistant round's reasoning blocks — carries none of the
        three, and a client renders nothing for it rather than an empty
        message in the assistant's voice.


        A `user-message` with `decided: true` is not the person's words: the
        host wrote it to tell the model a decided write's outcome and started
        the next turn with it. The model still reads it as a user message; a
        client draws it as the host's note, never as the person's own row.
      required: [id, sessionId, laneId, sequence, kind, createdAt]
      properties:
        id:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid
        laneId:
          type: string
          format: uuid
        sequence:
          type: integer
          format: int64
          minimum: 1
          description: >-
            The entry's position in this session's transcript, ascending and
            unique within the session. It is also the paging position: the
            cursor of a page resumes after the last sequence it returned.
        kind:
          type: string
          enum: [user-message, assistant-message, tool-call, tool-result, summary, system, intercom, monitor-event]
        createdAt:
          type: string
          format: date-time
        text:
          type: string
          description: >-
            The entry's plain text, for the kinds that carry text: a person's
            message, the assistant's answer, a compaction summary, a system
            note nobody typed, an inter-session message. Absent on the tool
            kinds and on an entry whose content is not served.
        label:
          type: string
          maxLength: 128
          description: >-
            The tag somebody put on this moment of the conversation
            (setEntryLabel), absent when nobody named it. This is the read half
            of that write: without it a label can be set and then never found —
            no client can show the tag on the entry it belongs to, confirm a
            write landed, or offer to clear one with its current value.
            SessionTreeNode.labels says a whole session carries "the version
            that worked" and never which of its entries does.
        decided:
          type: boolean
          description: >-
            True on a user-message the host wrote to report a decided product
            write's result or decline; absent on every other entry, the
            person's own messages included.
        excludedReason:
          type: string
          description: >-
            Why this entry is no longer part of what the model is sent, absent
            while it still is. An entry with a reason has left the active
            window for good — the first producer is a message that overflowed
            the model's context window, and overflowed it again after the
            conversation behind it was summarised, so it provably cannot be
            sent.
            It is served because the entry is still HERE: an exclusion is not a
            deletion, the entry keeps its own text, and a reader who sees the
            conversation jump would otherwise have no way to tell what was set
            aside or why. A client drawing a transcript can mark the row rather
            than hide it.
        restorePoint:
          type: boolean
          description: >-
            Whether the files can go back to how they stood at this moment —
            true when a restore point reaches this entry or an earlier one.
            False is not a failure: restore points begin at the steps a turn
            commits, and a conversation that ran before narya was snapshotting
            it has moments the files cannot return to. Outside a git repository
            narya keeps no file snapshots, so it is false for every moment.
            It is served here because it is what a client offering to rewind
            has to say on the row BEFORE somebody picks it. Discovering it
            after the choice wastes the whole gesture, and rewinding the
            conversation at that same moment works either way.
        agent:
          type: string
          description: >-
            The named recipe this message asked its turn to run under, absent
            when it named none and on every entry a turn writes. A message
            naming none runs under its lane's own agent (Lane.agent): a
            delegate's, or the one its session was created under. It is served because a client drawing a conversation it
            never watched has no other source for it: the session's current
            agent is what the NEXT message will run under, so attributing a
            turn from three agents ago to it would name the wrong recipe.
        model:
          type: string
          description: >-
            The model that produced this assistant message, absent on the
            other kinds and on a round written before this was recorded. It
            closes a turn's attribution for a client reading the conversation
            back: the same fact a live watcher takes off the stream.
        provider:
          type: string
          description: >-
            Which supplier issued the model above, absent for the same reasons.
        stopReason:
          type: string
          description: >-
            Why this assistant round ended, when it ended somewhere other than
            its own end — max-tokens, content-filter, interrupted. Absent on a
            round that finished normally, which is what carries no admission on
            the line closing the turn.
        reasoning:
          $ref: '#/components/schemas/EntryReasoning'
        toolCall:
          $ref: '#/components/schemas/EntryToolCall'
        toolResult:
          $ref: '#/components/schemas/EntryToolResult'
        attachments:
          type: array
          description: >-
            The pictures this row carried, absent on every row that carried
            none — which is nearly every row of nearly every conversation, so
            an empty array would be a field spent on saying nothing.

            It exists because a client reading a conversation back has no other
            source for it. A picture somebody attached is not in the entry's
            text: the text is the words they typed, and until this the only
            trace of the file was an "@" marker inside those words, which
            cannot say what kind of thing was attached, whether the bytes are
            still there, or how tall it is. So a client drawing a transcript it
            never watched happen drew nothing where a screenshot was.

            The records carry metadata alone. Entry.attachments says a picture
            is there and what shape it is; GET
            /v1/sessions/{sessionId}/media/{mediaKey} is the one road to its
            bytes.
          items:
            $ref: '#/components/schemas/EntryAttachment'
      examples:
        - id: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          sequence: 1
          kind: user-message
          createdAt: 2026-08-08T12:01:03Z
          text: where does the ledger validate a transaction's asset code?
          label: the version that worked

    EntryReasoning:
      type: object
      description: >-
        What one round of the model's own thinking WAS, without any of what it
        said. The blocks are still not served and neither are their signatures;
        these are three derived facts about the run — what the model called it,
        how many blocks it took, and how long it ran — and none of them is text
        in the assistant's voice.
        It exists because the collapsed line naming a run is part of the
        conversation (R10) and a client reading a session back had no source for
        it: a replayed turn that thought for four minutes showed nothing where
        the thinking happened. A client expanding one of these says the
        scratchpad is not kept rather than drawing an empty surface.
      properties:
        title:
          type: string
          description: >-
            The heading the model wrote as the first act of the run, absent when
            it named the run nothing. Parsed from the first block rather than
            served from it — a heading is what the model called its own work,
            and the work itself stays here.
        steps:
          type: integer
          minimum: 1
          description: How many reasoning blocks the round produced.
        durationMs:
          type: integer
          minimum: 1
          description: >-
            How long the round ran, measured from the entry before it to this
            one. Absent when the entry before it is not in this page, which is
            the first row of a page and nothing else — a client drops the
            segment rather than printing a duration of zero.
      examples:
        - title: Inspecting the ordering
          steps: 3
          durationMs: 4200

    EntryToolCall:
      type: object
      description: >-
        A tool-call entry's content: the call exactly as the model asked for
        it, arguments untruncated. The stream's tool-call-started event
        carries a bounded preview of the same call; this is the durable
        record it announces.
      required: [id, name]
      properties:
        id:
          type: string
          description: The tool call's id, matching the tool-result entry that answers it.
        name:
          type: string
          description: The tool's name.
        arguments:
          type: object
          additionalProperties: true
      examples:
        - id: call_01
          name: read
          arguments:
            path: components/ledger/validate.go

    EntryToolResult:
      type: object
      description: >-
        A tool-result entry's content: the text the model read next (its
        model channel) and whether the call failed. The structured machine
        channel and the display metadata are not served here — a transcript
        read reconstructs the conversation, not a tool's private plumbing.
      required: [toolCallId, output]
      properties:
        toolCallId:
          type: string
        output:
          type: string
        isError:
          type: boolean
        denied:
          type: boolean
          description: >-
            The call was REFUSED rather than attempted: a permission rule or a
            person turned it down, so the tool never ran. Always accompanied by
            isError, because the refusal is what the model read as this call's
            answer — this says which kind of failure it was.

            It exists because the two are different facts to a person reading a
            session back, and only one of them is worth acting on: a tool that
            ran and failed is the model's problem, and a tool that was refused
            is a rule the reader may want to change. The live stream has always
            distinguished them (tool-call-finished's `denied` status); the
            durable entry could not, so a client reading a conversation it did
            not watch had to report both as failures. Absent means the call was
            attempted, which is every entry recorded before this field existed.
        ranWith:
          type: object
          additionalProperties: true
          description: >-
            The arguments this call ACTUALLY ran with, present only where a
            rule rewrote them before the call went out. Absent means the call
            ran on exactly what the tool-call entry carries, which is the
            ordinary case and every entry recorded before this field existed.

            It is on the ANSWER rather than the question because the tool-call
            entry is the model's own message replayed back to it: editing that
            would rewrite what the model is re-shown, and reading a session
            back would then never reveal that anything was changed at all. Both
            facts are on the record instead — what was asked for on the call,
            what was done on its answer — and a person reading a conversation
            can see a rule at work without having been watching when it fired.

            A redaction is the shape to expect: a rule strips a secret out of a
            `write` and the call runs on the stripped version. What appears
            here is therefore what the TOOL saw, not what the model composed.
      examples:
        - toolCallId: call_01
          output: "func Validate(tx Transaction) error {"
          isError: false

    EntryAttachment:
      type: object
      description: >-
        One file a transcript row carried, as this host stored it — metadata
        only, never the bytes.

        The BYTES are fetched rather than pointed at, and that is the whole
        shape of this schema. A path in a JSON field describes a file on the
        machine the engine runs on, and the client may be nowhere
        near that machine — so a path would be a road that works on a laptop
        and silently breaks the day the host is elsewhere. mediaKey names the
        file to the operation that serves it, and nothing else may be inferred
        from it.

        The PIXEL SIZE travels here rather than being sniffed from the bytes
        because of the order a client has to decide things in: how many rows
        to reserve for a picture is settled before whether to spend the fetch,
        and sniffing the size means fetching. bytes answers the second half of
        that decision. All three are absent on a record stored before this
        host recorded them, and on a file that has no pixels at all — a
        converted document is an attachment too.
      required: [mediaKey, name, mediaType]
      properties:
        mediaKey:
          type: string
          description: >-
            What this file is called inside its own session's media store, and
            the whole of what GET /v1/sessions/{sessionId}/media/{mediaKey}
            needs. Opaque: the host composes it, and a client that takes it
            apart is reading a private layout.
        name:
          type: string
          description: >-
            What the file was called where the person had it. It is what a chip
            in a composer or a caption under a picture says, and what every
            sentence about an attachment that cannot be shown has to name — a
            placeholder reading only "an image" leaves nobody able to say which
            of three.
        mediaType:
          type: string
          description: >-
            The mime this host sniffed from the file's own leading bytes, never
            from its name. It is also what the media operation answers with.
            An attachment that is not an image/* is a document narya converted
            for the model to read.
        width:
          type: integer
          minimum: 1
          description: The picture's width in pixels.
        height:
          type: integer
          minimum: 1
          description: The picture's height in pixels.
        bytes:
          type: integer
          minimum: 1
          description: >-
            How much the stored file weighs. A client decides whether a picture
            is worth a fetch on this and on the pixel size, before it asks for
            anything.
      examples:
        - mediaKey: screenshot.png-2749183021
          name: screenshot.png
          mediaType: image/png
          width: 1568
          height: 1045
          bytes: 284117

    Session:
      type: object
      description: >-
        A durable conversation container. Survives the process that
        created it; archiving hides it from active lists but its transcript
        remains queryable and exportable forever.
      required: [id, repository, status, clientKind, createdAt, updatedAt]
      properties:
        id:
          type: string
          format: uuid
          description: The session's id.
        title:
          type: string
          maxLength: 512
          description: Human-readable title.
        repository:
          type: string
          maxLength: 4096
          description: >-
            Absolute path of the directory the session works in; on a hosted
            home, the path relative to its workspace, which is all a
            client there is shown.

            It is a working-directory LABEL and carries no authority. The
            session's key is `id`, and whose session it is is recorded by the
            host from the identity it authenticated the request as — nothing is
            authorised, looked up or scoped to a tenant by this field. What it
            is read for is what a directory is for: where the tools are
            confined, which project's configuration and instruction files
            apply, and the `repository` filter on listSessions.
        status:
          type: string
          enum: [idle, running, waiting, archived]
          description: >-
            Lifecycle: running while a turn executes or normal follow-ups
            chain; waiting after interrupt when admitted steering or follow-up
            inputs remain queued; idle only when neither execution nor queued
            input exists; archived accepts no new work.
        provider:
          type: string
          description: The model provider currently in use.
        model:
          type: string
          description: The model currently in use.
        variant:
          type: string
          description: >-
            The preset of request options this session's turns run under —
            the name, not the options it stands for, since what a name means
            is the catalogue's answer and can change while the session's own
            choice cannot. Absent on a session that never chose one, which is
            every session running its model at the model's own defaults.
        style:
          type: string
          maxLength: 64
          description: >-
            The named response style this session's answers are shaped by —
            the name, not the prompt it appends, since what a name means is
            configuration's answer and changes when somebody edits that file
            while the session's own choice does not. Absent on a session that
            never chose one, which is every session answering in narya's own
            voice. Unlike `variant` it survives a model switch: a style is a
            fact about how the conversation reads rather than about the model
            answering it. A client renders this and derives nothing — the
            names that exist are the host's answer (setSessionStyle refuses
            the rest).
        clientKind:
          $ref: '#/components/schemas/ClientKind'
          description: >-
            Which kind of client opened this session, recorded when it was
            created and never rewritten. Always present: `unknown` for a
            session created before this host recorded the datum, which is the
            honest answer rather than a guessed frontend.
        modelNotice:
          type: string
          maxLength: 1024
          description: >-
            One sentence stating what a client could not otherwise know about
            the model this session opened on or was just moved to, present on
            the responses to createSession and setSessionModel, and on the
            session-created event of a session createSession opened, so a
            reader of the event stream (`narya -p --json`) has it too. Three facts
            earn it. The first is a model narya chose itself rather than being
            told to use: no default_model configured and no model previously
            chosen, so the first provider holding a usable credential answered
            — or a model chosen earlier that the catalogue no longer carries,
            which was passed over. It names the alternatives that
            also qualified and how to choose another, because a choice nobody
            made has to be stated to be a choice at all rather than something
            that merely happened. The second is a level this session will run
            without: a model reference may carry one (see the variant field),
            and an endpoint that does not take the field runs at its own
            default instead — so a level named at creation or at a switch, or
            configured beside default_model, is said here rather than quoted
            in a picker while every request drops it. The third is a
            default_model no provider lists: it is refused, never replaced, so
            the session opens on no model and this sentence names the one
            configured. A client that renders
            the live event stream rather than the session's transcript has no
            other road to it. Otherwise absent, including for a model that was
            simply configured, chosen or named; absent on getSession and
            listSessions, which report state rather than how it came about.
        usage:
          $ref: '#/components/schemas/TokenUsage'
          description: >-
            What this session has consumed and cost across every turn it has
            run — a running total, not the last turn's figure. Carried by
            getSession and by every row of listSessions, so a client drawing a
            list of sessions can say what each one has cost without asking per
            row. Absent on a session that has never completed a turn, and its
            costUsd is absent whenever any one of that session's turns could
            not be priced: the sum over only the priced turns is smaller than
            what the session actually cost, and a figure that quietly
            understates money is worse than an admitted unknown.
        lastTurnUsage:
          $ref: '#/components/schemas/TokenUsage'
          description: >-
            What the newest round of this session's own conversation sent and
            produced — the LAST turn's figure, not the running total above.
            Carried by getSession only.

            It exists because the two are not interchangeable for the one
            question a reader reopening a long conversation asks: how much room
            is left before this compacts. That answer is the size of the prompt
            last sent, and usage above is a sum over every turn the session ever
            ran, which is a larger number growing without bound and means
            nothing as a fraction of a context window. The live event stream
            carries this figure on turn-finished, so a client watching a session
            has always had it; a client that READ the session had no source for
            it at all and drew no context figure until it spent a turn of its
            own. Restoring a recorded reading rather than deriving one is the
            whole of it — nothing here is computed from the transcript.

            The newest round of the MAIN lane: a delegation's rounds are another
            conversation's prompts, and the figure is about the reader's. Absent
            on a session that has never completed a round, and on one whose
            rounds all ran on delegates. Its costUsd is that one round's price,
            absent when the round could not be priced.
        pendingInputs:
          type: array
          description: >-
            Pending steering and follow-up references in durable operation
            order. Content is intentionally absent; fetch the transcript by
            entryId.
          items:
            $ref: '#/components/schemas/PendingInputReference'
        parentSessionId:
          type: string
          format: uuid
          description: When the session was branched, the session it branched from.
        summary:
          type: string
          maxLength: 4096
          description: >-
            What a model said this conversation tried and concluded. Absent
            until somebody asks for it (summarizeSession) — nothing summarises
            a branch because a rewind moved away from it. A model that answers
            past this length is trimmed to it, with a marker saying so rather
            than a sentence that just stops.
        summaryAt:
          type: string
          format: date-time
          description: >-
            When the summary was taken. Read against updatedAt it answers
            whether the summary predates the last thing that happened here.
        posture:
          $ref: '#/components/schemas/Posture'
          description: >-
            Whether somebody is there to answer a permission prompt raised
            here. Absent on a session nobody ever said either way about, which
            reads by its clientKind (Posture) — the posture is only ever
            recorded because somebody stated it (setSessionPosture), never
            inferred from whether a client happens to be connected.
        warming:
          $ref: '#/components/schemas/Warming'
          description: >-
            Whether this session pays to keep its provider cache alive while
            nobody is using it, and the most it may ever spend doing so.
            Absent on a session nobody ever turned it on for, which is every
            session until somebody does — warming is off unless somebody asked
            (setSessionWarming).
        viewedAt:
          type: string
          format: date-time
          description: >-
            When a reader last had this session in front of them
            (acknowledgeSessionViewed). Read against updatedAt it answers
            whether anything has happened here since somebody looked, which is
            what lets a client mark a background conversation as carrying
            news — and, because the fact is held here rather than in a window,
            what makes two clients agree about it and makes the answer survive
            a relaunch. ABSENT on a session nobody has ever viewed, which is
            not the same as viewed long ago: one has nothing to report, the
            other has everything that ever happened in it.
        lastOutcome:
          type: string
          enum: [finished, failed]
          description: >-
            How the last turn here ended. It is what lets a client that was
            not running at the time tell a conversation that finished from one
            that failed. Absent on a session that has never run a turn. An
            interrupt is not among the values: somebody was there and stopped
            it themselves, so it is not news to bring back to them.
        cacheSpend:
          $ref: '#/components/schemas/CacheSpend'
          description: >-
            What this session's provider prompt cache has cost it, from both
            sides: what letting it expire cost, and what keeping it alive
            cost. Present only on getSession, and only once one of the two has
            happened — a session that has never paid for either carries
            nothing here rather than a pair of zeros.
        environment:
          $ref: '#/components/schemas/SessionEnvironment'
          description: >-
            Where this session's code lives and its commands run, present only
            when that is somewhere other than the host's own machine. Absent
            is the ordinary case and says the session runs here — a developer
            working on their own machine is told nothing, because there is
            nothing to tell. On a hosted home it says instead that the
            session's own container is not made yet: its first turn makes it.
        clone:
          type: string
          description: >-
            The repository cloned into the tree this session works in, as
            `owner/name`: the `clone` it was opened with, or the `repository`
            of the environment it runs on. Absent when nothing was cloned.
        product:
          type: string
          description: >-
            The product tree a product session reads through (createSession's
            `product`); absent on every other session.
        tasks:
          type: array
          description: >-
            The agent's own task list for this session — every item it
            has written, in the order it wrote them, whatever is in the store
            right now.

            It is here rather than behind a listing of its own because the list
            is a stored row and every client already reads the session when it
            attaches, so one optional field costs no round trip and no second
            operation. A change to the list is announced as `task-list`, which
            carries the same whole list for the same reason; this is where a
            client that was not attached at the time gets it.

            Present on getSession only, like `lastTurnUsage` and `cacheSpend`
            and for their reason: a listing that carried it would read every
            row's tasks to draw a page nobody asked for.

            ABSENT MEANS EMPTY, and it cannot mean anything narrower: the store
            holds rows, so a list nobody ever wrote and a list somebody cleared
            are the same read. The live stream is where those two differ — a
            clearing write announces `task-list` carrying an empty array — and
            a read that returned `[]` would be claiming to know which of the
            two it was looking at.
          items:
            $ref: '#/components/schemas/Task'
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
          description: >-
            When this session's own ROW last changed. Eight writes stamp it and
            nothing else does: its status, its provider and model, its style,
            its title, its repository, its environment binding, its archival,
            and the status a restart settles it into.

            IT IS NOT A LAST-ACTIVITY CLOCK, and reading it as one is the
            mistake this description exists to prevent. A transcript entry, a
            streamed event, a tool call, a summary and a viewed mark all leave
            it exactly where it was — so a session that is `running` carries
            THE MOMENT ITS TURN WAS CLAIMED, not the moment anything last
            happened in it — and a moment later than that only when one of the
            other seven writes landed during the turn: a rename mid-turn is
            allowed and restamps the row, moving this field forward with
            nothing having happened in the conversation. A caller asking
            whether a run has gone quiet reads the event stream; read off this
            field, "nothing for N minutes" is true of every healthy turn that
            has been running longer than N, and false of a stuck one somebody
            renamed.

            `SessionAttention.since` carries the same warning for a waiting
            session, and for the same reason.
        owner:
          type: string
          readOnly: true
          description: >-
            Who this session belongs to: the identifier of the person the
            request that created it was authenticated as, inside the
            organisation this host serves. It is who the session is attributed
            to and whose allowance its cache warming is billed against.

            The host writes it from the authenticated identity and there is no
            owner field on any create request — its absence there is the rule
            rather than an omission, because an owner a caller can name is an
            owner anybody can be, and this value is read later by whoever
            decides who pays and who may open a private conversation. A
            request body naming an owner changes nothing.

            `unclaimed` is the one value that is not a person: a store that
            existed before this host recorded owners, on a machine nobody has
            signed in on yet. The first sign-in against such a store replaces
            it everywhere.
        visibility:
          $ref: '#/components/schemas/Visibility'
          description: >-
            Who inside this organisation may read this session. Always present
            on a host that serves this field, unlike `posture` beside it: every
            session HAS a visibility, and the absence of a mark is the
            organisation's rather than a third state, so there is nothing for
            an absent field to mean. A client draws the mark from this value
            and derives nothing — whether a session is held back, and whether
            this reader may see it at all, are the host's answers.
        attention:
          $ref: '#/components/schemas/SessionAttention'
          description: >-
            What this conversation is waiting for, when it is waiting for a person. `status` says `waiting` and does not say what for.


            Carried by every row of listSessions and resolved for the whole page in one read, which is the point of it: a page of a hundred rows is enough to draw an overview of sessions, with no request per row. getSession carries the same field for its one session.


            ABSENT WHEN THERE IS NOTHING TO SAY, and absent rather than null-shaped: a conversation nobody is being asked about carries no field, not an empty object. It is also absent when the caller may not see the prompt itself — on a conversation somebody marked private, a reader who is not its owner and does not hold the read-private permission sees no attention even though the request behind it exists, because what this field may say is bounded by the same door the ask is behind.
      examples:
        - id: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          title: Fix flaky ledger test
          repository: /Users/dev/repos/midaz
          status: running
          provider: anthropic
          model: claude-sonnet-4-5
          clientKind: inline
          owner: user_01HZX3Q8N4KDPYV2A7C9M5T6RB
          usage:
            inputTokens: 152000
            outputTokens: 8400
            costUsd: 1.87
          createdAt: 2026-08-08T12:00:00Z
          updatedAt: 2026-08-08T12:34:56Z

    Task:
      type: object
      description: >-
        One item of the agent's task list: what the work is, and where it
        stands.

        Two fields and no more. There is no id, because the list is replaced
        whole on every write and position is the only order there is — an
        identifier would be a second thing two surfaces could disagree about.
        There is no priority, because nothing renders or orders by one.
      required: [content, status]
      properties:
        content:
          type: string
          description: What the task is, in the agent's own words.
        status:
          type: string
          enum: [pending, in_progress, completed, cancelled]
          description: >-
            Where the item stands. `cancelled` counts toward the total and not
            toward what is done — an abandoned step is still a step the list
            once held. The engine does not enforce "exactly one in_progress":
            that is guidance in the tool's own description, and a list holding
            two is drawn honestly rather than refused.
          x-enum-varnames:
            - TaskStatusPending
            - TaskStatusInProgress
            - TaskStatusCompleted
            - TaskStatusCancelled
      examples:
        - content: Reproduce the flaky ledger test
          status: completed

    Posture:
      type: string
      enum: [attended, detached]
      # Named explicitly for the reason ClientKind's are, one schema below.
      x-enum-varnames:
        - PostureAttended
        - PostureDetached
      description: >-
        Whether somebody is there to answer a permission prompt raised in a
        session. attended — a prompt is shown and waited on. detached — nobody
        is watching, so a prompt is refused and recorded with its reason
        rather than blocking the turn forever on a question nothing can
        answer. A session that recorded neither is attended when a terminal
        client (clientKind inline or tui) opened it, or it is a product
        session, and detached otherwise.

        Orthogonal to clientKind, and deliberately not a value of it:
        clientKind is stamped once at creation and never rewritten, whereas a
        session BECOMES detached when somebody says so. It is a recorded fact
        with exactly one transition (setSessionPosture) and is never inferred
        from the presence or absence of a connection — a client that crashed,
        a lid that closed and a pipe that dropped all leave it exactly as it
        was.

    Visibility:
      type: string
      enum: [organisation, private]
      # Named explicitly for the reason Posture's and ClientKind's are.
      x-enum-varnames:
        - VisibilityOrganisation
        - VisibilityPrivate
      description: >-
        Who inside this organisation may read a session. organisation — every member may, which is the default and what every session is until somebody marks it. private — its owner, plus a member holding `sessions:read-private`, plus whoever a share of it names.

        Two values and no third. There is no "unset": the absence of a mark IS the organisation's, so "nobody said" and "the team may read it" are one fact rather than two, and nothing in this product has a use for a visibility somebody would have to define.

    ClientKind:
      type: string
      enum: [inline, tui, oneshot, api, unknown]
      # Named explicitly so these constants keep their names in the package
      # third parties import: left to the generator they are Inline/Tui/…,
      # which any later enum carrying one of those values would silently
      # rename.
      x-enum-varnames:
        - ClientKindInline
        - ClientKindTui
        - ClientKindOneshot
        - ClientKindApi
        - ClientKindUnknown
      description: >-
        Which kind of client opened a session — the coarse classification a client filters and mixes on when it lists sessions it did not itself open. inline — the terminal client (`narya`). tui — the full-screen client (`narya --tui`). oneshot — a one-shot command (`narya -p`). api — a caller of this contract that named no kind; every narya frontend names itself, so this is what speaking the contract directly is. unknown — never recorded, which is every session created before this host carried the datum; a read answers `unknown` rather than picking a kind, and a create request naming it records nothing. It also answers who attends a session that recorded no posture: inline and tui have a person behind them, so a permission prompt is asked; oneshot, api and unknown have nobody, so a prompt is refused with its reason. In a repository nobody has trusted, an api or unknown session's turn is refused naming the trust gesture; a oneshot session runs there under the operator's rules without the repository's own files, whoever created it.

    SessionCreate:
      type: object
      description: >-
        Request to create a session.

        There is no owner field and its absence is the rule rather than an
        omission — the host writes the owner from the identity it
        authenticated the request as.
      properties:
        repository:
          type: string
          maxLength: 4096
          x-go-type-skip-optional-pointer: true
          description: >-
            Absolute path of the repository to work in, required unless
            `clone` is given and refused (422) beside it. With `worktree`
            present this is the checkout the request is ABOUT — the one the
            new worktree is added to — and the session is rooted in that new
            worktree instead, whose path comes back on the response's
            `Session.repository` (relative to the workspace on a hosted home).

            WHICH DIRECTORIES A HOST WILL NAME IS THE HOST'S OWN SETTING and
            never a field on this request. A host running on a developer's own
            machine takes the absolute path as given. A host that names its own
            directories resolves every path in this field under its workspace
            root — so a relative path is resolved there rather than refused —
            and refuses with 422 one that escapes it, including a `worktree`
            whose derived checkout would land outside. The same body is legal
            against both; only the set of nameable directories differs.
        title:
          type: string
          maxLength: 512
          description: >-
            Optional title, supplied by the caller. A session created without
            one is named by the host after its first answered turn on the main
            lane: a 3–6 word title written by the session's own model, charged
            to the session like its turns. A
            `narya -p` run and a detached one are not named, and a title the
            caller gave or set with setSessionTitle is never replaced. Until
            one exists a client renders the session by whatever it knows
            instead — the repository's own name, or the session id.
        agent:
          type: string
          maxLength: 128
          description: >-
            The agent recipe every turn of this session runs under unless the
            message opening it names another; none when absent. Refused 422
            when no recipe by that name may be a session's own agent. It is
            checked against the repository named above before the session or
            its worktree exists, so a recipe only its clone, container,
            environment or new worktree defines is named on a message instead.
            A branch keeps it.
        provider:
          type: string
          minLength: 1
          description: >-
            The provider `model` is resolved within. Only beside `model` (422
            otherwise); both absent is the configured default.
        model:
          type: string
          description: >-
            Model override; the configured default when absent. Named without
            `provider`, it is a reference (`provider/id#level`, an alias or a
            bare id) the host resolves as `narya -p --model` does; named beside
            `provider`, it is resolved within that provider only, as
            setSessionModel resolves it. Either way the host asks the provider
            for its own listing when the catalogue lacks the model, and one
            nothing lists is refused 422 naming it.
        clientKind:
          $ref: '#/components/schemas/ClientKind'
          description: >-
            The kind of client creating this session, stamped on it for the
            rest of its life. `api` when absent — a client that does not name
            itself is a caller of this contract, and that is what gets
            recorded. Naming `unknown` records nothing, which reads back as
            `unknown` exactly like a session that predates the datum.
        environment:
          $ref: '#/components/schemas/EnvironmentId'
          description: >-
            Run this session inside the named environment, which must already exist (createEnvironment). Absent means the host's own machine on a person's own home unless `container` asks for one, and a container of the session's own on a hosted home. The environment holds the code, at its workspace `/workspace`, and runs the commands; `repository` is recorded as given and does not choose where the code is.
        clone:
          type: string
          maxLength: 200
          pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$'
          description: >-
            A repository this organisation selected for Narya's GitHub App, as
            `owner/name`, cloned into the conversation's own container on a
            hosted home before the session exists: the
            container `conversation-<sessionId>`, its own clone, and its own
            branch `narya/conversation-<sessionId>` cut from the default
            branch. Two conversations naming one repository share nothing.

            The clone runs exactly as createEnvironment's `repository` does,
            and is refused the same way, in the same sentence naming the
            connect gesture, with nothing created. After it, the clone's own
            `.narya/setup.sh` runs once inside the container, by `sh`, unless
            `setupScript` is given; a repository without that file runs no
            setup. A clone or a setup that fails opens no conversation: 422
            carrying what it said, and nothing is kept. The create waits for
            both.

            Only on a hosted home and only without `environment` (422
            otherwise): a named environment holds its own repository.

            The clone is the repository: the request names no `repository`,
            and the session's `repository` reads as `owner/name`. Its project
            configuration, rules and styles are read from the clone inside the
            container, never from the host's disk.
        container:
          type: boolean
          description: >-
            Open the conversation in a container of its own before the session exists: `conversation-<sessionId>`, made from this home's sandbox image with nothing cloned into it, its workspace the session's tree. On a person's own home this is how a client asks for one, so instructions nobody vouches for run where the agent cannot reach the home, its token or its socket; a hosted home makes one for every session that names no environment, and true there makes it now rather than at the first turn. A home whose host names no sandbox image (NARYA_SANDBOX_IMAGE) refuses it with 422 saying so, and so does a body that also names `environment` or `worktree`. Absent or false is the session's ordinary machine.
        setupScript:
          type: string
          minLength: 1
          maxLength: 65536
          description: >-
            Run once by `sh` in the conversation's container in place of the
            repository's own `.narya/setup.sh`. Only with `clone` (422
            otherwise).
        style:
          type: string
          maxLength: 64
          description: >-
            Open the session in this named response style, so its very first
            turn is already shaped by it rather than a round trip later. One of
            the names configuration declares — the person's own `[styles.*]`,
            or those of the repository named above once its directory is
            trusted; any other is refused by name with the file that would
            declare it, before the session exists. A session opening in an
            `environment` or a `clone` chooses from the person's own alone:
            its clone's styles are chosen with setSessionStyle once it exists.
            Absent is
            narya's own voice, which is every session until somebody chooses.
        worktree:
          $ref: '#/components/schemas/SessionWorktree'
          description: >-
            Add a git worktree to `repository` first and root the session in THAT checkout instead. Absent means the session opens in `repository` itself, which is what a client that never sends it keeps getting. A hosted home refuses it (422): the checkout would be cut on the host's disk, where the session never works.
        visibility:
          $ref: '#/components/schemas/Visibility'
          description: >-
            Open the session already marked, so a conversation that was never
            meant for the team is not readable by it for the seconds between
            being created and being marked. Absent means `organisation`, which
            is what every session is until somebody says otherwise, and there
            is no third state to ask for.
        product:
          type: string
          minLength: 1
          maxLength: 64
          description: >-
            Open a product session over the Lerian product a `[tool_trees.<name>]` table marked `product = true` names: its tools are the operations that product's people call, called with the bearer of the request that starts each turn. A write (any method but GET) is never sent when the model calls it: it is stored as a pending permission ask holding the exact call, the turn ends, and only its owner's decidePermission sends it. The session holds one such item, a write or a question, at a time; a message closes a pending write as declined. It works in no repository and no container, so `repository`, `clone`, `container`, `environment`, `worktree` or `setupScript` beside it, and a name no product tree declares, are refused 422 before anything else is checked. It opens private and stays its owner's alone: any other member, a holder of sessions:read-private included, gets 404 as for an unknown id, and `visibility: organisation` is refused 422. Publishing, marking, branching or rewinding it answers 422.
      examples:
        - repository: /Users/dev/repos/midaz
          title: Fix flaky ledger test
          clientKind: tui

    SessionWorktree:
      type: object
      description: >-
        Where a session is BORN when it is not born in a directory that
        already exists: narya adds a git worktree to the repository
        the request names, on a NEW branch cut from `base`, and the session
        opens in the checkout that created.

        WHERE THE NEW CHECKOUT GOES IS DERIVED AND IS NOT AN INPUT. It is a
        sibling of the repository named, suffixed with the branch, with every
        path separator in the branch flattened to a dash — so branch `feat/x`
        on `/srv/code/api` becomes `/srv/code/api-feat-x`, and a slashed
        branch can never produce a nested tree. The absolute path git actually
        wrote comes back on `Session.repository`, and that is the only answer
        to "which directory is this session working in": nobody supplied it,
        so nothing else can be asked for it.

        Everything git refuses is refused in git's own words, with 422: a
        branch that already exists, a destination directory that exists and is
        not empty, a base nothing resolves, and a repository that is not a
        checkout. Trust is asked about the new directory exactly as about any
        other, and an untrusted one opens normally and runs untrusted — the
        clause createSession already states.

        A birth refused AFTER git wrote the checkout — a worktree whose own
        `.narya/config.toml` will not load — leaves that checkout on disk. The
        branch and the files are real; once the configuration loads, a session
        opens on the directory the ordinary way.
      required: [branch]
      properties:
        branch:
          type: string
          description: >-
            The branch to create. It must NOT already exist: this creates a
            branch rather than checking one out, so a session born here has
            somewhere of its own to commit. Its shape is git's business and
            any name git rejects is refused carrying git's own sentence —
            with one exception, which the host refuses itself, in its OWN
            words, before git runs at all: a name that is empty or begins
            with a dash. `git worktree add -b` hands the name to an inner
            `git branch` with no separator in front of it, so a dashed name
            is read there as an option and acts on the BASE ref instead of
            creating anything; an empty one derives a checkout directory
            named after nothing. Both are refused with 422, before git's
            parser can reach them.
        base:
          type: string
          description: >-
            The ref the new branch starts at. `HEAD` when absent, which is
            what a caller who just wants another checkout of this work needs.
      examples:
        - branch: feat/settlement
          base: main

    EnvironmentId:
      type: string
      minLength: 1
      maxLength: 64
      pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$'
      description: >-
        An environment's identifier, chosen by whoever creates it and unique
        on this host. It travels as one path segment, so its shape is bounded
        to what a path segment carries unchanged, exactly as a monitor's name
        is: a "/" would address no route and a "?" or "#" would end the
        segment early and address something else. One beginning
        `conversation-` is a hosted home's own, and a create naming it is 422.

    EnvironmentState:
      type: string
      enum: [ready, degraded, gone, unusable]
      # Named explicitly for the reason ClientKind states: left to the
      # generator these are Ready/Degraded/…, which any later enum carrying
      # one of those values would silently rename in the package third
      # parties import.
      x-enum-varnames:
        - EnvironmentStateReady
        - EnvironmentStateDegraded
        - EnvironmentStateGone
        - EnvironmentStateUnusable
      description: >-
        Whether an environment is answering, and — when it is not — whether waiting is the right move. The distinction is the whole point: a session waiting on an unreachable environment looks identical to a slow one otherwise, and what the person does next depends entirely on which it is. ready — answering; work proceeds. degraded — not answering right now and expected back: a restart or a dropped connection, which the host retries so the next call may simply succeed, or a container stopped outside narya, which the next turn starts. gone — destroyed, its name taken by another environment since, or the runtime no longer has it. A hosted home also removes a container nothing has used for 15 minutes and keeps its disks, the workspace and its restore points, and so does a person's own home for a conversation's own `conversation-<sessionId>`; it reads gone until a turn needs it. A session bound to it is then given a new container on its next turn, over the disks the old one left when those are still there, and is told which; on a person's own home a named environment that is gone needs a new one. unusable — its setup did not succeed; `setup` says what happened.

    Environment:
      type: object
      description: >-
        A place a session's code lives and its commands run. The
        host's own machine is not one of these — it is what a session gets by
        naming none.
      required: [id, state, image, createdAt]
      properties:
        id:
          $ref: '#/components/schemas/EnvironmentId'
        state:
          $ref: '#/components/schemas/EnvironmentState'
        image:
          type: string
          maxLength: 512
          description: >-
            The immutable digest of the image this environment actually runs, pinned when it was created and never floated afterwards. Reconnecting after a host restart, and replacing an environment that was lost, both ask for this digest again, so an environment that worked yesterday is the same environment today. There is one official image, which is why nothing supplies this on creation.
        setup:
          $ref: '#/components/schemas/EnvironmentSetup'
          description: >-
            What the setup script did, absent when none was supplied.
        createdAt:
          type: string
          format: date-time
      examples:
        - id: build-box
          state: ready
          image: ghcr.io/lerianstudio/narya-sandbox@sha256:3f7a1c2b9d4e5f60718293a4b5c6d7e8f9012345678901234567890abcdefabcd
          setup:
            outcome: succeeded
            output: |
              + go version
              go version go1.26.0 linux/amd64
          createdAt: 2026-08-29T09:12:00Z

    EnvironmentSetup:
      type: object
      description: >-
        The outcome of the one setup script an environment runs at creation. It exists so a script that failed is visible now instead of
        discoverable at the first build the model cannot explain.
      required: [outcome]
      properties:
        outcome:
          type: string
          enum: [succeeded, failed, timedOut]
          x-enum-varnames:
            - EnvironmentSetupOutcomeSucceeded
            - EnvironmentSetupOutcomeFailed
            - EnvironmentSetupOutcomeTimedOut
          description: >-
            succeeded — the script exited zero and the environment is usable.
            failed — it exited non-zero; the environment is `unusable` and
            `output` is the cause. timedOut — it ran past the host's bound and
            was killed, which is reported as itself rather than as a hang,
            because a script waiting on a prompt and a script doing slow work
            are indistinguishable from the outside and only one of them is
            worth waiting for.
        output:
          type: string
          maxLength: 65536
          description: >-
            What the script wrote, both streams interleaved as they arrived, trimmed to this bound, keeping its end: the last thing a failing script said is the part that names the failure.

    EnvironmentCreate:
      type: object
      description: >-
        Request to provision an environment. No image is named: there is one official image, and the customer's own tooling arrives as the script below, on top of it.
      required: [id]
      properties:
        id:
          $ref: '#/components/schemas/EnvironmentId'
        setupScript:
          type: string
          maxLength: 65536
          description: >-
            A script run once inside the new environment, after the image is
            up and before any session uses it — where a customer's own
            toolchain arrives. It runs bounded in time; a non-zero exit or a
            run past the bound leaves the environment `unusable` with this
            script's own output as the cause, rather than an environment that
            looks healthy and cannot build anything.
        repository:
          type: string
          maxLength: 200
          pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$'
          description: >-
            A repository this organisation selected when it installed Narya's
            GitHub App, as `owner/name`. The host clones it into the
            environment's workspace BEFORE the environment's own machine
            exists, so the machine holds the code and never the credential
            that fetched it: the clone runs in a throwaway Lerian
            container over the same workspace, as the same unprivileged user
            the environment runs as, and the short-lived installation token
            lives only for that one command. The clone happens before the
            setup script, so a script can build what was just cloned.

            A repository that is not in the installed set, an organisation
            that has connected no installation, and an installation its owner
            uninstalled or suspended on GitHub, are refused before any machine
            is made, and the refusal names the connect gesture. A clone that fails leaves the environment `unusable`
            with the cause as its setup output, exactly as a failed setup
            script does. Omit it for an environment that starts empty.
      examples:
        - id: build-box
          repository: acme/widgets
          setupScript: |
            set -eux
            apt-get update && apt-get install -y --no-install-recommends make

    EnvironmentList:
      type: object
      description: The environments, in creation order.
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Environment'

    GitHubRepository:
      type: object
      description: >-
        One repository the customer selected on GitHub when they installed the
        Narya GitHub App. Every field is GitHub's own answer rather than
        anything narya assembled: a repository that moved, was renamed, or
        lives on an enterprise host is still clonable from what it reports.
      required: [fullName, cloneUrl]
      properties:
        fullName:
          type: string
          maxLength: 512
          description: >-
            `owner/name`, which is how a person names a repository and how
            createEnvironment carries one.
        cloneUrl:
          type: string
          maxLength: 2048
          description: GitHub's own URL for cloning it.
        private:
          type: boolean
          description: >-
            Whether GitHub calls it private. Carried because a person picking
            one repository out of forty recognises their own by it, and
            because a public and a private fork of one name would otherwise
            be two identical rows to pick the wrong one from.
        defaultBranch:
          type: string
          maxLength: 255
          description: >-
            The branch a clone lands on, as GitHub reports it. Never assumed
            to be `main`, which is wrong on every repository older than 2020
            and on every one whose owner renamed it.
      examples:
        - fullName: acme/ledger
          cloneUrl: https://github.com/acme/ledger.git
          private: true
          defaultBranch: main

    GitHubRepositoryPage:
      type: object
      description: >-
        One page of the repositories this organisation's GitHub installation
        carries, plus whatever standing fact a person needs to read beside
        them.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/GitHubRepository'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        notices:
          type: array
          description: >-
            Standing facts about this listing in the words a person reads — the sentence that says this organisation has connected no GitHub, or that its installation was uninstalled or suspended there, and names the gesture that fixes it. Scoped to this listing, the way SkillPage.notices is scoped to that one, and repeated on every request because they are conditions rather than events.
          items:
            type: string
      examples:
        - items:
            - fullName: acme/ledger
              cloneUrl: https://github.com/acme/ledger.git
              private: true
              defaultBranch: main
          limit: 25

    SessionEnvironment:
      type: object
      description: >-
        A session's binding to an environment, as a client draws it beside the session's identity. The id and the state, and deliberately nothing else: everything a person acts on is here, which keeps a page of twenty-five sessions from carrying twenty-five setup transcripts.
      required: [id, state]
      properties:
        id:
          $ref: '#/components/schemas/EnvironmentId'
        state:
          $ref: '#/components/schemas/EnvironmentState'

    RepositoryDraft:
      type: object
      description: >-
        One person's saved composer draft for a repository — the host's own
        record of text no client has submitted yet, shared by every session
        and every window that person opens against that repository.
      required: [text]
      properties:
        text:
          type: string
          maxLength: 8388608
          description: >-
            The draft text. A `@path:N-M` line-range mention travels
            unchanged; BOTH marker kinds a composer can hold — a paste chip
            and an attached file — are expanded to what they stand for
            before the client ever saves it here, so a stored draft never
            carries a marker with nothing behind it, since the
            registry that gives either marker meaning is client-local and
            does not survive a restart — which is also why the bound is
            8 MiB: a single ~2,000-line pasted file already expands past 64
            KiB, and a save over the bound is rejected unseen (this is a
            fire-and-forget best-effort save, so the client never learns the
            write was rejected), costing the whole draft, not just the paste.

    RepositoryTrust:
      type: object
      description: >-
        A repository's stored trust answer, with everything that answer
        governs: the instruction files that would reach the model, the
        repository's own command and skill directories, which the same
        answer loads and whose shell blocks it lets run, and its own
        `.narya/config.toml`, whose permission rules and hooks the same
        answer loads.
      required: [repository, decision, instructionFiles, instructionRoot, resourceDirs]
      properties:
        repository:
          type: string
          maxLength: 4096
          description: >-
            The canonical repository path the answer is stored under —
            absolute, with symlinks resolved, which may differ from the
            path the request named. On a hosted home this,
            `instructionRoot`, `inheritedFrom` and `parent` are relative to
            its workspace, and the walk to `instructionRoot` stops at the
            workspace root.
        instructionRoot:
          type: string
          maxLength: 4096
          description: >-
            The directory `instructionFiles` are named relative to: the
            nearest enclosing git repository root at or above `repository`,
            or `repository` itself when it sits inside no repository. It
            differs from `repository` whenever a session was opened in a
            subdirectory of a checkout — a component of a monorepo — and a
            surface that asks somebody to approve these files must name
            this directory, because it is where they will actually be read
            from. The answer is still stored under `repository` alone.
        decision:
          type: string
          enum: [unknown, trusted, refused]
          description: >-
            The EFFECTIVE answer for this directory: its own stored answer,
            or, when it has none, the nearest one stored above it —
            `inheritedFrom` says which. `unknown` when nobody has
            been asked for this directory or any ancestor, which is what
            makes an interactive client ask. `trusted` lets the instruction
            files below into the system prompt, loads the repository's own
            commands, skills, agent recipes and `.narya/config.toml`, and
            lifts the permission posture out of untrusted mode; `refused`
            keeps all of it out and stops the asking.
        inheritedFrom:
          type: string
          maxLength: 4096
          description: >-
            The ancestor directory `decision` came from, when it did not
            come from `repository` itself. Absent means the answer is this
            directory's own, or that there is none. A client uses it to say
            WHY it is not asking — a worktree under a trusted parent is
            trusted without anybody being questioned about it.
        parent:
          type: string
          maxLength: 4096
          description: >-
            The immediate parent of `repository`, which is the only other
            directory a client may offer to trust ("trust the parent folder" — the one that covers every sibling
            worktree cut later). The IMMEDIATE parent and never a
            grandparent, so nobody trusts their whole machine by misreading
            a dialogue. Absent when there is no such directory to offer:
            when `repository` IS the filesystem root, and when its parent
            WOULD BE the filesystem root — `/workspace`, `/app`, `/srv`, the
            usual repository mount in a container. A `/` row trusts every
            directory on the machine forever, so it is never one keypress
            away; a user who wants one writes it into the store by hand.
        instructionFiles:
          type: array
          description: >-
            The repository files the loader DISCOVERS by name, in prompt
            order, relative to the repository root, that would reach the
            model if this repository were trusted. Only files present on
            disk right now. An empty array does not mean nothing would
            reach the model: `resourceDirs` is governed by the same answer,
            and `projectConfig` can NAME further repository files that this
            array does not carry.
          items:
            type: string
            maxLength: 512
        resourceDirs:
          type: array
          description: >-
            The repository's own command, skill and agent directories, its `.mcp.json` and its Claude Code settings file, present on disk right now, relative to `instructionRoot`, in resolution order. The SAME answer governs them, and it grants more there than it grants over instruction files: a command file under `.claude/commands` becomes typeable, and its `!`cmd`` blocks run a shell on this machine, in this repository's directory, the instant somebody types the command's name; an agent recipe under `.claude/agents` supplies the system prompt, the tool allowlist and the model a delegated turn runs under; and `.claude/settings.json`'s hook table runs shell with no trigger at all — once when the session starts, and again before every tool call, model request and tool result. `.mcp.json` declares tool servers the answer starts on this machine with no trigger, and the tool descriptions they publish reach the model. A surface asking for the answer must name these too, and must ask about a repository that carries only these — no instruction files at all — because otherwise its commands, skills, recipes and hooks never load in any session.
          items:
            type: string
            maxLength: 512
        projectConfig:
          type: string
          maxLength: 512
          description: >-
            The repository's own `.narya/config.toml`, when it is present on
            disk right now — the fourth thing this one answer settles, and
            the one a client is most likely to miss. It declares this
            repository's permission rules and its hook table, and a hook
            runs shell with no trigger at all, so it carries the weight of
            `.claude/settings.json` rather than of an instruction file.
            Its own text never reaches the model: its rules are evaluated
            and its hooks run, and neither the file nor what a hook prints
            enters a prompt. What it can do is NAME text that does — its
            `instruction_files` key lists repository paths whose contents
            are wrapped into every turn's prompt once this repository is
            trusted — and those names appear neither here nor in
            `instructionFiles`, which carries the discovered files alone.
            So a surface asking for the answer must name this file, and
            must ask about a repository that carries only this — no
            discovered instruction files, no resource directories —
            because otherwise its rules, its hooks and the files it names
            never load in any session and the only signal is one warning
            on the first gated effect.

            Relative to `repository`, NOT to `instructionRoot`, which is the
            one entry on this response that differs: a component of a
            monorepo loads its own `.narya/config.toml` rather than the one
            at the checkout root (the answer is keyed by `repository`), and
            a consent surface naming a file its loader will not read is the
            one lie it cannot afford. Absent means this repository declares
            nothing of its own.
      examples:
        - repository: /Users/dev/repos/midaz/components/ledger
          instructionRoot: /Users/dev/repos/midaz
          decision: unknown
          instructionFiles: [AGENTS.md, docs/PROJECT_RULES.md]
          resourceDirs: [.claude/commands, .claude/skills, .claude/agents, .claude/settings.json]
          projectConfig: .narya/config.toml

    RepositoryTrustAnswer:
      type: object
      description: The answer a person gave for a repository.
      required: [decision]
      properties:
        decision:
          type: string
          enum: [trusted, refused]
          description: >-
            `unknown` is deliberately absent: it is the absence of an
            answer, not one somebody can give. So is "continue untrusted",
            and for the opposite reason: it is a choice
            about THIS session that is deliberately not written down, so the
            next session asks again. A client that takes it simply does not
            call this operation.
        scope:
          type: string
          enum: [directory, parent]
          default: directory
          description: >-
            Which directory the answer is stored for. `directory` is the one
            named in `repository`. `parent` stores it for that directory's
            IMMEDIATE parent — the answer that covers every
            sibling worktree cut under it later — and ALSO for `repository`
            itself whenever the parent's answer would not reach it, which is
            when that directory carries a contrary answer of its own:
            nearest wins, so the parent's row alone would have left a
            refusal standing under a prompt whose own key line says the
            repository's material loads. A directory with no contrary answer
            gets no row of its own, so one answer still covers a whole tree.
            The server computes the
            parent rather than trusting a caller to name an ancestor, so a
            client cannot widen an answer past the one it offered, and it
            refuses `parent` with 400 wherever the GET reported none.
            The filesystem root is refused SEPARATELY and for every scope,
            the omitted one included, so naming `/` as the `repository`
            itself is a 400 too — a client that builds the path by joining
            and lets a component escape lands there without any dialogue.
            One `/` row answers `trusted` for every directory on the machine,
            permanently, with nothing that shows or revokes it; writing one
            by hand into the store stays the only route to it.
      examples:
        - decision: trusted
        - decision: trusted
          scope: parent

    SessionBranch:
      type: object
      description: Request to branch a session at an earlier entry.
      required: [entryId]
      properties:
        entryId:
          type: string
          format: uuid
          description: The transcript entry to branch at. The new session continues from this point.
        title:
          type: string
          maxLength: 512
          description: Optional title for the new session.
      examples:
        - entryId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
          title: Retry with a different approach

    SessionRewind:
      type: object
      description: >-
        Where to go back to, and what to take back. Both fields are required:
        there is no default scope, and a request without one is refused rather
        than guessed at.
      required: [entryId, scope]
      properties:
        entryId:
          type: string
          format: uuid
          description: >-
            The transcript entry to go back to. For a conversation rewind the
            new branch continues from this point. For a code rewind the files
            return to the restore point standing at this entry, or to the
            nearest one before it.


            Required in every request, and IGNORED in exactly one: a code
            rewind that sets partEntryId takes its restore point from that
            step instead, because a step already says which moment it undoes.
            It stays required rather than becoming conditional so that one
            field always names the moment a rewind is about, whatever else
            narrows it.
        scope:
          type: string
          enum: [code, conversation, both]
          description: >-
            What the rewind takes back. code restores the working tree and leaves the conversation exactly where it is — carried WHOLE into the branch it continues in, which stands at the tip rather than at the chosen entry. conversation branches at the entry and leaves every file untouched. both does the two together, so the new branch continues against the restored tree. Deliberately without a default: guessing wrong in either direction breaks something the other direction would have preserved.
        title:
          type: string
          maxLength: 512
          description: >-
            Optional title for the new branch. Meaningless only for a
            narrowed revert — paths or partEntryId — which is the one shape
            that creates no session.
        paths:
          type: array
          minItems: 1
          maxItems: 512
          description: >-
            Narrows the restore to exactly these files and leaves every other one alone: reverting a whole step is often too much, because the agent got four files right and one wrong. Absolute paths inside the session's repository; one that resolves outside it is refused and nothing is written. Each path names a FILE: one naming a directory is refused too, because restoring a directory would put back everything under it and remove whatever this session added there while reporting the one path that was sent. Name the files inside it, or revert the whole step with partEntryId instead. Meaningful only with scope=code — any other scope with this set is refused, because there is no honest reading of "branch the conversation, but only these files". A whole-tree restore point is still taken first, so the undo of a one-file revert is the same mechanism as the undo of everything else. An EMPTY list is refused, never a whole-tree restore: leave the field out to revert everything, because a selection that filtered to nothing must not overwrite the files on disk.
          items:
            type: string
            maxLength: 4096
        partEntryId:
          type: string
          format: uuid
          description: >-
            Reverts one transcript entry's worth of file effects as a unit:
            the files that entry wrote, edited or deleted go back to how they
            stood immediately before it, and nothing else moves. A file the
            entry only READ is not touched — reverting a read would put back
            a file the agent merely looked at. Meaningful only with
            scope=code, and never together with paths, which would be two
            answers to one question.
      examples:
        - entryId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
          scope: both
          title: Before the refactor went wrong
        - entryId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
          scope: code
          paths: [/home/dev/ledger/internal/api/server.go]

    EntryLabel:
      type: object
      description: >-
        The tag to put on one transcript entry. An empty string clears it —
        setting and clearing are one write, because from where a person stands
        renaming a marker and removing it are the same gesture.
      required: [label]
      properties:
        label:
          type: string
          maxLength: 128
          description: >-
            A short tag a person types to recognise this moment on sight.
            Empty clears whatever was there.
      examples:
        - label: the version that worked

    SessionTree:
      type: object
      description: >-
        One whole branched family: every session reachable from the one asked
        about through branch provenance, root first, parents always listed
        before the branches that came off them.
      required: [rootSessionId, nodes]
      properties:
        rootSessionId:
          type: string
          format: uuid
          description: >-
            The family's root — the session nothing in this family branched
            from. It names which family was searched even when a label filter
            left no nodes at all.
        nodes:
          type: array
          description: >-
            The family's sessions, root first. Empty only when a label filter
            matched nothing.
          items:
            $ref: '#/components/schemas/SessionTreeNode'

    SessionTreeNode:
      type: object
      description: >-
        One session inside a branched family, with what it takes to recognise
        it without opening it.
      required: [sessionId, status, entryCount, createdAt, updatedAt]
      properties:
        sessionId:
          type: string
          format: uuid
        parentSessionId:
          type: string
          format: uuid
          description: >-
            The session this one branched from. Absent on a root — including
            a branch whose origin was purged, which becomes a root of its own
            rather than being taken away with it.
        branchedFromEntryId:
          type: string
          format: uuid
          description: >-
            The entry this session branched at. It survives the origin being
            purged, so a branch can still say WHERE it came off even when it
            can no longer say what from.
        branchedFromSeq:
          type: integer
          format: int64
          minimum: 1
          description: >-
            That entry's position in the parent's transcript — the readable
            form of the branch point, since the id above is 36 characters
            nobody can place. Read against the parent node's entryCount it
            says how much of the parent this branch left behind: "branched at
            6 of 14".

            This is what indentation cannot say. Indentation says WHICH session
            a branch came off; without a position, two rewinds off the same
            parent — one at its second message, one at its eighth — are
            siblings drawn identically, and every rewind is a branch,
            so "how far back did this one go" is the fact somebody opens the
            tree to decide on.

            Absent when the parent was purged: the id outlives that, the entry
            it names does not, so there is no transcript left to count it in.
        title:
          type: string
          maxLength: 512
        status:
          type: string
          enum: [idle, running, waiting, archived]
        entryCount:
          type: integer
          description: >-
            How many transcript entries this session holds — every lane of it,
            deliberately unlike the excerpts below, which are the main lane's
            alone. This answers how long the session is, and a delegation's
            rows are as much part of what it holds as the conversation's own.
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        firstMessageExcerpt:
          type: string
          maxLength: 512
          description: >-
            The first thing said in this session's main lane, as one cleaned
            display line. A message — never a tool result, never a note about
            the session itself, and never a delegation's own track. Absent when
            nobody has said anything here yet.

            Every session of a family carries the SAME first line: branching
            copies the transcript from the start of the lane, so this
            discriminates nothing between siblings and narya's own clients draw
            lastMessageExcerpt instead. It is served for a client that wants
            the family's opening question, not as the way a branch is
            recognised.
        lastMessageExcerpt:
          type: string
          maxLength: 512
          description: >-
            The last thing said in this session's main lane, as one cleaned
            display line, and a message on the same terms as the first. This is
            the excerpt that differs between siblings, so it is the one a tree
            row draws: no summary is ever produced because a branch was
            abandoned, so a shelf of retired branches has to be told apart by
            what was last said in them, and by branchedFromSeq, rather than by
            the epitaph retiring them wrote.
        summary:
          type: string
          maxLength: 4096
          description: >-
            The summary somebody asked for (summarizeSession), trimmed to this
            length with a marker when the model answered past it. Absent until
            they do: nothing summarises a branch because a rewind happened.
        summaryAt:
          type: string
          format: date-time
          description: >-
            When that summary was taken, so a node can say its summary
            predates the last thing that happened in it.
        labels:
          type: array
          description: >-
            Every label set on this session's entries, in transcript order.
            This is what the label filter matches.
          items:
            type: string
            maxLength: 128
      examples:
        - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          parentSessionId: 3a1c5e7f-9b2d-4e6a-8c0f-1d3b5a7c9e2f
          branchedFromEntryId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
          branchedFromSeq: 6
          title: Retry with a different approach
          status: idle
          entryCount: 12
          firstMessageExcerpt: where does the ledger validate an asset code?
          lastMessageExcerpt: it validates it in validate.go, line 88
          labels: [the version that worked]

    SessionModel:
      type: object
      description: >-
        The model to point a session at. Both fields are required: a bare
        model id is a client-side convenience, never a wire shape, so this
        operation never has to guess which provider was meant.
      required: [provider, model]
      properties:
        provider:
          type: string
          maxLength: 128
          description: The provider serving the model, as the catalogue names it.
        model:
          type: string
          maxLength: 256
          description: The model's id, as the catalogue names it.
        variant:
          type: string
          maxLength: 64
          description: >-
            The preset of request options the chosen model is to run under —
            one of the names `Model.variants` publishes for it, which includes
            the reasoning-effort levels every model whose family carries that
            knob offers for free. Absent means the model's own defaults, so a
            switch that omits it clears whatever the session was running at.
            A name the chosen model does not offer is refused by name, with
            the ones it does offer, rather than ignored: a level silently
            dropped is a week of turns at a setting somebody believes they
            chose.
        sessionOnly:
          type: boolean
          description: >-
            True moves this session alone: the caller's default and their
            recently chosen models stay as they were. `narya -p --model` sends
            it, because a model named for one run is not a choice of default.
      examples:
        - provider: anthropic
          model: claude-sonnet-5
        - provider: anthropic
          model: claude-opus-5
          variant: max

    SessionTitle:
      type: object
      description: >-
        A session's new name. The same bound SessionCreate's own title carries,
        because it is the same field: a rename is not a second kind of title.
      required: [title]
      properties:
        title:
          type: string
          maxLength: 512
          description: >-
            The new title. Whitespace only is refused rather than stored — see
            setSessionTitle.
      examples:
        - title: Fix flaky ledger test

    Warming:
      type: object
      description: >-
        One session's standing instruction about its provider cache while
        nobody is using it: whether to pay to hold the cached prefix past the
        five minutes a provider keeps it for free, and the lifetime dollars
        this session may spend doing so.

        Both fields travel together because they are one decision. Turning
        warming on without saying what it may cost is not a decision anybody
        makes, and there is no default narya could supply that is not a guess
        about somebody else's bill.

        The ceiling is a LIFETIME figure for this session, not a daily one: a
        session is a bounded thing with an end, and "this conversation may
        cost me at most fifty cents to keep warm" is the sentence a person
        means. A second, separate ceiling bounds the whole store's spending
        per UTC day and is operator configuration rather than part of any
        session — either one being reached stops warming rather than warning
        about it.
      required: [enabled, ceilingUsd]
      properties:
        enabled:
          type: boolean
          description: >-
            Whether this session is warmed. False with a ceiling recorded is a
            session somebody turned off, which keeps the number they chose for
            the day they turn it back on.
        ceilingUsd:
          type: number
          format: double
          minimum: 0
          description: >-
            The most this session may ever spend on warming, in US dollars,
            counted across its whole life. Reaching it stops warming this
            session and nothing else; every other session keeps its own
            allowance. Zero is a legitimate value and means the same as off.
      examples:
        - enabled: true
          ceilingUsd: 0.5

    CacheSpend:
      type: object
      description: >-
        What this session's provider prompt cache has cost it, read from both
        sides of one decision: what letting the cached prefix
        expire cost, and what refusing to let it expire cost.

        The two numbers are reported together because they are the same
        decision seen from either end. Somebody weighing whether to turn
        warming on for this session is comparing exactly these figures, and
        either one alone answers half the question.

        The headline is the re-billed prompt: rounds that paid full input
        rates to send again a prefix the provider had been holding, because
        the session sat idle past the cache's lifetime. A duplicated cache
        WRITE is a real event too and is deliberately not reported here — it
        is an internal metric, not what a person reads on a bill.

        Absent on a session where neither has ever happened, which is every
        session until one of them does. Never a zero-filled object standing
        in for "nothing yet".
      required: [rebilledRounds, rebilledTokens, rebilledUsd, warmingUsd]
      properties:
        rebilledRounds:
          type: integer
          minimum: 0
          description: >-
            How many rounds paid to re-send an expired prefix. Zero here with
            a warming figure present is a session that has been kept warm and
            has therefore never paid for its own idleness — which is warming
            working.
        rebilledTokens:
          type: integer
          minimum: 0
          description: >-
            The prefix re-sent across those rounds: the part of each round's
            cache write that had genuinely been cached before, never the whole
            write. A resumed conversation also writes the message that ended
            the wait, and that content would have been written with the prefix
            alive too — charging it to the expiry would blame idleness for the
            weight of the message that ended it.
        rebilledUsd:
          type: number
          format: double
          minimum: 0
          description: >-
            What that expiry cost, in US dollars: for each round, the
            difference between what its prompt was billed and what the same
            prompt would have cost with the prefix still cached.

            It DECOMPOSES money already reported in `usage.costUsd` rather
            than adding to it. A re-billed round's whole price is in the
            session total like every other round's, and this says how much of
            that price the expiry was; adding the two together counts the same
            dollars twice.
        rebilledUsdComplete:
          type: boolean
          description: >-
            Absent means true. Present and false makes `rebilledUsd` a FLOOR —
            "at least this much" — because at least one re-billed round ran on
            a model this host cannot price, contributing a real expiry and no
            dollars. The same honesty `costComplete` carries on a usage
            report, for the same reason: a total that quietly skipped the
            rounds it could not price is a number a reader will believe.
        warmingUsd:
          type: number
          format: double
          minimum: 0
          description: >-
            What this session has spent keeping its cache alive, in US
            dollars, counted across its whole life and against the ceiling in
            `warming.ceilingUsd`. Reservations are included: a warm that was
            paid for and whose outcome was never established is money that may
            already be gone, and leaving it out would let it be spent twice.
      examples:
        - rebilledRounds: 4
          rebilledTokens: 18240
          rebilledUsd: 0.0312
          warmingUsd: 0.045

    SessionPosture:
      type: object
      description: >-
        The posture to record on a session. One field, required: there is no
        "toggle" and no default, because a request that did not say which
        posture it meant is a request that must be answered rather than
        guessed for — and the permissive guess is the one that blocks a turn
        on a prompt nothing can answer.
      required: [posture]
      properties:
        posture:
          $ref: '#/components/schemas/Posture'
      examples:
        - posture: detached

    SessionAllowList:
      type: object
      description: The allow rules to hold for one session, in the order they apply.
      required: [rules]
      properties:
        rules:
          type: array
          items:
            $ref: '#/components/schemas/SessionAllow'
      examples:
        - rules:
            - action: shell
              resource: 'make deploy'
            - action: network
              resource: docs.rs

    SessionAllow:
      type: object
      description: One allow rule, as a permissions.toml line states it.
      required: [action, resource]
      properties:
        action:
          type: string
          maxLength: 128
          description: shell, read, write, network or tool.
        resource:
          type: string
          maxLength: 4096
          description: >-
            What the rule matches: a command for shell, a path glob for read
            and write, a host for network, a tool name for tool. A trailing *
            is a prefix.

    SessionStyle:
      type: object
      description: >-
        The response style to put a session into. One field, required: there is
        no "toggle", because a style is chosen by name and the way back is the
        empty string rather than the absence of a field. Send the empty string
        to CLEAR — the session goes back to narya's own voice and the stored
        style becomes absent again, indistinguishable from a session that never
        chose one.
      required: [style]
      properties:
        style:
          type: string
          maxLength: 64
          description: >-
            One of the names configuration declares for this session's
            repository, or the empty string to clear. Any other name is
            refused with 422 naming the file that would declare it, and the
            session keeps whatever style it had.
      examples:
        - style: terse
        - style: ''

    SessionVisibility:
      type: object
      description: >-
        The visibility to put a session under. One field, required: an omitted
        field is refused rather than read as "hand it back to the
        organisation", because a request that did not say which visibility it
        meant has to be answered rather than guessed for — and the permissive
        guess would take the mark off a conversation somebody held back.
        SessionPosture's rule and its reason.
      required: [visibility]
      properties:
        visibility:
          $ref: '#/components/schemas/Visibility'
      examples:
        - visibility: private

    SessionRepository:
      type: object
      description: >-
        The directory a session should work in instead. One field, required:
        a request that did not say where it meant is a request to be answered
        rather than guessed for.
      required: [repository]
      properties:
        repository:
          type: string
          maxLength: 4096
          description: >-
            Absolute path of the directory to work in, which must exist and be
            a directory. Stored in its canonical form, because that one string
            is both the root the next turn's tools are confined to and the key
            the trust answer is keyed on — a session vouched for under one
            spelling and working under another is the state this refuses to
            represent.
      examples:
        - repository: /srv/code/api-feat-settlement

    MessageCreate:
      type: object
      description: >-
        A user message submitted into a session or lane. Posted while work is
        running it is a steering message, queued and delivered between tool
        batches without discarding work already done.
      required: [content]
      properties:
        content:
          type: string
          minLength: 1
          description: The message text.
        attachments:
          type: array
          maxItems: 32
          description: Absolute paths of files to attach.
          items:
            type: string
            maxLength: 4096
        queueKind:
          type: string
          enum: [steer, follow-up]
          default: steer
          description: >-
            While a turn is running, steer delivers after a complete tool batch and follow-up starts a fresh turn after turn-finished. During a final stream with no subsequent tool batch, steer is durably converted to follow-up with the same admitted entry and turn id.
        agent:
          type: string
          pattern: '^[^\s/\\]+$'
          maxLength: 128
          description: >-
            Named agent recipe this message runs under: its prompt replaces
            the system prompt for the turn, its model overrides the
            session's, and its tool allowlist is the only catalogue the turn
            can see. The session's own behaviour when absent. Recorded on
            the admitted message, so the transcript says which agent each
            turn ran under — there is no session-wide current agent.
            Ignored on a side lane, exactly as LaneCreate ignores it there:
            a side conversation always runs the host's read-only explorer,
            and that is not the caller's to change on any road.
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            A key the client chooses for this one message, so a send whose
            answer was lost can be repeated safely. A message carrying a key
            its session already holds admits nothing and starts nothing: it is
            answered as the first one was, 202 with its turn id or 204 when it
            answered a lane's question, whatever else this body says. Absent,
            every send is a new message. A message's key is stored with it and
            outlives a host restart; an answer's key is held by the running
            host only, so after a restart its resend is handled as a new send:
            it answers the question the lane asks then, or starts a turn.
      examples:
        - content: Run the ledger test suite and fix the flaky test.
          attachments:
            - /Users/dev/repos/midaz/components/ledger/test.log

    PendingInputReference:
      type: object
      description: >-
        A content-free durable queued-input reference. Message content
        remains only in the transcript.
      required: [entryId, turnId, kind, sequence, enqueuedAt]
      properties:
        entryId:
          type: string
          format: uuid
        turnId:
          type: string
          format: uuid
        kind:
          type: string
          enum: [steer, follow-up]
        sequence:
          type: integer
          format: int64
          minimum: 1
        enqueuedAt:
          type: string
          format: date-time
      examples:
        - entryId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          kind: follow-up
          sequence: 7
          enqueuedAt: 2026-08-10T15:04:05Z

    TurnAccepted:
      type: object
      description: >-
        Acknowledgement of a submitted message. Everything the turn produces
        streams over GET /v1/events, correlated by this turn id — never over
        the submitting response.
      required: [turnId]
      properties:
        turnId:
          type: string
          format: uuid
          description: Correlates this turn's events on the stream.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a

    Lane:
      type: object
      description: >-
        A parallel track of execution inside a session. The main lane
        carries the primary conversation; subagent lanes carry delegated
        work; side lanes carry conversations opened mid-run without
        interrupting the work. Workflow lanes carry one step of a workflow
        run: they have no parent lane and sit at depth 0, because a run
        starts from a request rather than from anybody's turn.
      required: [id, sessionId, kind, status, createdAt]
      properties:
        id:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid
        kind:
          type: string
          enum: [main, side, subagent, workflow]
        status:
          type: string
          enum: [idle, running, waiting, closed]
        agent:
          type: string
          description: >-
            The agent recipe running in the lane, for subagent and workflow
            lanes and for a main lane whose session was created under one.
        activity:
          type: string
          maxLength: 1024
          description: What the lane is currently doing, in one line.
        createdAt:
          type: string
          format: date-time
      examples:
        - id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          kind: subagent
          status: running
          agent: reviewer
          activity: Reviewing the diff of components/ledger
          createdAt: 2026-08-08T12:10:00Z

    SummaryCreate:
      type: object
      description: Request to summarise a conversation; an empty body asks plainly.
      properties:
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            A key the client chooses for this summary, so a request whose
            answer was lost can be repeated safely: one whose key the stored
            summary was asked under runs no round and answers with it. The
            key is held by the running host only: after a restart, a request
            under it runs a new round.

    LaneCreate:
      type: object
      description: >-
        Request to open a lane. kind side opens a side conversation; kind
        subagent delegates to a named agent recipe — agent is required in
        that case, and ignored for kind side.
      required: [kind, message]
      properties:
        kind:
          type: string
          enum: [side, subagent]
        agent:
          type: string
          maxLength: 128
          description: >-
            The agent recipe to delegate to. Required when kind is subagent.
            Ignored when kind is side: a side conversation always runs the
            host's read-only explorer, and the lane comes back naming it —
            an unknown name here is neither honoured nor refused.
        message:
          type: string
          minLength: 1
          description: The initial message that starts the lane's first turn.
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            createMessage's key, for the message that starts the lane, so a
            create whose answer was lost can be repeated safely. A create
            carrying a key its session already holds opens nothing and starts
            nothing: it is answered 201 with the lane that message is on,
            whatever else this body says. Absent, every create opens a lane.
      examples:
        - kind: subagent
          agent: reviewer
          message: Review the diff of components/ledger for correctness.

    Agent:
      type: object
      description: >-
        A named agent recipe — instructions, allowed tools and model.
        Recipes come from configuration and packages; read-only through
        this API in v1.
      required: [name]
      properties:
        name:
          type: string
          pattern: '^[^\s/\\]+$'
          maxLength: 128
        description:
          type: string
          maxLength: 1024
        model:
          type: string
          description: The model the recipe runs on; the session default when absent.
        tools:
          type: array
          description: Tool names the recipe may use.
          items:
            type: string
        mode:
          type: string
          enum: [primary, subagent, all]
          description: >-
            Where the recipe may be used: primary is the session's own
            agent, subagent is a delegate, all is both. A client offering a
            primary-agent picker lists primary and all.
        source:
          type: string
          enum: [builtin, user, claude-user, package, project-claude, project-narya, authored]
          description: >-
            Where the recipe was read from, nearest source winning a name
            collision: a built-in, the user's own narya or Claude Code
            directory, an installed package, or the repository's own
            .claude/.narya directory.


            `authored` is the odd one and the only one with no file behind
            it: narya wrote the recipe and a person consented to it, so it
            is a row in the refinement ledger rather than something anybody
            can open. It sits between the built-ins and the user's own
            directory — a learned override of a shipped default is the
            point, and a model is not the user.
        sourcePackage:
          type: string
          description: The package that supplied the recipe; absent for built-ins and local configuration.
      examples:
        - name: reviewer
          description: Reviews diffs for correctness and style.
          model: claude-sonnet-4-5
          tools: [read, grep, shell]
          mode: subagent
          source: package
          sourcePackage: lerian/review-pack

    Skill:
      type: object
      description: >-
        One installed skill as a client renders it: the name it answers to,
        the one line that says when to reach for it, the file it was read
        from and the rung that file sits on. The instructions are absent by
        design — that absence IS progressive disclosure, and expandSkill is
        the one way to end it.
      required: [name]
      properties:
        name:
          type: string
          pattern: '^[^\s/\\]+$'
          maxLength: 128
        description:
          type: string
          maxLength: 4096
        path:
          type: string
          description: >-
            The SKILL.md file itself, absolute as a person would see it —
            what a client names when it has to tell somebody which file to
            go and rename.
          maxLength: 4096
        source:
          type: string
          enum: [user, claude-user, package, project-claude, project-narya, authored]
          description: >-
            Where the skill was read from, nearest source winning a name
            collision: the user's own narya or Claude Code directory, an
            installed package, or the repository's own .claude/.narya
            directory. A package is the lowest rung — enabling a package is
            not trusting it.


            `authored` is the odd one and the only one with no file behind
            it: narya wrote the skill and a person consented to it, so it is
            a row in the refinement ledger rather than a SKILL.md anybody can
            open, and `path` is absent on it. Its rung is just above the
            packages and below every directory the person owns.
        sourcePackage:
          type: string
          description: The package that shipped the skill; absent for the user's own and a project's.
      examples:
        - name: pdf
          description: Fill in and sign PDF forms. Use when a task needs a filled PDF.
          path: /home/dev/.claude/skills/pdf/SKILL.md
          source: claude-user

    SkillPage:
      type: object
      description: >-
        One page of installed skills, plus whatever the loaders complained
        about while reading them.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Skill'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        notices:
          type: array
          description: >-
            Standing facts about the SKILL files on this machine's disk — a
            package skill something nearer displaces, a file with no
            description — in the words a person reads. Scoped to this
            listing: what the command loaders complained about arrives with
            listCommands, so a caller walking both receives each complaint
            once. They repeat on every request because they are conditions
            rather than events; a client that shows them keeps its own
            memory of what it has already said.
          items:
            type: string
      examples:
        - items:
            - name: pdf
              description: Fill in and sign PDF forms.
          limit: 25

    Command:
      type: object
      description: >-
        One command file as a client renders it: what "/" reaches it by, the
        author's description, the hint for what to type after the name, and
        where the file came from. The body is absent — expandCommand is what
        turns this into text.
      required: [name]
      properties:
        name:
          type: string
          pattern: '^[^\s/\\]+$'
          maxLength: 128
        description:
          type: string
          maxLength: 4096
        argumentHint:
          type: string
          description: A human sketch of what to type after the name, e.g. "<file> [--fix]".
          maxLength: 4096
        path:
          type: string
          description: >-
            The file this was read from. It is what a refusal names when two
            files claim one command name: for a package's own file the
            package name says enough, but for two files somebody wrote
            themselves the path is the only thing they can act on.
          maxLength: 4096
        source:
          type: string
          enum: [user, claude-user, package, project-claude, project-narya, authored]
          description: >-
            Which rung of the command ladder served this file, nearest source
            winning a name collision: the user's own narya or Claude Code
            directory, an installed package, or the repository's own
            .claude/.narya directory. Spelled as Skill.source is, and with
            the same members — this ladder carries no built-ins, so there is
            no `builtin` here.


            `authored` is the one a consent surface needs: narya wrote the
            command and a person allowed it, so it is a row in the refinement
            ledger rather than a file, `path` is absent on it, and its shell
            blocks never run whatever the repository's trust answer says.
            Without this field a client cannot tell a command the person
            wrote from one narya wrote, which is the distinction the person
            is being asked about.
        sourcePackage:
          type: string
          description: >-
            The installed package that shipped the file; absent for the person's own and a trusted repository's. A package's command never runs shell blocks, whatever the repository's trust answer says. Spelled as Skill.sourcePackage and Agent.sourcePackage are: the three are read off one resolution order and mean one thing, so a client rendering them reads one name for it.
      examples:
        - name: review
          description: Review the working tree for correctness.
          argumentHint: '[path]'
          path: /home/dev/.claude/commands/review.md

    CommandPage:
      type: object
      description: >-
        One page of command files, plus whatever the loaders complained
        about while reading them.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Command'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        notices:
          type: array
          description: >-
            Standing facts about the COMMAND files on this machine's disk,
            in the words a person reads — scoped to this listing, the way
            SkillPage.notices is scoped to that one.
          items:
            type: string
      examples:
        - items:
            - name: review
              description: Review the working tree for correctness.
          limit: 25

    ExpansionRequest:
      type: object
      description: >-
        What to expand a skill or a command file against: the repository the
        work is happening in, and whatever the person typed after the name.
      properties:
        repository:
          type: string
          description: >-
            Absolute path of the repository, which decides which rungs are
            read and — for a command — where its shell blocks run and
            whether they may. A hosted home runs no shell block. On a
            hosted home it resolves under the home's workspace, and a path
            outside it is refused 422.
          maxLength: 4096
        sessionId:
          type: string
          format: uuid
          description: >-
            The conversation to expand against, in place of repository
            (LadderSessionParam): for one whose code lives in an environment,
            the clone's skill or command is read there and a command's shell
            blocks run there, never on this host, starting its stopped
            container first. Naming both is refused 400. An expansion is a
            read of that conversation: a colleague's of a shared one is in
            its disclosure audit before any text comes back, and refused
            when that record cannot be written (listSessionDisclosures).
        arguments:
          type: string
          description: >-
            Everything typed after the name, verbatim. A command file's
            $ARGUMENTS and $1..$9 are filled from it, AFTER its shell blocks
            have already run, so text pasted here can never become a block.
          maxLength: 65536
      examples:
        - repository: /home/dev/repos/narya
          arguments: internal/wire

    Expansion:
      type: object
      description: >-
        The text one expansion produced, ready to be put in front of a
        person. It is never sent anywhere on their behalf: the whole point of
        expanding into a composer is that somebody reads what a file wrote in
        their name before a model does.
      required: [text]
      properties:
        text:
          type: string
      examples:
        - text: "Review the working tree.\n\nOn branch develop\n\ninternal/wire"

    Permission:
      type: object
      description: >-
        A permission ask — a sensitive action waiting on a decision. Pending
        asks also arrive as permission-asked events.
      required: [id, sessionId, action, resource, status, requestedAt]
      properties:
        id:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid
        laneId:
          type: string
          format: uuid
          description: The lane whose work triggered the ask, when not the main lane.
        action:
          type: string
          maxLength: 128
          description: The kind of action, for example shell, write, network.
        resource:
          type: string
          maxLength: 4096
          description: What the action targets — a parsed command, a path, a host.
        detail:
          type: string
          maxLength: 4096
          description: >-
            Human-readable presentation of the ask: what is being asked, the
            exact rule a remembered allow would grant, and the rules that
            matched in evaluation order. Newline-separated, and
            truncated with a marker if it would exceed the length below —
            the gate matched the full text either way.
        status:
          type: string
          enum: [pending, decided, expired]
          description: >-
            `pending` also covers a product write its person approved whose
            outcome is unknown (approvedAt set): for 15 minutes from its approval
            it waits on them to be sent again, under its own key.
        requestedAt:
          type: string
          format: date-time
        productCall:
          $ref: '#/components/schemas/ProductCall'
        approvedAt:
          type: string
          format: date-time
          description: >-
            When the person approved a product write whose outcome is unknown:
            no answer arrived, the product answered 5xx without
            X-Idempotency-Fenced, or it held the key (X-Idempotency-Held). For
            15 minutes from that approval it can only be sent again, under its
            own key, and the product's key makes the re-send apply at most
            once while it keeps the key; it is never declined, and a message
            does not close it. Meanwhile its owner's other writes to the same
            endpoint are not stored, and its conversation is not archived.
            Past those minutes it ends as a change that may have applied.
            Absent on every other ask.
        entered:
          type: array
          items: {type: string}
          description: >-
            On a write with approvedAt whose send outcome was recorded, the
            credentials that send was given on the card, by property name and
            sorted, never their values; [] when it was given none. A re-send
            carries exactly these names. Absent when no send was recorded, and
            the re-send then takes the card's credentials as on a first send.
            Absent on every other ask.
      examples:
        - id: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          action: shell
          resource: 'git push origin main'
          detail: "Allow shell: git push origin main\nRemembering this answer would grant the rule: allow shell — for exactly the text above, nothing broader.\nRules that matched, in order (the last one decides):\n  ask shell git push* — rule 4 in defaults.toml"
          status: pending
          requestedAt: 2026-08-08T12:20:00Z

    PermissionDecisionRequest:
      type: object
      description: >-
        A decision on a pending permission ask. A denial is never remembered:
        remember must be none (or absent) whenever decision is deny, and any
        other combination is refused with 400. There are four answers —
        allow once, allow always in this project, allow always globally, and
        deny with a message — so a remembered refusal is not one of them, and
        the prompt states before the answer exists that remembering would
        grant an allow rule.
      required: [decision]
      properties:
        decision:
          type: string
          enum: [allow, deny]
        remember:
          type: string
          enum: [none, session, project, global]
          default: none
          description: >-
            Scope to remember the decision at — none
            decides only this ask, session remembers for this session,
            project appends a rule scoped to this repository, global appends
            a rule to the user's own configuration. Two "always" scopes
            rather than one because "fine in THIS repository" and "fine
            everywhere I work" are different claims, and a single "always"
            forces everybody to over-grant. Only an allow is remembered; any
            scope other than none alongside decision=deny is a 400.
        message:
          type: string
          maxLength: 4096
          description: >-
            Optional correction shown to the agent, useful on deny. It
            reaches the model verbatim as something it can act on: a denial
            saying only "denied" teaches nothing, so the model tries a
            variant and burns the turn, while "do not push to main, open a
            PR branch instead" ends the exchange.
        secrets:
          type: object
          maxProperties: 32
          additionalProperties:
            type: string
            minLength: 1
            maxLength: 16384
          description: >-
            The credentials the person typed on a product write's card, by property name (`productCall.secrets`), on an allow only. Used by the send.
      examples:
        - decision: deny
          remember: none
          message: Do not push; open a PR branch instead.

    PermissionDecisionRecord:
      type: object
      description: >-
        One entry of the decision audit. There is exactly one
        decision per ask, so the decision's id IS the ask's id: id and
        permissionId carry the same identifier.


        WHAT IS RECORDED, stated here because it is narrower than "every
        decision" and deliberately so: every ASK and its answer, whoever
        answered it, and every DENY — including the ones taken without asking
        anybody, which is the shape almost every denial has in unattended
        work. A rule-derived ALLOW on a free action is NOT a row. Recording
        every permitted read would write thousands of rows per session and
        bury the two questions this audit exists to answer — what was denied
        and why, and what did I approve.


        AND A DECISION IS NOT A RECORD OF EXECUTION. It says what was decided,
        not what ran: a call interrupted in the same instant its decision was
        released can leave an `allow` here for something that never happened.
        Read it as the trail of judgements, and the transcript for what
        followed.
      required: [id, permissionId, decision, requestedAt]
      properties:
        id:
          type: string
          format: uuid
        permissionId:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid
        action:
          type: string
          maxLength: 128
        resource:
          type: string
          maxLength: 4096
        decision:
          type: string
          enum: [allow, deny, expired]
          description: >-
            `expired` is an ask nobody answered before the call it was
            blocking ended. It carries no decider and no decidedAt, because
            "nobody answered" is itself the finding when somebody asks why an
            action did not happen.
        decidedBy:
          type: string
          enum: [user, policy, hook]
          description: >-
            Who decided — a human, the policy deciding without asking (which
            includes a rule that landed after the ask was raised and covered
            it), or the configured executable at the permission-ask
            decision point. Absent on an expired entry.
        decidedBySubject:
          type: string
          maxLength: 256
          description: >-
            WHICH person decided, beside decidedBy's kind. Any member of the
            organisation may decide a permission request raised on a
            conversation that is not marked private, so a decision taken for
            somebody else is an ordinary event here and this is the only field
            that distinguishes it from one the owner took.


            Absent when no person decided — a rule or a hook answered, or the
            ask expired with nobody answering — and absent on every decision
            this host recorded before it began recording who. An absent value
            is never the owner: a decision nobody recorded and a decision the
            owner took are different facts, and reading one as the other would
            attribute commands to people who never approved them.
        ownerSubject:
          type: string
          maxLength: 256
          description: >-
            Whose conversation the decision was taken ON — the owner this
            request was parked on, which is the same value the session's own
            `owner` field carries. Read beside decidedBySubject it answers "who
            let that command run, and on whose work", from the audit alone.


            Absent only for a row this host can attribute to nobody: a
            refinement round whose conversation was purged before owners were
            recorded.
        reason:
          type: string
          maxLength: 4096
          description: >-
            Why, in the decider's own words: a person's message on a
            deny-with-message, the rule a policy refusal names, or the hook
            that decided — a hook denial that does not say which hook is an
            audit trail ending in a shrug. Absent when the outcome speaks for
            itself.
        remember:
          type: string
          enum: [none, session, project, global]
        requestedAt:
          type: string
          format: date-time
          description: >-
            When the decision was raised. It is what the audit is ordered and
            paged by, so a page boundary is stable while new decisions land.
        decidedAt:
          type: string
          format: date-time
          description: Absent on an expired entry, which nobody decided.
        productCall:
          $ref: '#/components/schemas/ProductCall'
        outcome:
          $ref: '#/components/schemas/ProductWriteOutcome'
      examples:
        - id: 3f1e5d7c-2b4a-4c6e-8d0f-9a1b3c5d7e9f
          permissionId: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          action: shell
          resource: 'git push origin main'
          decision: deny
          decidedBy: user
          reason: 'do not push to main, open a PR branch instead'
          remember: none
          requestedAt: 2026-08-08T12:20:58Z
          decidedAt: 2026-08-08T12:21:00Z

    ProductCall:
      type: object
      description: >-
        The exact request a product write sends, on an ask with action
        `product`, stored when the model proposed it and never truncated: its
        person confirms this call and nothing else, and a confirmed send is
        these bytes. Absent on every other ask.
      required: [product, operationId, method, path]
      properties:
        product:
          type: string
          description: The product tree the call goes to.
        operationId:
          type: string
        summary:
          type: string
          description: The product specification's summary of the operation, verbatim.
        method:
          type: string
        path:
          type: string
          description: The resolved path with its query, as the request line carries it.
        body:
          type: string
          description: >-
            The model's request body as stored, JSON text; the typed
            credentials in `secrets` are added to a copy of it at send time.
            Absent when the call has none.
        secrets:
          type: array
          description: >-
            The credentials the card asks its person for, sorted by name: the
            body-root properties the product's specification marks
            `x-lerian-secret`, never one the model filled. Names and the
            specification's words only; the values are typed with the allow
            (`PermissionDecisionRequest.secrets`). Absent when there are none.
          items:
            $ref: '#/components/schemas/ProductCallSecret'

    ProductCallSecret:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: The body property.
        description:
          type: string
          description: The product specification's description of the property, verbatim.
        required:
          type: boolean
          description: The body requires it, so the allow carries it.
        maxLength:
          type: integer
          format: int64
          description: The longest value the specification allows. Absent when it sets none.

    ProductWriteOutcome:
      type: object
      description: >-
        How a confirmed product write's send ended, once it ended. Absent
        while the send has not ended, and on every other decision.
      properties:
        status:
          type: integer
          description: The product's HTTP status; absent when no answer arrived.
        replayed:
          type: boolean
          description: The product replayed an earlier attempt's answer under the same key, so nothing changed now.
        unknown:
          type: boolean
          description: >-
            Nobody can tell yet whether the send applied: no answer arrived,
            the product answered 5xx without X-Idempotency-Fenced, or it held
            the key for an attempt that did not run (X-Idempotency-Held). For
            15 minutes from the approval the write can be allowed again, which
            sends it under the same key, so the product applies it at most
            once while it keeps that key.
        mayHaveApplied:
          type: boolean
          description: >-
            The send may have applied and can no longer be sent again under its
            key: the product answered 5xx carrying X-Idempotency-Fenced (true:
            it refuses the key from now on; false: it released the key), or the
            15 minutes to send it again passed.
            Its current state is read before it is proposed again, and a new
            proposal is a new change under its own key.
        entered:
          type: array
          items: {type: string}
          description: >-
            The credentials the person typed on the card for this send, by
            property name, never their values.

    Peer:
      type: object
      description: >-
        A session on this machine addressable for inter-session messaging,
        with what it is working on and its liveness. An archived session is
        not a peer and never appears here.
      required: [sessionId, name, status]
      properties:
        sessionId:
          type: string
          format: uuid
        name:
          type: string
          maxLength: 256
          description: >-
            The name the session is addressable by, which is its session id and nothing else. A session's `title` is duplicable and may be absent, so it is what a person reads in a list and never what a message is routed by; no third name exists to sit between them. Carried alongside `sessionId` because the field is required and predates that ruling.
        status:
          type: string
          enum: [idle, running, waiting]
        liveness:
          type: string
          enum: [addressable, gone]
          description: >-
            Whether a message sent right now would reach this session.
            `addressable` covers both a free session, which takes it into its
            transcript immediately, and a busy one, which takes it from the
            durable queue when it frees. `gone` is a session that exists but
            that nothing is serving — a row a dead host left claiming a turn
            — and addressing it is refused (NRY-0009) rather than queued for
            a recipient that will never read it.
        repository:
          type: string
          maxLength: 4096
          description: >-
            Absolute path of the repository the session works in, which is
            how a caller picks between several sessions on one machine; on a
            hosted home, relative to its workspace.
        activity:
          type: string
          maxLength: 1024
          description: >-
            What the session is working on, in one line — its title, absent
            until the model has written one. Display only.
        lastSeenAt:
          type: string
          format: date-time
          description: Last liveness confirmation.
      examples:
        - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          name: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          status: running
          liveness: addressable
          repository: /Users/dev/repos/midaz
          activity: Fixing the flaky ledger test

    InboxMessageCreate:
      type: object
      description: >-
        A message from one session to another. kind ask expects an answer and
        returns an askId; kind message is one-way. Delivery is durable: a
        busy recipient gets it when free, and it survives a restart.
      required: [from, content]
      properties:
        from:
          type: string
          maxLength: 256
          description: >-
            The sending session's id. It is a SELECTION among the sessions the caller can already reach, never a claim the host takes on trust: an id outside the caller's reach is refused with the same "no such session" an id that never existed gets. Where the request already carries a sender of its own — the session a tool call runs under — that session is the sender and this field is not consulted at all.
        content:
          type: string
          minLength: 1
          description: The message text.
        kind:
          type: string
          enum: [message, ask, answer]
          default: message
        askId:
          type: string
          format: uuid
          description: >-
            Required when kind is answer, ignored otherwise: the ask being
            resolved. The answer is posted to the ASKING session's inbox, so
            this id and the path's sessionId must describe the same ask —
            answering a question nobody asked you is refused rather than
            recorded.
        wake:
          type: boolean
          default: false
          description: >-
            Only for kind message. Whether a recipient with no turn running
            starts one to take the message, run as and paid by the caller.
            Off, the message waits for the recipient's next turn. A recipient
            that is busy takes the message as it always did, with no turn
            of its own. Nothing caps how many turns in a row it wakes.
      examples:
        - from: midaz-research
          content: The fee calculation you need is in components/fees/engine.go.
          kind: message

    InboxReceipt:
      type: object
      description: >-
        Delivery acknowledgement. queued means the message is durably held
        but not yet in the recipient's conversation, because the recipient
        was busy or landing it failed — it is delivered when the recipient's
        next turn ends or the host next starts. "Held" is bounded
        by the retention window configured for intercom_messages, not by
        forever: with no window configured nothing sweeps it, and with one
        configured `narya retention status` says how long it has and `narya
        doctor` reports the oldest message still waiting.
      required: [status]
      properties:
        status:
          type: string
          enum: [delivered, queued]
        deliveryId:
          type: string
          format: uuid
          description: >-
            This delivery's identity, stable across every redelivery attempt.
            Transport is at-least-once with an idempotent append: a host that
            dies between landing a message and recording that it landed offers
            the message again, and this id is what makes the second attempt
            recognise the first rather than saying it twice. Content is never
            an identity — the same sentence said twice is two messages.
        askId:
          type: string
          format: uuid
          description: >-
            Present when kind was ask: the id the answer carries when it
            arrives as an intercom-message event. GET /v1/asks/{askId} reads
            where the question stands.
      examples:
        - status: queued
          deliveryId: 4d2b6f8a-1c3e-4a5b-9d7f-2e4a6c8b0d1f
          askId: 8a6b4c2d-0e1f-4a3b-9c5d-7e9f1a3b5c7d

    Ask:
      type: object
      description: >-
        A question one session asked another. Nothing waits on it; the answer
        also arrives on the event stream as an intercom-message event.
      required: [id, status, askedAt]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [pending, answered]
          description: >-
            `pending` until the asked session answers, with no deadline;
            `answered` once it has, after which a second answer is refused.
        answer:
          type: string
          description: The answer, once given.
        askedAt:
          type: string
          format: date-time
        answeredAt:
          type: string
          format: date-time
      examples:
        - id: 8a6b4c2d-0e1f-4a3b-9c5d-7e9f1a3b5c7d
          status: answered
          answer: Yes, the fee engine rounds half-even.
          askedAt: 2026-08-08T12:30:00Z
          answeredAt: 2026-08-08T12:31:00Z

    Package:
      type: object
      description: >-
        An installed package — the one thing a user installs. May carry
        declarative resources, Go code, or both; only code-carrying packages
        cost a rebuild.
      required: [name, enabled, carriesCode]
      properties:
        name:
          type: string
          maxLength: 256
        version:
          type: string
          maxLength: 64
        source:
          type: string
          maxLength: 1024
          description: The registry reference or local path it was installed from.
        carriesCode:
          type: boolean
          description: True when the package carries Go code and changes require a rebuild.
        enabled:
          type: boolean
        installedAt:
          type: string
          format: date-time
      examples:
        - name: lerian/review-pack
          version: 1.2.0
          source: registry.lerian.studio/review-pack
          carriesCode: true
          enabled: true
          installedAt: 2026-08-01T09:00:00Z

    PackageInstall:
      type: object
      description: Request to install a package.
      required: [source]
      properties:
        source:
          type: string
          maxLength: 1024
          description: A registry reference or a local directory path.
        trustAcknowledged:
          type: boolean
          default: false
          description: >-
            Must be true when source carries compiled Go code:
            installing it means trusting it fully — it runs in the host's
            own process, with no sandbox. Omitted or false on a
            code-carrying source is rejected (422) naming the acknowledgement, before any
            install is attempted.
      examples:
        - source: registry.lerian.studio/review-pack
          trustAcknowledged: true

    PackageClassification:
      type: object
      description: >-
        What a source turns out to be, learned without compiling it, importing
        it or downloading it.
      required: [source, name, kind, requiresFetch, requiresTrust]
      properties:
        source:
          type: string
          description: >-
            The CANONICAL form of the source, and what an install must be
            started with: an absolute path for a local directory or file, the
            reference unchanged for a module. A consent surface that names one
            artifact and installs another is the one thing it may not do, so
            the caller carries this value through to installPackage rather than
            the word somebody typed.
        name:
          type: string
          description: The package name.
        kind:
          type: string
          enum: [resources, code, both]
          # Named explicitly: two of these three values also spell values of
          # SessionRewindScope, and letting the generator name constants by
          # value would have RENAMED that older enum's constants to
          # disambiguate — a breaking change to the generated Go surface made
          # by an additive edit to this file.
          x-enum-varnames:
            - PackageKindResources
            - PackageKindCode
            - PackageKindBoth
          description: >-
            resources installs instantly; code and both carry Go source and
            cost a rebuild and a binary swap. Reported as code for a module
            reference nothing has downloaded yet, which is the safe side of a
            guess nobody can make without fetching.
        requiresFetch:
          type: boolean
          description: >-
            The source is a module reference whose real kind and claims stay
            unknown until it is downloaded. A caller asking a person for
            consent says so rather than describing contents nobody has read.
        requiresTrust:
          type: boolean
          description: >-
            Installing this needs an acknowledgement: true for anything
            carrying compiled Go code, and equally true for a resources-only
            package declaring a hook, since a hook is an executable narya runs
            through the shell with no sandbox. A resources-only package with no
            hooks is never gated.
      examples:
        - source: /home/dev/packages/review
          name: acme/review
          kind: code
          requiresFetch: false
          requiresTrust: true

    PackageJob:
      type: object
      description: >-
        An asynchronous package operation. Code-carrying packages resolve,
        build and swap the binary; a failure at any stage leaves the previous
        binary intact and serving — the user is never left without a working
        agent.
      required: [id, status, startedAt]
      properties:
        id:
          type: string
          format: uuid
        packageName:
          type: string
          maxLength: 256
        source:
          type: string
          maxLength: 1024
        status:
          type: string
          enum: [resolving, building, swapping, done, failed]
        error:
          $ref: '#/components/schemas/Error'
          description: Present when status is failed; the previous binary is intact.
        startedAt:
          type: string
          format: date-time
        finishedAt:
          type: string
          format: date-time
      examples:
        - id: 5c3d1e9f-7a8b-4c2d-0e4f-6a8b0c2d4e6f
          packageName: lerian/review-pack
          source: registry.lerian.studio/review-pack
          status: building
          startedAt: 2026-08-08T12:40:00Z

    WorkflowRunCreate:
      type: object
      description: >-
        Request to start a workflow run from a script — exactly one of
        scriptPath or source.
      properties:
        repository:
          type: string
          maxLength: 4096
          x-go-type-skip-optional-pointer: true
          description: >-
            The repository the run's session, and with it every step and
            delegate, works in, named as createSession's `repository`: required
            unless `clone` is given and refused beside it.
        scriptPath:
          type: string
          maxLength: 4096
          description: Absolute path of the workflow script on this machine, beneath `repository`.
        source:
          type: string
          description: Inline workflow script source.
        args:
          type: array
          maxItems: 64
          description: Arguments passed to the script.
          items:
            type: string
        environment:
          # EnvironmentId's bounds inline: a $ref here makes the validator
          # report this schema's oneOf before any property's own bound.
          type: string
          minLength: 1
          maxLength: 64
          pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$'
          description: >-
            Run the session this run opens, and with it every step and
            delegate, inside this environment, which must already exist
            (createEnvironment). Absent means the host's own machine on a
            person's own home, and a container of the session's own on a
            hosted home (createSession).
        clone:
          type: string
          maxLength: 200
          pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$'
          description: >-
            A repository to clone into the run session's own container before
            the run starts, exactly as createSession's `clone`: on a hosted
            home, without `environment`, and the session's repository.
        setupScript:
          type: string
          minLength: 1
          maxLength: 65536
          description: >-
            Run once in place of the clone's own `.narya/setup.sh`, as
            createSession's `setupScript`. Only with `clone` (422 otherwise).
        clientKind:
          # ClientKind inline, for environment's reason above; x-go-type keeps
          # the generated field the shared ClientKind.
          type: string
          enum: [inline, tui, oneshot, api, unknown]
          x-go-type: ClientKind
          description: >-
            The kind of client starting this run, stamped on the session it
            opens as createSession's `clientKind` is: `api` when absent, so
            nobody attends the run and an ask in it is refused rather than
            waited on. A terminal client names itself, and the run asks it.
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            A key the client chooses for this run, so a start whose answer was
            lost can be repeated safely. A start carrying a key its caller
            already started a run under starts nothing: it is answered 202 with
            that run as it stands, whatever else this body says, and 409 while
            that first start has not answered yet. Absent, every start runs.
      oneOf:
        - required: [scriptPath]
        - required: [source]
      examples:
        - repository: /Users/dev/repos/midaz
          scriptPath: /Users/dev/repos/midaz/.narya/workflows/release.js
          args: [--dry-run]

    WorkflowStep:
      type: object
      description: >-
        One step of a workflow run. A failed step never silently disappears;
        a stop marks running steps stopped and unstarted steps skipped.
      required: [name, status]
      properties:
        name:
          type: string
          maxLength: 256
        status:
          type: string
          enum: [pending, running, completed, failed, stopped, skipped]
        output:
          type: string
          description: The step's result or partial result, as the script reported it.
        startedAt:
          type: string
          format: date-time
        finishedAt:
          type: string
          format: date-time
      examples:
        - name: run-tests
          status: completed
          output: 412 tests passed
          startedAt: 2026-08-08T13:00:00Z
          finishedAt: 2026-08-08T13:04:00Z

    WorkflowRun:
      type: object
      description: A deterministic orchestration run with per-step progress.
      required: [id, status]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: string
          enum: [pending, running, completed, failed, stopped]
        scriptPath:
          type: string
          maxLength: 4096
          description: >-
            Reserved response field. The current host snapshots a local path
            into the run's stored source and does not retain or return the
            path, so this field is absent for both source forms.
        sessionId:
          type: string
          format: uuid
          description: >-
            The session the run's agents work in, so their transcripts can be
            read back after the run and after a restart. Absent until the run
            has started one, and cleared if that session is later purged.
        args:
          type: array
          items:
            type: string
        steps:
          type: array
          items:
            $ref: '#/components/schemas/WorkflowStep'
        error:
          $ref: '#/components/schemas/Error'
          description: Present when the run itself failed outside any step.
        startedAt:
          type: string
          format: date-time
        finishedAt:
          type: string
          format: date-time
      examples:
        - id: 2d4e6f8a-0b2c-4d6e-8f0a-2b4c6d8e0f2a
          status: running
          args: [--dry-run]
          steps:
            - name: run-tests
              status: completed
              output: 412 tests passed
            - name: build-artifacts
              status: running
          startedAt: 2026-08-08T13:00:00Z

    Provider:
      type: object
      description: A registered model provider with its authentication state.
      required: [name, authState]
      properties:
        name:
          type: string
          pattern: '^[a-z0-9][a-z0-9-]*$'
          maxLength: 128
        authState:
          type: string
          enum: [unauthenticated, authenticated, expired]
          description: >-
            expired means stored credentials no longer work and sign-in must
            be repeated; nothing fails silently.
        models:
          type: array
          description: Ids of the models this provider serves.
          items:
            type: string
        defaultModel:
          type: string
          description: >-
            Id of the model this provider runs when nobody names one — the
            model a session opens on after `narya auth login <provider>` and
            nothing else. Absent when the provider declares none, in which
            case the host falls back to the first id in models. A client
            listing what this installation can use cannot derive it: which
            model a provider defaults to is the host's own knowledge, and a
            client guessing at it names a different model than the host will
            actually run.
      examples:
        - name: anthropic
          authState: authenticated
          models: [claude-sonnet-4-5, claude-opus-4-6]
          defaultModel: claude-opus-4-6

    ProviderAuthStart:
      type: object
      description: >-
        Which sign-in to start, and the key when the person typed one. An
        absent body is the same as `{"method": "key"}` with no key, which is
        refused — a key method needs a key.
      properties:
        method:
          type: string
          enum: [key, subscription]
          default: key
          description: >-
            `key` stores a key the person typed, after probing it. It is the
            default because a subscription sign-in launches a browser or a
            device-code poll, and that must never be a surprise.
        key:
          type: string
          maxLength: 4096
          description: >-
            The provider API key, required for method `key` and refused for
            any other. It is a secret in transit and at rest: the host stores
            it in the OS keychain, never in its store, its logs, any
            transcript, or any error message this operation can answer with.
      examples:
        - method: key
          key: sk-example-not-a-real-key
        - method: subscription

    ProviderAuthSession:
      type: object
      description: >-
        A sign-in flow, in flight or finished. When browserUrl is present the
        client opens it; completion is reflected in the provider's authState.
      required: [status]
      properties:
        status:
          type: string
          enum: [pending, complete, failed]
          description: >-
            `pending` means the host is still driving the flow — poll
            getProviderAuth. `complete` means a credential was probed and
            stored. `failed` means this attempt is over and message says why;
            nothing was stored.
        browserUrl:
          type: string
          format: uri
          description: URL the user opens to complete a browser-based flow.
        message:
          type: string
          maxLength: 1024
          description: >-
            Human-readable instruction, for example a device code to enter —
            and, on a failed status, why the sign-in failed. On a complete
            status it names the environment variable whose key turns spend
            instead of the stored one, when such a variable is set. It never
            carries the key or token itself.
        expiresAt:
          type: string
          format: date-time
          description: When the pending flow expires.
      examples:
        - status: pending
          browserUrl: https://auth.example-provider.com/device?code=XQRT-PLMN
          message: Enter code XQRT-PLMN in the opened page.
          expiresAt: 2026-08-08T12:50:00Z
        - status: failed
          message: openai rejected this credential

    Model:
      type: object
      description: One model in the flat cross-provider catalogue, with capability and cost metadata.
      required: [id, provider]
      properties:
        id:
          type: string
          description: The model's id, unique across providers.
        provider:
          type: string
          description: The provider that serves it.
        name:
          type: string
          description: Display name.
        contextWindow:
          type: integer
          minimum: 0
          description: >-
            Context window in tokens: the catalogue's published figure, or,
            for a model it states none for, the one this home learned from
            the model's own refusal of a request as too long.
        maxOutputTokens:
          type: integer
          minimum: 0
        capabilities:
          type: array
          description: >-
            What the catalogue knows the model can do, each listed only when
            true: `vision` (it reads images) and `reasoning` (it reasons before
            it answers). Absent claims nothing.
          items:
            type: string
        inputCostPerMTok:
          type: number
          minimum: 0
          description: Cost per million input tokens, US dollars.
        outputCostPerMTok:
          type: number
          minimum: 0
          description: Cost per million output tokens, US dollars.
        availability:
          type: string
          enum: [listed, unlisted, discovered]
          description: >
            What the provider's own model listing said about this model, when a
            credential on this host let narya ask. Absent means nothing has
            asked — no credential, a failed fetch, or a machine that never
            reaches the network — and claims nothing either way.
            `listed`: the provider currently serves it.
            `unlisted`: the catalogue carries it and the provider's listing did
            not; still offered and still runnable, since that listing may be
            paginated or scoped to an organisation.
            `discovered`: the provider lists it and the catalogue does not, so
            it carries no published window and no published price — unknown
            cost, never zero.
        variants:
          type: array
          description: >-
            Every named preset of request options this model offers, in the
            order a client should list them: the ones declared beside the model
            in configuration first, then the reasoning-effort levels weakest
            first. The levels are built in for every model whose family carries
            that knob and need no declaration anywhere; a configured variant
            reusing one of their names replaces it. Absent or empty means this
            model offers none, and asking for one is refused by name. A client
            reads this and nothing else to decide what to offer — it never
            derives a level, and a model whose endpoint turns out not to accept
            the field is told so by the host wherever the level is chosen — the
            model switch, and opening a session at one.
          items:
            $ref: '#/components/schemas/ModelVariant'
      examples:
        - id: claude-sonnet-4-5
          provider: anthropic
          name: Claude Sonnet 4.5
          contextWindow: 200000
          maxOutputTokens: 64000
          capabilities: [vision, reasoning]
          inputCostPerMTok: 3
          outputCostPerMTok: 15
          availability: listed
          variants:
            - name: low
              effort: low
            - name: medium
              effort: medium
            - name: high
              effort: high
            - name: max
              effort: max

    ModelVariant:
      type: object
      description: >-
        One named preset of request options for a model, selectable as
        `provider/id#name` wherever a model reference is written and as
        `SessionModel.variant` on the switch. It carries request options only
        and never prompt text: what a variant changes is what a turn is SENT
        with, never what it is told.
      required: [name]
      properties:
        name:
          type: string
          maxLength: 64
          description: >-
            What to ask for. The four reasoning-effort levels are spelled
            `low`, `medium`, `high` and `max`; any other name was declared
            beside the model in configuration.
        effort:
          type: string
          enum: [low, medium, high, max]
          description: >-
            How hard the model is asked to think on a turn run under this
            variant, in narya's own four words rather than any supplier's —
            each family spells the same idea differently and the host
            translates once per family. Absent leaves the model's own default.

    FindableEntryKind:
      type: string
      enum:
        - user-message
        - assistant-message
        - tool-call
        - tool-result
        - summary
        - system
        - intercom
        - monitor-event
      description: >-
        What part of a conversation a record search means — the vocabulary of
        `searchRecords`'s `kind` filter, and of the `kind` a match reports.


        It is deliberately its OWN vocabulary rather than the transcript's.
        `EntryKind` says what a conversation CONTAINS; this says what the
        record search can FIND, and the two are free to drift as what gets
        indexed changes. Nothing else on this contract references it.


        What the index holds is the PLAIN TEXT of an entry's payload, so what
        is findable is decided by the payload and not by the kind — which
        makes two of these values narrower than they look, and both are worth
        knowing before a reader concludes the search is broken.


        `assistant-message` is only PARTLY findable by text. A round's spoken
        text is indexed; the same round's thinking is a separate entry of this
        same kind carrying reasoning, and reasoning has no plain text, so it
        never reaches the index. Filtering to assistant messages and not
        finding a phrase the model reasoned about is the index telling the
        truth, not failing.


        `tool-call` is never findable by text at all — a call carries a tool
        name and its arguments, and neither is indexed. It is listed here
        because it IS reachable by the NAME somebody put on that moment: a
        label is matched on any entry whatever its kind, so a labelled tool
        call answers an unfiltered search and must stay reachable from a
        filtered one. Leaving it out would make naming every value of this
        filter return LESS than naming none.


        Making either searchable by its text is a change to what is written
        into the index and to the backfill behind it, not a value added here.

    RecordSearchResult:
      type: object
      description: >-
        One full-text match, from one of the three session kinds
        searchRecords covers: the caller's own, one marked for the
        organisation, or any session when the caller holds the
        read-private permission.


        It carries no score, no match offsets and no total. A relevance score
        on a page the read rule has already narrowed is a number about rows
        the caller cannot see, and a total is a count of the same.
      required: [sessionId, excerpt, timestamp]
      properties:
        sessionId:
          type: string
          format: uuid
        entryId:
          type: string
          format: uuid
          description: The matching transcript entry — usable as a branch point.
        sessionTitle:
          type: string
          maxLength: 512
        excerpt:
          type: string
          maxLength: 4096
          description: The entry excerpt containing the match.
        sequence:
          type: integer
          format: int64
          minimum: 1
          description: >-
            The matched entry's position in its session's transcript — the
            same number `Entry.sequence` carries, and the one
            `listSessionEntries` pages on. It is what turns a hit into a place
            to open a conversation AT rather than a conversation to open:
            without it a client holding a match can name the session and not
            the moment, and has to read the transcript to find out where the
            match was. Optional, so a client written before it reads this page
            unchanged.
        kind:
          $ref: '#/components/schemas/FindableEntryKind'
        timestamp:
          type: string
          format: date-time
      examples:
        - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          entryId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
          sessionTitle: Fix flaky ledger test
          excerpt: '...the test fails only when the clock crosses midnight UTC...'
          sequence: 42
          kind: assistant-message
          timestamp: 2026-08-08T12:15:00Z

    RecordFileResult:
      type: object
      description: One session that touched the queried file.
      required: [sessionId, path, touchedAt]
      properties:
        sessionId:
          type: string
          format: uuid
        sessionTitle:
          type: string
          maxLength: 512
        path:
          type: string
          maxLength: 4096
        touchedAt:
          type: string
          format: date-time
          description: When the session last read or modified the file.
      examples:
        - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          sessionTitle: Fix flaky ledger test
          path: /Users/dev/repos/midaz/components/ledger/clock.go
          touchedAt: 2026-08-08T12:18:00Z

    UsageRow:
      type: object
      description: Consumption for one group over the queried period.
      required: [group, inputTokens, outputTokens, costUsd]
      properties:
        group:
          type: string
          description: >-
            The group key: a model id, a UTC day as YYYY-MM-DD, an absolute
            repository path (relative to the workspace on a hosted home, where
            a folder named `private` reads `./private`), a conversation id or
            a person's opaque subject,
            depending on groupBy. The literal `private` is the one key that
            names no repository: it is where the spend of every conversation
            the caller may not read lands when grouping by repository, so
            the total still reconciles without publishing a name the caller
            may not see. No repository key spells it.
        inputTokens:
          type: integer
          minimum: 0
        outputTokens:
          type: integer
          minimum: 0
        cacheReadTokens:
          type: integer
          minimum: 0
          description: >-
            Tokens served from the provider's prompt cache over the period.
            Absent for a period whose rows recorded none.
        cacheWriteTokens:
          type: integer
          minimum: 0
          description: >-
            Tokens written into the provider's prompt cache over the period.
            Absent for a period whose rows recorded none.
        costUsd:
          type: number
          minimum: 0
        costComplete:
          type: boolean
          description: >-
            False when at least one model round in this group recorded no
            cost at all, which makes costUsd a floor rather than the bill.
            Pricing is not resolved for every model, so a zero here can mean
            "free" or "unknown" — this field is what tells them apart, and a
            reader showing money must say so rather than print a total it
            cannot stand behind. Absent means the same as true.
        unpricedRounds:
          type: integer
          minimum: 1
          description: >-
            How many model rounds in this group recorded no cost at all —
            the size of the gap `costComplete: false` reports the existence
            of. One unpriced round in a thousand and a thousand out of a
            thousand are both floors, and only this number tells a reader
            which one they are looking at. Absent when every round in the
            group was priced, which is when costUsd is the whole of it.
      examples:
        - group: claude-sonnet-4-5
          inputTokens: 2400000
          outputTokens: 130000
          costUsd: 9.15
          costComplete: true

    SessionUsageReport:
      type: object
      description: >-
        One conversation's consumption, one page of it, grouped by the model
        each round was billed under and ordered by model name ascending.
      required: [sessionId, groupBy, rows]
      properties:
        sessionId:
          type: string
          format: uuid
        groupBy:
          type: string
          description: The dimension these rows are grouped by. model is the only one a single conversation has; it is echoed so a client reads the same envelope here as on the period report.
          enum:
            - model
          x-enum-varnames:
            - SessionUsageReportGroupByModel
        rows:
          type: array
          items:
            $ref: '#/components/schemas/UsageRow'
        nextCursor:
          type: string
          description: Pass as `cursor` for the next page. Absent on the last page.
      examples:
        - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          groupBy: model
          rows:
            - group: claude-opus-4
              inputTokens: 120000
              outputTokens: 8000
              costUsd: 2.4
            - group: claude-sonnet-4-5
              inputTokens: 2400000
              outputTokens: 130000
              costUsd: 9.15

    UsageReport:
      type: object
      description: >-
        Consumption over a period, broken down by the requested dimension.
        One page of it: the rows are ordered by their own group key
        ascending — the model name, the UTC day, the repository path, the
        conversation id or the person's subject, whichever was asked for —
        and the withheld `private` group, which has no key of its own, comes
        first. A key is unique per group by construction, so there is no tie
        to break and the cursor is simply the last key served.
      required: [from, to, groupBy, rows]
      properties:
        from:
          type: string
          format: date-time
        to:
          type: string
          format: date-time
        groupBy:
          type: string
          description: The dimension these rows are grouped by, echoed from the request.
          enum: [model, day, repository, conversation, person]
          x-enum-varnames:
            - UsageReportGroupByModel
            - UsageReportGroupByDay
            - UsageReportGroupByRepository
            - UsageReportGroupByConversation
            - UsageReportGroupByPerson
        rows:
          type: array
          items:
            $ref: '#/components/schemas/UsageRow'
        nextCursor:
          type: string
          description: >-
            Pass as `cursor` for the next page, with the same `groupBy`. A
            cursor issued for one grouping is refused by another rather than
            re-interpreted against a key it does not name. Absent on the
            last page.
      examples:
        - from: 2026-08-01T00:00:00Z
          to: 2026-08-08T00:00:00Z
          groupBy: model
          rows:
            - group: claude-sonnet-4-5
              inputTokens: 2400000
              outputTokens: 130000
              costUsd: 9.15

    EventBase:
      type: object
      description: >-
        Fields shared by every event on the stream. id is the SSE event id —
        send it back as Last-Event-ID to resume. sessionId and laneId are
        present when the event belongs to a session or lane.
      required: [id, type, timestamp]
      properties:
        id:
          type: string
          description: Monotonic per-stream event id, usable as Last-Event-ID.
        type:
          type: string
          description: The event type, discriminating the payload.
        sessionId:
          type: string
          format: uuid
        laneId:
          type: string
          format: uuid
        timestamp:
          type: string
          format: date-time
      examples:
        - id: '1024'
          type: turn-started
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:34:56Z

    Monitor:
      type: object
      description: >-
        One long-running watcher a session is keeping beside its conversation.
      required: [name, command, startedBy, startedAt]
      properties:
        name:
          type: string
          description: Unique within its session; this is what stops it.
        command:
          type: string
          description: What runs, under the session's repository.
        filter:
          type: string
          description: >-
            A regular expression keeping only the output lines that match it.
            Absent means every line.
        startedBy:
          type: string
          enum: [model, user]
          description: >-
            Which hand reached the switch. Both may, and the registry treats
            them identically; this says which one it was, so the model knows
            before it stops something it did not start.
        startedAt:
          type: string
          format: date-time
      examples:
        - name: tests
          command: go test ./... -run Ledger
          startedBy: model
          startedAt: 2026-08-23T12:01:03Z

    MonitorList:
      type: object
      description: A session's running monitors, in start order.
      required: [items, waking]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Monitor'
        waking:
          type: integer
          minimum: 0
          description: What monitor-list's waking says, as of this read.

    MonitorStart:
      type: object
      description: >-
        A request to run a watcher. Monitors started this way are recorded as
        the person's; the model starts its own through its tool.
      required: [name, command]
      properties:
        name:
          type: string
          description: >-
            The watcher's name. Bounded to what a URL path segment carries
            unchanged, because this same name is the address the request that
            stops the watcher is sent to.
          minLength: 1
          maxLength: 64
          pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*$'
        command:
          type: string
          minLength: 1
          maxLength: 4096
        filter:
          type: string
          maxLength: 512
        wake:
          type: boolean
          default: false
          description: >-
            Whether the watcher's output starts a turn on the conversation
            while nothing runs in it, paid by whoever started the watcher.
            Off, the output waits for the next turn. Nothing caps how many
            turns in a row it wakes: whoever turned wake on stops the watcher
            or turns it off. A turn under a spend ceiling cannot start a
            watcher with wake on; the turn it wakes holds no more tools than
            the turn that started it.
      examples:
        - name: tests
          command: go test ./... -run Ledger
          filter: ^(FAIL|ok)

    MonitorEventPayload:
      type: object
      description: >-
        Something a monitor said, ready to draw. text is the whole sentence,
        attributed to the monitor, in the words a person reads — the same form
        the transcript read serves, and a client prints it without asking for
        anything else. The model reads its own copy of the same event off the
        transcript, framed for a model rather than for a reader. The matching
        entry-appended event carries the durable entry this is the body of; a
        client that renders this one must not draw that one again.
      required: [monitor, notice, text, running]
      properties:
        monitor:
          type: string
        notice:
          type: string
          enum: [started, output, throttled, exited, stopped]
        text:
          type: string
        exit:
          type: integer
          description: The process's exit code, on the notice that reports it.
        running:
          type: array
          description: >-
            Every monitor this session is running after this event, by name.
            It rides each event so a status line and a session panel stay true
            without a read of their own.
          items:
            type: string
      examples:
        - monitor: tests
          notice: output
          text: "monitor \"tests\":\nFAIL ledger_test.go:12"
          running: [tests]

    MonitorEventEvent:
      description: Envelope for monitor-event.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, payload]
          properties:
            type:
              const: monitor-event
            payload:
              $ref: '#/components/schemas/MonitorEventPayload'
      examples:
        - id: '1206'
          type: monitor-event
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-23T12:01:03Z
          payload:
            monitor: tests
            notice: exited
            text: monitor "tests" ended on its own with exit code 1
            exit: 1
            running: []

    MonitorListPayload:
      type: object
      description: >-
        Which monitors a session is running, and nothing else. It carries no
        words, so no client draws it — it exists because the LIST must not wait
        for a turn to end while a monitor's WORDS must. A monitor's words go
        into the transcript, whose tail belongs to whatever turn is running, so
        a monitor-event is held until that turn ends; the running list is not
        part of the conversation and is announced the moment it changes. Sent on
        every change: a watcher starting, one being stopped, and one whose
        process ended by itself — the last of which nothing else reports until
        the turn is over.
      required: [running, waking]
      properties:
        running:
          type: array
          description: Every monitor this session is running, by name, in start order.
          items:
            type: string
        waking:
          type: integer
          minimum: 0
          description: >-
            How many watchers were started with wake on and have not yet had
            their last words land, plus the turns they woke that have not ended:
            each can start a turn on this conversation. `narya -p` exits only
            once this reads zero.
      examples:
        - running: [tests]
          waking: 1

    MonitorListEvent:
      description: Envelope for monitor-list.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, payload]
          properties:
            type:
              const: monitor-list
            payload:
              $ref: '#/components/schemas/MonitorListPayload'
      examples:
        - id: '1207'
          type: monitor-list
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-23T12:01:04Z
          payload:
            running: []
            waking: 0

    TaskListPayload:
      type: object
      description: >-
        The agent's task list for this session, whole, as it now stands.

        In `monitor-list`'s words and for its reason: the engine decides the
        list and the client draws it, so every change carries the entire list
        rather than a delta nobody can apply to a list they may not have. A
        client that missed one frame is corrected by the next, and one that
        attached late reads the same list off the session.

        Sent on every write, including the write that empties it — an agent
        that finishes and clears its list is telling the person something, and
        an empty array says it.
      required: [tasks]
      properties:
        tasks:
          type: array
          description: Every task this session has, in the order the agent wrote them.
          items:
            $ref: '#/components/schemas/Task'
      examples:
        - tasks:
            - content: Reproduce the flaky ledger test
              status: completed
            - content: Fix the race in the balance reader
              status: in_progress

    TaskListEvent:
      description: Envelope for task-list.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, payload]
          properties:
            type:
              const: task-list
            payload:
              $ref: '#/components/schemas/TaskListPayload'
      examples:
        - id: '1301'
          type: task-list
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-09-04T12:01:04Z
          payload:
            tasks:
              - content: Reproduce the flaky ledger test
                status: completed

    QueuedInputWithdrawnPayload:
      type: object
      description: >-
        A message that was waiting behind a running turn is no longer waiting,
        because somebody took it back (`DELETE
        /v1/sessions/{sessionId}/inputs/{entryId}`).

        It is the other half of entry-appended: a message ARRIVING in the queue
        is announced to every attached window, and so is one LEAVING it, so a
        second window on the same session stops drawing it.

        Written in the same transaction as the withdrawal itself, so a client
        is never told a message left a queue it is still in. A withdrawal that
        was refused — nothing was waiting under that id — emits nothing.

        The entry stays in the transcript with the reason it was set aside,
        which is what a client reads to draw it: this event names which message
        left and nothing more.
      required: [entryId]
      properties:
        entryId:
          type: string
          format: uuid
          description: >-
            The transcript entry whose queued message was withdrawn — the same
            id PendingInputReference carries, so a window holding several
            waiting messages knows which one to remove.
      examples:
        - entryId: 7c1e8b3a-2d4f-4a6b-8c9d-1e2f3a4b5c6d

    QueuedInputWithdrawnEvent:
      description: Envelope for queued-input-withdrawn.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, payload]
          properties:
            type:
              const: queued-input-withdrawn
            payload:
              $ref: '#/components/schemas/QueuedInputWithdrawnPayload'
      examples:
        - id: '1208'
          type: queued-input-withdrawn
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-23T12:02:10Z
          payload:
            entryId: 7c1e8b3a-2d4f-4a6b-8c9d-1e2f3a4b5c6d

    ScheduleFiredEvent:
      description: >-
        Envelope for schedule-fired. Host-level, never session-scoped: the
        session it names was created a moment ago, so nobody can be watching
        it yet, and that is the whole reason the event exists.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: schedule-fired
            payload:
              $ref: '#/components/schemas/ScheduleFiredPayload'
      examples:
        - id: '1209'
          type: schedule-fired
          timestamp: 2026-09-04T09:00:04Z
          payload:
            scheduleId: 8f2c1a4e-6b0d-4e2f-9a3c-5d7e1f0b2a4c
            sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d

    ScheduleFiredPayload:
      type: object
      description: >-
        The host's clock fired a schedule and the session it owed now exists,
        already running its turn. Both ids are here so a client can walk
        straight into the work — from the schedule that asked for it, to the
        session doing it — rather than polling a listing to notice that
        something started while nobody was looking.
      required: [scheduleId, sessionId]
      properties:
        scheduleId:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid

    ShareUpdatedEvent:
      description: >-
        Envelope for share-updated. Session-scoped.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, payload]
          properties:
            type:
              const: share-updated
            payload:
              $ref: '#/components/schemas/ShareUpdatedPayload'
      examples:
        - id: '1210'
          type: share-updated
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-09-08T09:20:00Z
          payload:
            shareId: K7QX2ZM4YB6PWNRJ5TFHCDA3EV
            throughSeq: 42

    ShareUpdatedPayload:
      type: object
      description: >-
        A share carried more of its session to the organisation's home, so whoever is watching re-reads the transcript.

        IT CARRIES NO CONVERSATION. The words are read back through the transcript; an event carrying them would be a second copy of a published conversation on a durable stream that replays forever.
      required: [shareId, throughSeq]
      properties:
        shareId:
          type: string
          minLength: 26
          maxLength: 26
          pattern: '^[A-Z2-7]{26}$'
          description: The share whose copy grew.
        throughSeq:
          type: integer
          format: int64
          minimum: 0
          description: >-
            The highest transcript position the organisation's home has
            confirmed it holds — the same number a recipient's own read of the
            transcript stops at, so a watcher can tell "there is more" from "I
            already have this" without a request.

    ShareReceivedEvent:
      description: >-
        Envelope for share-received: a delivery landed at THIS
        home and the copy of a shared session grew.

        IT BELONGS TO NO SESSION HERE, which is what separates it from
        share-updated. The conversation it names was published from another
        machine and no session of that id exists at this home, so the share id
        travels in the payload and the envelope carries no sessionId — making
        it a machine-wide event on the wire.

        GATED ANYWAY, AND BY THE SHARE. Having no session means the session
        gate cannot answer for it, so the host asks the share's own audience
        about every frame it writes, exactly as the read road does: a member
        no live share admits never receives it, on the machine-wide stream or
        on a narrowed one that asked for host events.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: share-received
            payload:
              $ref: '#/components/schemas/ShareReceivedPayload'
      examples:
        - id: '1211'
          type: share-received
          timestamp: 2026-09-08T09:21:00Z
          payload:
            shareId: K7QX2ZM4YB6PWNRJ5TFHCDA3EV
            throughSeq: 42

    ShareReceivedPayload:
      type: object
      description: >-
        The copy of a shared session at this home reaches a further position,
        so whoever may read it re-reads the entries.

        IT CARRIES NO CONVERSATION, for ShareUpdatedPayload's reason: the
        words come back through the share's entries, which is the one road
        that applies the audience rule and writes the disclosure record. An
        event carrying them would be a second copy of a published
        conversation on a durable stream that replays forever, recorded in
        nobody's audit.
      required: [shareId, throughSeq]
      properties:
        shareId:
          type: string
          minLength: 26
          maxLength: 26
          pattern: '^[A-Z2-7]{26}$'
          description: >-
            The share whose copy grew — what a reader asks this home about,
            there being no session here to ask about.
        throughSeq:
          type: integer
          format: int64
          minimum: 0
          description: >-
            How far into the conversation this home now reaches, in the
            publishing home's own numbering. A batch offering entries this
            home already held announces the same position again, which a
            watcher reads as "nothing new" rather than as a second copy.

    ReplayGapEvent:
      description: >-
        Envelope for replay-gap: some events this replay would have
        carried were forgotten, because their turn finished. It is written as
        a frame with no id, so it moves no Last-Event-ID; its envelope id is
        the cursor the replay started from, and its sessionId is the session
        the stream is narrowed to, absent on the machine-wide stream. A client
        whose last transcript read's lastEventId is below the payload's
        forgottenThrough re-reads the transcript from what it already holds and
        resumes the stream from that read's lastEventId; one whose read reached
        it already holds what was forgotten.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: replay-gap
            payload:
              $ref: '#/components/schemas/ReplayGapPayload'
      examples:
        - id: '1211'
          type: replay-gap
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-10-17T09:21:00Z
          payload:
            forgottenThrough: '1290'

    ReplayGapPayload:
      type: object
      description: >-
        How far the stream forgot. A position in the event sequence, as public
        as the cursor; it names no session, turn, type or count.
      required: [forgottenThrough]
      properties:
        forgottenThrough:
          type: string
          description: The highest event id the stream has forgotten.
      additionalProperties: false

    Event:
      description: >-
        The typed event envelope carried in each SSE frame's data field,
        discriminated by type.
      oneOf:
        - $ref: '#/components/schemas/SessionCreatedEvent'
        - $ref: '#/components/schemas/SessionStatusChangedEvent'
        - $ref: '#/components/schemas/SessionPostureChangedEvent'
        - $ref: '#/components/schemas/ModelSwitchEvent'
        - $ref: '#/components/schemas/TurnStartedEvent'
        - $ref: '#/components/schemas/MessageDeltaEvent'
        - $ref: '#/components/schemas/MessageStartEvent'
        - $ref: '#/components/schemas/MessageEndEvent'
        - $ref: '#/components/schemas/CompactionEvent'
        - $ref: '#/components/schemas/ContextPrunedEvent'
        - $ref: '#/components/schemas/EntryAppendedEvent'
        - $ref: '#/components/schemas/ToolCallStartedEvent'
        - $ref: '#/components/schemas/ToolCallUpdateEvent'
        - $ref: '#/components/schemas/ToolCallFinishedEvent'
        - $ref: '#/components/schemas/ToolCallReceiptEvent'
        - $ref: '#/components/schemas/PermissionAskedEvent'
        - $ref: '#/components/schemas/PermissionDecidedEvent'
        - $ref: '#/components/schemas/LaneStartedEvent'
        - $ref: '#/components/schemas/LaneFinishedEvent'
        - $ref: '#/components/schemas/DelegationsChangedEvent'
        - $ref: '#/components/schemas/TurnFinishedEvent'
        - $ref: '#/components/schemas/IntercomMessageEvent'
        - $ref: '#/components/schemas/WorkflowProgressEvent'
        - $ref: '#/components/schemas/LadderChangedEvent'
        - $ref: '#/components/schemas/CheckpointCreatedEvent'
        - $ref: '#/components/schemas/ExtensionEvent'
        - $ref: '#/components/schemas/MonitorEventEvent'
        - $ref: '#/components/schemas/MonitorListEvent'
        - $ref: '#/components/schemas/QueuedInputWithdrawnEvent'
        - $ref: '#/components/schemas/ScheduleFiredEvent'
        - $ref: '#/components/schemas/TaskListEvent'
        - $ref: '#/components/schemas/QuestionAskedEvent'
        - $ref: '#/components/schemas/SessionMovedEvent'
        - $ref: '#/components/schemas/CompactionStartedEvent'
        - $ref: '#/components/schemas/ShareUpdatedEvent'
        - $ref: '#/components/schemas/RefinementProposedEvent'
        - $ref: '#/components/schemas/CompactionFailedEvent'
        - $ref: '#/components/schemas/ShareReceivedEvent'
        - $ref: '#/components/schemas/ReplayGapEvent'
        - $ref: '#/components/schemas/ModelSilenceEvent'
      discriminator:
        propertyName: type
        mapping:
          session-created: '#/components/schemas/SessionCreatedEvent'
          session-status-changed: '#/components/schemas/SessionStatusChangedEvent'
          session-posture-changed: '#/components/schemas/SessionPostureChangedEvent'
          model-switch: '#/components/schemas/ModelSwitchEvent'
          turn-started: '#/components/schemas/TurnStartedEvent'
          message-delta: '#/components/schemas/MessageDeltaEvent'
          message-start: '#/components/schemas/MessageStartEvent'
          message-end: '#/components/schemas/MessageEndEvent'
          compaction: '#/components/schemas/CompactionEvent'
          context-pruned: '#/components/schemas/ContextPrunedEvent'
          entry-appended: '#/components/schemas/EntryAppendedEvent'
          tool-call-started: '#/components/schemas/ToolCallStartedEvent'
          tool-call-update: '#/components/schemas/ToolCallUpdateEvent'
          tool-call-finished: '#/components/schemas/ToolCallFinishedEvent'
          tool-call-receipt: '#/components/schemas/ToolCallReceiptEvent'
          permission-asked: '#/components/schemas/PermissionAskedEvent'
          permission-decided: '#/components/schemas/PermissionDecidedEvent'
          lane-started: '#/components/schemas/LaneStartedEvent'
          lane-finished: '#/components/schemas/LaneFinishedEvent'
          delegations-changed: '#/components/schemas/DelegationsChangedEvent'
          turn-finished: '#/components/schemas/TurnFinishedEvent'
          intercom-message: '#/components/schemas/IntercomMessageEvent'
          workflow-progress: '#/components/schemas/WorkflowProgressEvent'
          ladder-changed: '#/components/schemas/LadderChangedEvent'
          checkpoint-created: '#/components/schemas/CheckpointCreatedEvent'
          extension: '#/components/schemas/ExtensionEvent'
          monitor-event: '#/components/schemas/MonitorEventEvent'
          monitor-list: '#/components/schemas/MonitorListEvent'
          queued-input-withdrawn: '#/components/schemas/QueuedInputWithdrawnEvent'
          schedule-fired: '#/components/schemas/ScheduleFiredEvent'
          task-list: '#/components/schemas/TaskListEvent'
          question-asked: '#/components/schemas/QuestionAskedEvent'
          session-moved: '#/components/schemas/SessionMovedEvent'
          compaction-started: '#/components/schemas/CompactionStartedEvent'
          share-updated: '#/components/schemas/ShareUpdatedEvent'
          refinement-proposed: '#/components/schemas/RefinementProposedEvent'
          compaction-failed: '#/components/schemas/CompactionFailedEvent'
          share-received: '#/components/schemas/ShareReceivedEvent'
          replay-gap: '#/components/schemas/ReplayGapEvent'
          model-silence: '#/components/schemas/ModelSilenceEvent'

    SessionCreatedPayload:
      type: object
      description: A session came into existence.
      required: [session]
      properties:
        session:
          $ref: '#/components/schemas/Session'
      examples:
        - session:
            id: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
            repository: /Users/dev/repos/midaz
            status: idle
            clientKind: inline
            createdAt: 2026-08-08T12:00:00Z
            updatedAt: 2026-08-08T12:00:00Z

    SessionCreatedEvent:
      description: Envelope for session-created.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: session-created
            payload:
              $ref: '#/components/schemas/SessionCreatedPayload'
      examples:
        - id: '1001'
          type: session-created
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:00:00Z
          payload:
            session:
              id: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              repository: /Users/dev/repos/midaz
              status: idle
              clientKind: inline
              createdAt: 2026-08-08T12:00:00Z
              updatedAt: 2026-08-08T12:00:00Z

    SessionStatusChangedPayload:
      type: object
      description: A session's status changed — the panel's live indicator.
      required: [status]
      properties:
        status:
          type: string
          enum: [idle, running, waiting, archived]
        previousStatus:
          type: string
          enum: [idle, running, waiting, archived]
      examples:
        - status: waiting
          previousStatus: running

    SessionStatusChangedEvent:
      description: Envelope for session-status-changed.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: session-status-changed
            payload:
              $ref: '#/components/schemas/SessionStatusChangedPayload'
      examples:
        - id: '1002'
          type: session-status-changed
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:20:00Z
          payload:
            status: waiting
            previousStatus: running

    SessionPostureChangedPayload:
      type: object
      description: >-
        Somebody recorded whether anybody is watching this session — the
        promotion gesture (`narya session promote`, and the answer to the
        clients' exit prompt) seen from the stream.

        It is emitted only when the posture actually MOVED, so recording a
        posture a session already holds is silent. Nothing infers this event:
        a client connecting, crashing or disconnecting never produces one,
        because attendance is asserted and never inferred.
      required: [posture]
      properties:
        posture:
          $ref: '#/components/schemas/Posture'
        previousPosture:
          $ref: '#/components/schemas/Posture'
      examples:
        - posture: detached

    SessionPostureChangedEvent:
      description: Envelope for session-posture-changed.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: session-posture-changed
            payload:
              $ref: '#/components/schemas/SessionPostureChangedPayload'
      examples:
        - id: '1003'
          type: session-posture-changed
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T23:12:00Z
          payload:
            posture: detached

    SessionMovedPayload:
      type: object
      description: >-
        The session is working in another directory (`moveSession`):
        from the next turn onwards its tools are confined to the new root, and
        every surface that names the directory a session is on is now naming
        the wrong one until it folds this in.

        It is emitted only when the path actually MOVED, so re-homing a
        session to the directory it already stands in is silent. Nothing
        infers it either: a session is never observed to have moved, it is
        moved by a call.

        What it does not carry, because the response to that call does: the
        session's restore points were dropped by the move. A client drawing a
        "go back to how the files were" surface re-reads it after this event
        rather than reasoning from the two paths.
      required: [repository, previousRepository]
      properties:
        repository:
          type: string
          description: >-
            The directory the session works in now: an absolute path on a
            local home, the path relative to its workspace on a hosted home. `previousRepository` is spelled the same way.
        previousRepository:
          type: string
          description: >-
            The directory it left. Always present, unlike
            `SessionPostureChangedPayload.previousPosture`: a session cannot
            exist without a directory, so there is no "never stated" case
            here.
      examples:
        - repository: /srv/code/api-feat-settlement
          previousRepository: /srv/code/api

    SessionMovedEvent:
      description: Envelope for session-moved.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: session-moved
            payload:
              $ref: '#/components/schemas/SessionMovedPayload'
      examples:
        - id: '1101'
          type: session-moved
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-09-05T10:00:00Z
          payload:
            repository: /srv/code/api-feat-settlement
            previousRepository: /srv/code/api

    ModelSwitchPayload:
      type: object
      description: >-
        The session's provider/model pair moved — mid-session model switching,
        whether a person picked the new model or the host substituted it. The
        pair named here is the one the NEXT turn will run on.
      required: [provider, model]
      properties:
        provider:
          type: string
          description: The provider the session is on now, e.g. anthropic.
        model:
          type: string
          description: The model id the session is on now, as the catalogue names it.
      examples:
        - provider: anthropic
          model: claude-sonnet-4-5

    ModelSwitchEvent:
      description: Envelope for model-switch.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: model-switch
            payload:
              $ref: '#/components/schemas/ModelSwitchPayload'
      examples:
        - id: '1003'
          type: model-switch
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:25:00Z
          payload:
            provider: anthropic
            model: claude-sonnet-4-5

    TurnStartedPayload:
      type: object
      description: A turn began executing.
      required: [turnId]
      properties:
        turnId:
          type: string
          format: uuid
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a

    TurnStartedEvent:
      description: Envelope for turn-started.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: turn-started
            payload:
              $ref: '#/components/schemas/TurnStartedPayload'
      examples:
        - id: '1003'
          type: turn-started
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:00Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a

    MessageDeltaPayload:
      type: object
      description: >-
        A fragment of streamed text from the ASSISTANT. With no channel it is
        the answer — what the model said. With channel `reasoning` it is the
        model's own thinking, which is activity rather than answer: a client may
        show that thinking is happening, and must never fold it into what the
        assistant said. The opaque provider material that makes a reasoning
        block replayable never travels on this event.

        A running tool call's own output travels on `tool-call-update`, not
        here. `toolCallId` and channel `display` stay declared because older
        recorded events carry them and replay forever; nothing emits them now.
      required: [turnId, delta]
      properties:
        turnId:
          type: string
          format: uuid
        delta:
          type: string
          description: The next fragment of the message text.
        toolCallId:
          type: string
          description: >-
            Retired. Present only on events recorded before a running tool
            call's output moved to `tool-call-update`.
        channel:
          type: string
          enum: [display, reasoning]
          description: >-
            Which channel this fragment came from. `reasoning` is the model
            thinking aloud; absent means the assistant's answer. `display` is
            retired — it marked a running tool call's own output, which is now
            its own event, and appears only on events recorded before that
            change.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          delta: 'The test is flaky because '

    MessageDeltaEvent:
      description: Envelope for message-delta.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: message-delta
            payload:
              $ref: '#/components/schemas/MessageDeltaPayload'
      examples:
        - id: '1004'
          type: message-delta
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:02Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            delta: 'The test is flaky because '

    MessageStartPayload:
      type: object
      description: >-
        One assistant message is about to be generated: the request is built and
        one call away from the provider. It names the provider and model this
        round will actually be answered by, which is what lets a cost or audit
        consumer attribute the round before its first token rather than after
        the turn.

        A turn has one of these per round — an answer, a tool-calling round, an
        answer again are three rounds and three starts. The summarisation round
        a compaction sends is not a message and produces none.

        One round is not always ONE message, though: see message-end. Count
        committed messages by their ends, never by their starts.
      required: [turnId, provider, model]
      properties:
        turnId:
          type: string
          format: uuid
        provider:
          type: string
          description: The provider this round is being sent to.
        model:
          type: string
          description: The model id this round is being answered by.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          provider: anthropic
          model: claude-sonnet-4-5

    MessageStartEvent:
      description: Envelope for message-start.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: message-start
            payload:
              $ref: '#/components/schemas/MessageStartPayload'
      examples:
        - id: '1005'
          type: message-start
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:01Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            provider: anthropic
            model: claude-sonnet-4-5

    MessageEndPayload:
      type: object
      description: >-
        One assistant message is durably committed, named by the transcript
        entry that holds it. It is a NOTIFICATION of a fact already recorded: a
        consumer cannot revise, replace or reject the message, because it landed
        before this event existed.

        A round that died before committing anything emits no end at all, so a
        start is not guaranteed a partner. The turn's own outcome is
        turn-finished's.

        One round can also commit TWO messages, and then emits two ends under
        its one start: a round whose stream dies mid-answer on a context
        overflow keeps the words it already said, and the retry that follows
        the compaction commits its own answer beside them. Count ends per
        committed message, never per start — every end names the transcript
        entry it is about.
      required: [turnId, entryId]
      properties:
        turnId:
          type: string
          format: uuid
        entryId:
          type: string
          format: uuid
          description: The transcript entry this message committed as.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          entryId: 7c1d3e5f-0a2b-4c6d-8e0f-1a3b5c7d9e2f

    MessageEndEvent:
      description: Envelope for message-end.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: message-end
            payload:
              $ref: '#/components/schemas/MessageEndPayload'
      examples:
        - id: '1006'
          type: message-end
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:09Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            entryId: 7c1d3e5f-0a2b-4c6d-8e0f-1a3b5c7d9e2f

    ModelSilencePayload:
      type: object
      description: >-
        The model of a running round has produced no piece of the answer
        (text, visible thinking, or a tool call's arguments) for silentMinutes
        whole minutes, counted from the moment the round's request was sent
        or from its last piece. It repeats once per minute of continued
        silence, so silence is never mute; no bound changes with it (the round
        still ends where it would).

        A notice with silentMinutes 0 closes the silence the earlier ones
        reported: a piece of the answer arrived, or the round ended. A round
        that was never silent for a minute emits none at all.

        Session-and-lane scoped, and a turn's own event, forgotten with it.
      required: [turnId, silentMinutes, attempt, attempts]
      properties:
        turnId:
          type: string
          format: uuid
        silentMinutes:
          type: integer
          minimum: 0
          description: >-
            Whole minutes this round has gone without a piece of the answer;
            0 ends the silence.
        attempt:
          type: integer
          minimum: 1
          description: >-
            Which try of its round chain this round is: a stream that dropped
            or was cut is sent again, and the retry is attempt 2.
        attempts:
          type: integer
          minimum: 1
          description: >-
            The most tries the chain may take: 3, with or without a spend ceiling.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          silentMinutes: 2
          attempt: 2
          attempts: 3

    ModelSilenceEvent:
      description: Envelope for model-silence.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, payload]
          properties:
            type:
              const: model-silence
            payload:
              $ref: '#/components/schemas/ModelSilencePayload'
      examples:
        - id: '1007'
          type: model-silence
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:02:01Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            silentMinutes: 1
            attempt: 1
            attempts: 3

    CompactionPayload:
      type: object
      description: >-
        The session's transcript was summarised: what the model is working from
        is no longer a straight append of everything that happened, and this
        entry is where the older part now lives.

        reason says what brought it about. `threshold` is the projection
        refusing to send a round that would not fit — the designed path, no
        request wasted. `overflow` is the provider having refused a request the
        projection passed, which means the estimate was wrong and the round
        cost a round-trip to discover it. `behavioural` is a round that
        SUCCEEDED and still carried a signature of overflow: the provider never
        said so, and the evidence was in what came back. `manual` is a person
        having asked for it by name through `compactLane`, with nothing
        overflowing at all.
      required: [turnId, reason, entryId]
      properties:
        turnId:
          type: string
          format: uuid
          description: >-
            The turn this compaction happened inside. A `manual` compaction
            runs between turns, so there is no turn to name and this carries
            the compaction's own operation id instead — a correlation handle
            for its durable writes, and never the id of any turn.
        reason:
          type: string
          enum: [threshold, overflow, behavioural, manual]
          description: >-
            `threshold`: the pre-send projection said the next round no longer
            fits. `overflow`: the provider refused the request.
            `behavioural`: the round succeeded and its own shape said the
            window was already full. `manual`: somebody asked for it.
        entryId:
          type: string
          format: uuid
          description: The summary entry that replaced the summarised history.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          reason: threshold
          entryId: 9e2f1a3b-5c7d-4e0f-8a2b-4c6d8e0f1a3b

    CompactionEvent:
      description: Envelope for compaction.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: compaction
            payload:
              $ref: '#/components/schemas/CompactionPayload'
      examples:
        - id: '1008'
          type: compaction
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:02:00Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            reason: threshold
            entryId: 9e2f1a3b-5c7d-4e0f-8a2b-4c6d8e0f1a3b

    CompactionStartedPayload:
      type: object
      description: >-
        A compaction has BEGUN: the host has decided the transcript must be
        summarised and is about to spend a model round doing it. Nothing is
        summarised yet and nothing is committed — this is the moment the
        decision was taken and the money is about to be spent.

        Its own event type rather than a `compaction` with an absent
        `entryId`, deliberately and for the reason `ContextPrunedPayload`
        states about itself: every consumer counting compactions would have
        counted this one.

        HOW IT ENDS, stated plainly because a client that guessed would leave
        a word on screen forever: a compaction that commits announces
        `compaction` for the same turn, and one that does NOT commit, for any
        reason, announces `compaction-failed` for the same operation. So
        whatever state a client opens here is cleared on that `compaction`,
        on `compaction-failed`, or on `turn-finished` for the same turn,
        whichever comes first. No timer, nothing guessed.

        It is emitted for `manual` too, where the client that asked for the
        compaction is already showing the state and this changes nothing on
        its screen. One funnel, one event: the OTHER window on the same
        session has no other way to learn that a round is running.

        Session-and-lane scoped, exactly like `compaction`.
      required: [turnId, reason]
      properties:
        turnId:
          type: string
          format: uuid
          description: >-
            The turn this compaction is running inside. A `manual` compaction
            runs between turns, so there is no turn to name and this carries
            the compaction's own operation id instead — the same value
            `CompactionPayload.turnId` carries, so a start and its commit
            correlate on one field.
        reason:
          type: string
          enum: [threshold, overflow, behavioural, manual]
          description: >-
            Why it started, in `CompactionPayload.reason`'s own four words and
            with its meanings: `threshold` the pre-send projection refusing a
            round that would not fit, `overflow` the provider having refused
            one the projection passed, `behavioural` a round that succeeded
            while showing a signature of overflow, `manual` somebody having
            asked.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          reason: threshold

    CompactionStartedEvent:
      description: Envelope for compaction-started.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: compaction-started
            payload:
              $ref: '#/components/schemas/CompactionStartedPayload'
      examples:
        - id: '1102'
          type: compaction-started
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:01:52Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            reason: threshold

    CompactionFailedPayload:
      type: object
      description: >-
        A compaction ENDED without committing anything: the round its
        `compaction-started` announced is over, no summary entry was written,
        and the conversation is exactly what it was before.

        WHAT IT COVERS is every ending of every reason that is not a commit —
        a summarisation round the provider refused, a summary the store would
        not write, and a window with nothing eligible to summarise, which is a
        compaction that announces its start and then finds no material. A
        `manual` round runs BETWEEN turns and has no `turn-finished` to close
        it; a round inside a turn is often followed by one that answers
        uncompacted, and this ends the word before that answer arrives.

        It carries no cause. What went wrong reaches the person who asked, in
        the answer to their own call; every other window is being told one
        thing, which is that the round is over.

        Session-and-lane scoped, exactly like `compaction-started`.
      required: [turnId, reason]
      properties:
        turnId:
          type: string
          format: uuid
          description: >-
            The compaction this ends, named by the same id its
            `compaction-started` carried. A `manual` compaction runs between
            turns, so this is the compaction's own operation id and never a
            turn's — one field pairs a start with its ending, exactly as it
            pairs a start with its commit.
        reason:
          type: string
          enum: [threshold, overflow, behavioural, manual]
          description: >-
            Why the compaction had started, in `CompactionStartedPayload`'s
            own four words, so a consumer counting rounds by reason can settle
            this one without having held the start.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          reason: manual

    CompactionFailedEvent:
      description: Envelope for compaction-failed.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: compaction-failed
            payload:
              $ref: '#/components/schemas/CompactionFailedPayload'
      examples:
        - id: '1103'
          type: compaction-failed
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:01:57Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            reason: manual

    ContextPrunedPayload:
      type: object
      description: >-
        Old tool output was taken out of a lane's window: the model stops
        re-reading results the conversation has moved past, and nothing was
        summarised or destroyed to achieve it. The transcript still holds every
        result in full, and a client renders them exactly as before — only what
        the model is re-sent shrinks.

        Its own event type rather than a `compaction` with another reason,
        deliberately: a prune writes no summary entry, so it has nothing to put
        in `CompactionPayload.entryId`, and every consumer counting compactions
        would have counted this one.

        The same event fires whether a person asked through `pruneLane` or the
        host's own threshold pruned before summarising, because a client
        watching a lane needs the same fact either way.

        It carries no token figure. Nobody can say what the shrunken window
        costs until a provider counts it, so a client showing a context reading
        should drop it and wait for the next metered round — the same silence
        the `compaction` event asks for.
        It also carries no origin. A prune the host ran before considering a
        summary and a prune somebody typed are the same change to the same
        window, and the one person who could act on the difference is the one
        who just typed it. `compaction` carries a reason because its four
        triggers cost different things; a prune costs nothing however it
        started.
      required: [count]
      properties:
        count:
          type: integer
          minimum: 1
          description: >-
            How many tool results were set aside. Never zero: a prune that
            found nothing eligible writes nothing and announces nothing.
      examples:
        - count: 12

    ContextPrunedEvent:
      description: Envelope for context-pruned.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, laneId, payload]
          properties:
            type:
              const: context-pruned
            payload:
              $ref: '#/components/schemas/ContextPrunedPayload'
      examples:
        - id: '1009'
          type: context-pruned
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:02:04Z
          payload:
            count: 12

    ToolCallUpdatePayload:
      type: object
      description: >-
        One chunk of a RUNNING tool call's own output — a shell command's bytes
        as they arrive — named for the call it belongs under. A client renders it
        beneath that call's line, never as the assistant speaking; a chunk for a
        call the client never saw start has no line to sit under and is dropped
        rather than shown.

        The bytes are the tool's, not the model's: they are unsanitized on the
        wire and a client that draws them to a terminal owns that boundary.
      required: [turnId, toolCallId, delta]
      properties:
        turnId:
          type: string
          format: uuid
        toolCallId:
          type: string
          description: The running call whose output this is.
        delta:
          type: string
          description: The next chunk the tool wrote.
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          toolCallId: call_01
          delta: "ok  \tgithub.com/acme/ledger/internal/api\t0.412s\n"

    ToolCallUpdateEvent:
      description: Envelope for tool-call-update.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: tool-call-update
            payload:
              $ref: '#/components/schemas/ToolCallUpdatePayload'
      examples:
        - id: '1009'
          type: tool-call-update
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:30Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            toolCallId: call_01
            delta: "ok  \tgithub.com/acme/ledger/internal/api\t0.412s\n"

    EntryAppendedPayload:
      type: object
      description: >-
        Stable identity for one transcript entry that committed durably. The
        event envelope identifies its session and lane; turnId is present when
        the entry belongs to a turn.
      required: [entryId, kind]
      properties:
        entryId:
          type: string
          format: uuid
        kind:
          type: string
          enum: [user-message, assistant-message, tool-call, tool-result, summary, system, intercom, monitor-event]
        turnId:
          type: string
          format: uuid
        notice:
          type: string
          enum: [turn-failed]
          description: >-
            Present on the system note a failed turn leaves with its cause. The turn's turn-finished carries the same failure, so a
            window attached to the stream draws it from that event and does not
            read this entry; a transcript read shows it like any other note.
      examples:
        - entryId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
          kind: user-message
          turnId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c

    EntryAppendedEvent:
      description: Envelope for entry-appended.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [sessionId, laneId, payload]
          properties:
            type:
              const: entry-appended
            payload:
              $ref: '#/components/schemas/EntryAppendedPayload'
      examples:
        - id: '1005'
          type: entry-appended
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:01:03Z
          payload:
            entryId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
            kind: user-message
            turnId: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c

    ToolCallStartedPayload:
      type: object
      description: The agent began executing a tool call.
      required: [turnId, toolCallId, tool]
      properties:
        turnId:
          type: string
          format: uuid
        toolCallId:
          type: string
        tool:
          type: string
          description: The tool's name.
        input:
          type: object
          description: >-
            A preview of the tool call's input as the model produced it: any
            top-level string value over 512 runes is truncated with a
            trailing ellipsis. The full, untruncated input is durable in the
            tool-call transcript entry this event announces — this field
            exists for a receipt or sidebar preview, not as a second copy of
            arbitrarily large arguments on the broadcast event stream.
          additionalProperties: true
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          toolCallId: call_01
          tool: shell
          input:
            command: go test ./components/ledger/...

    ToolCallStartedEvent:
      description: Envelope for tool-call-started.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: tool-call-started
            payload:
              $ref: '#/components/schemas/ToolCallStartedPayload'
      examples:
        - id: '1005'
          type: tool-call-started
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:10Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            toolCallId: call_01
            tool: shell
            input:
              command: go test ./components/ledger/...

    ToolCallFinishedPayload:
      type: object
      description: >-
        A tool call finished, with its outcome.


        IT IS ALSO WHAT RETIRES A QUESTION. `ask_user` is a tool, so a call that
        put questions to the person (question-asked, carrying this same
        toolCallId) finishes when they are answered, when its turn is
        interrupted, and when a starting host closes what a dead one left
        pending. A client showing the prompt drops it here, on the call it
        already joined on, and no question-decided event exists because it
        would say nothing this one does not.
      required: [turnId, toolCallId, tool, status]
      properties:
        turnId:
          type: string
          format: uuid
        toolCallId:
          type: string
        tool:
          type: string
        status:
          type: string
          enum: [ok, error, denied, cancelled]
        durationMs:
          type: integer
          minimum: 0
        failure:
          type: string
          description: >-
            Why the call failed, in the words the model was given, bounded by
            the host. Present only for a status other than `ok`, and absent
            when the tool said nothing beyond failing. It exists so a client
            can show the reason at the moment the call fails: only a streaming
            tool sends progress chunks, so every other failed call settled a
            red line with nothing under it, and the reason was reachable only
            by reopening the session's durable record.
        fileTouches:
          type: array
          description: >-
            The rows this result produced (reads and successful mutations
            only).
          items:
            $ref: '#/components/schemas/FileTouch'
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          toolCallId: call_01
          tool: shell
          status: ok
          durationMs: 42000

    ToolCallFinishedEvent:
      description: Envelope for tool-call-finished.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: tool-call-finished
            payload:
              $ref: '#/components/schemas/ToolCallFinishedPayload'
      examples:
        - id: '1006'
          type: tool-call-finished
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:01:52Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            toolCallId: call_01
            tool: shell
            status: ok
            durationMs: 42000

    PermissionAskedPayload:
      type: object
      description: A sensitive action is waiting on a decision.
      required: [permissionId, action, resource]
      properties:
        permissionId:
          type: string
          format: uuid
        action:
          type: string
        resource:
          type: string
          maxLength: 4096
        detail:
          type: string
          maxLength: 4096
          description: Human-readable presentation, including the rule a remembered allow would grant.
        toolCallId:
          type: string
          description: >-
            The tool call this ask is blocking, when one raised it — the id
            tool-call-started already announced. It is the JOIN that lets a
            client show what is being decided rather than only its name: the
            call's own input is already on that earlier event, so a reader
            asked to approve an edit can be shown the patch instead of a path.
            Absent when no tool call raised the ask, and absent on the pending
            listing a reattaching client reads, so a client renders the ask
            from action, resource and detail alone whenever it cannot resolve
            the call.
      examples:
        - permissionId: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
          action: shell
          resource: 'git push origin main'
          detail: 'Allow running: git push origin main'
          toolCallId: call_01

    PermissionAskedEvent:
      description: Envelope for permission-asked.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: permission-asked
            payload:
              $ref: '#/components/schemas/PermissionAskedPayload'
      examples:
        - id: '1007'
          type: permission-asked
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:20:00Z
          payload:
            permissionId: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
            action: shell
            resource: 'git push origin main'

    PermissionDecidedPayload:
      type: object
      description: >-
        A permission ask was settled — answered by a human, resolved by
        policy, or closed unanswered. A client drops the prompt on any of
        the three: the question can no longer be answered.
      required: [permissionId, decision, decidedBy]
      properties:
        permissionId:
          type: string
          format: uuid
        decision:
          type: string
          description: >-
            `expired` is the ask nobody decided — the turn was interrupted,
            the tool call's own deadline passed, or the question could not
            be delivered to anybody. It is not a refusal: the action did not
            run, and no answer was given about it. Why it closed is on the
            audit row, which carries a reason; this stream carries only the
            fact, so a client can retire the prompt. The same word the
            decisions listing already filters by (`GET
            /permissions/decisions?decision=expired`), for the same state.
          enum: [allow, deny, expired]
        decidedBy:
          type: string
          enum: [user, policy, hook]
        decidedBySubject:
          type: string
          maxLength: 256
          description: >-
            WHICH person answered, when a person did. Any member may decide a
            permission request raised on a conversation that is not marked
            private, so the owner of a run released while they were away learns
            from this event that it happened AND who did it — without which the
            announcement says only that their own question is gone.


            Absent when no person decided (a rule, a hook, or an expiry) and on
            any host that did not record who. Never read as the owner.
      examples:
        - permissionId: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
          decision: deny
          decidedBy: user
          decidedBySubject: user_01HZX3Q8N4KDPYV2A7C9M5T6RB

    PermissionDecidedEvent:
      description: Envelope for permission-decided.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: permission-decided
            payload:
              $ref: '#/components/schemas/PermissionDecidedPayload'
      examples:
        - id: '1008'
          type: permission-decided
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:21:00Z
          payload:
            permissionId: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
            decision: deny
            decidedBy: user

    QuestionOption:
      type: object
      description: >-
        One answer a person may pick.

        `recommended` is a FIELD rather than a convention: the alternative is
        the model typing "(recommended)" into a label, which puts narya's own
        vocabulary inside text the model authored and leaves a client parsing
        for it.
      required: [value]
      properties:
        value:
          type: string
          description: The option as the person reads it.
        recommended:
          type: boolean
          description: >-
            The agent's own preference. The engine has already moved the
            recommended option to the front of the list, so a client drawing
            the options in the order it received them draws them right — and a
            client must never reorder, because the indices it sends back are
            the engine's.
        preview:
          type: string
          description: >-
            What this option would mean, at length, for the side-by-side pane a
            client draws beside the option list. Absent when the option speaks
            for itself.
        other:
          type: boolean
          description: >-
            The free-text row, which the ENGINE adds to every question and the
            model never writes. A client draws it as the row that takes typing;
            an answer naming its index is an answer somebody typed, and the
            typed text rides the same answer's `text`.

    Question:
      type: object
      description: >-
        One question the agent is putting to the person. Up to four
        ride in one call, and the answer names which option index was chosen
        rather than repeating its label, so the model can tell a pick from a
        typed sentence.
      required: [question, options]
      properties:
        header:
          type: string
          description: A short chip naming what this question is about.
        question:
          type: string
          description: The question itself.
        multiple:
          type: boolean
          description: >-
            More than one option may be chosen. Absent or false is a single
            choice, and a client draws checkboxes only when this says so.
        options:
          type: array
          description: >-
            The options in the order they must be drawn — recommended first,
            free-text row last, both placed by the engine. Two to four options
            the model wrote, plus that row.
          items:
            $ref: '#/components/schemas/QuestionOption'

    QuestionAskedPayload:
      type: object
      description: >-
        The agent is asking the person to choose, and its turn is blocked until
        somebody does.

        It is NOT a permission ask and does not reuse one: `action`,
        `resource`, `status`, `decision` and `remember` are all either
        meaningless or lying about a question, and a client would have to
        branch inside the permission box on whether the ask it is drawing is
        really a permission. The engine shares one table and one desk with the
        permission road, where sharing is cheap; the contract says what the
        thing is.

        THERE IS NO `question-decided` EVENT. The call that raised the question
        is a tool call, so its closure — answered, interrupted, or closed by a
        starting host after this one died — rides `tool-call-finished` for the
        call named by `toolCallId`, and a client showing the prompt retires it
        there. On a product session the call finishes as it asks and its turn
        ends, so the prompt retires instead when answerQuestion answers it, when
        the person sends a message on its lane, or when listQuestions no
        longer lists it.
      required: [questionId, questions]
      properties:
        questionId:
          type: string
          format: uuid
          description: >-
            The question's id, which is what an answer is posted against
            (`POST /v1/questions/{questionId}/answer`).
        toolCallId:
          type: string
          description: >-
            The tool call this question is blocking — the id
            tool-call-started already announced, and the same JOIN key
            PermissionAskedPayload documents. It is how a client retires the
            prompt when the call finishes, and how it shows the question beside
            the work that raised it. Absent when no tool call raised it, which
            is not the ordinary road.
        questions:
          type: array
          description: >-
            The questions, in the order they were asked and with their options
            already ordered. An answer is matched to its question by position.
          items:
            $ref: '#/components/schemas/Question'
      examples:
        - questionId: 4c2e9a71-5b3d-4e8f-9a1c-6d0b2f4e8a3c
          toolCallId: call_01
          questions:
            - header: Storage
              question: Where should the export land?
              options:
                - value: A new table
                  recommended: true
                - value: A file on disk
                  preview: One CSV per run under ./exports.
                - value: Something else
                  other: true

    QuestionAskedEvent:
      description: Envelope for question-asked.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: question-asked
            payload:
              $ref: '#/components/schemas/QuestionAskedPayload'
      examples:
        - id: '1302'
          type: question-asked
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-09-04T12:02:00Z
          payload:
            questionId: 4c2e9a71-5b3d-4e8f-9a1c-6d0b2f4e8a3c
            toolCallId: call_01
            questions:
              - question: Where should the export land?
                options:
                  - value: A new table
                    recommended: true
                  - value: Something else
                    other: true

    QuestionAnswer:
      type: object
      description: >-
        What the person chose for ONE question, by position in the asked list.
      required: [options]
      properties:
        options:
          type: array
          description: >-
            The indices of the options chosen, into the option list exactly as
            the engine sent it. Several only when the question said `multiple`.
            Empty is "this one was not answered", which the agent is told
            rather than left to infer from a short list.
          items:
            type: integer
            minimum: 0
        text:
          type: string
          description: >-
            What the person typed into the free-text row. It counts only when
            that row's index is among `options`; typed text with the row
            unchosen, and the row chosen with nothing typed, are both no
            answer at all.
      examples:
        - options: [0]

    QuestionAnswerRequest:
      type: object
      description: >-
        One person's answer to every question of one `ask_user` call, in the
        order the questions were asked — position is the join, because a
        question carries no id of its own.
      required: [answers]
      properties:
        answers:
          type: array
          description: One entry per question asked, in that order.
          minItems: 1
          maxItems: 4
          items:
            $ref: '#/components/schemas/QuestionAnswer'
      examples:
        - answers:
            - options: [0]
            - options: [2]
              text: Keep both and reconcile nightly.

    PendingQuestion:
      type: object
      description: >-
        One question standing on a conversation with nobody having answered it — the same ask `question-asked` announced, read back by a client
        that was not there to hear it.


        It carries what the announcement carries, so a client draws a
        recovered question through the code path the live event already goes
        through, plus the two facts an event envelope supplied and a listing
        row has to say for itself: which conversation it is standing on, and
        when it was asked.
      required: [questionId, sessionId, questions, askedAt]
      properties:
        questionId:
          type: string
          format: uuid
          description: >-
            The question's id, which is what an answer is posted against
            (`POST /v1/questions/{questionId}/answer`).
        sessionId:
          type: string
          format: uuid
        laneId:
          type: string
          format: uuid
          description: The lane whose work raised the question, when not the main lane.
        toolCallId:
          type: string
          description: >-
            The tool call this question is blocking — the same id
            `question-asked` carries, and the JOIN a client retires the prompt
            on: there is no question-decided event, so the box goes down when
            `tool-call-finished` names this call, whoever answered it and from
            which client. Absent only for a question no tool call raised,
            which is not the ordinary road.
        questions:
          type: array
          description: >-
            The questions, in the order they were asked and with their options
            already ordered. An answer is matched to its question by position,
            exactly as on the event.
          items:
            $ref: '#/components/schemas/Question'
        askedAt:
          type: string
          format: date-time
      examples:
        - questionId: 4c2e9a71-5b3d-4e8f-9a1c-6d0b2f4e8a3c
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          toolCallId: call_01
          questions:
            - header: Storage
              question: Where should the export land?
              options:
                - value: A new table
                  recommended: true
                - value: Something else
                  other: true
          askedAt: 2026-09-12T12:02:00Z

    QuestionPage:
      type: object
      description: One page of questions waiting on a person.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/PendingQuestion'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
      examples:
        - items:
            - questionId: 4c2e9a71-5b3d-4e8f-9a1c-6d0b2f4e8a3c
              sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              toolCallId: call_01
              questions:
                - question: Where should the export land?
                  options:
                    - value: A new table
                      recommended: true
              askedAt: 2026-09-12T12:02:00Z
          limit: 25

    LaneStartedPayload:
      type: object
      description: A lane opened. The envelope's laneId identifies it.
      required: [kind]
      properties:
        kind:
          type: string
          enum: [main, side, subagent, workflow]
        agent:
          type: string
          description: The agent recipe running in the lane, for subagent and workflow lanes.
      examples:
        - kind: subagent
          agent: reviewer

    LaneStartedEvent:
      description: Envelope for lane-started.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: lane-started
            payload:
              $ref: '#/components/schemas/LaneStartedPayload'
      examples:
        - id: '1009'
          type: lane-started
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:10:00Z
          payload:
            kind: subagent
            agent: reviewer

    LaneFinishedPayload:
      type: object
      description: A lane finished or closed.
      required: [status]
      properties:
        status:
          type: string
          enum: [completed, failed, cancelled]
      examples:
        - status: completed

    LaneFinishedEvent:
      description: Envelope for lane-finished.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: lane-finished
            payload:
              $ref: '#/components/schemas/LaneFinishedPayload'
      examples:
        - id: '1010'
          type: lane-finished
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:30:00Z
          payload:
            status: completed

    DelegationsChangedPayload:
      type: object
      description: >-
        One of the session's delegations started or ended, and how many are
        still under way. A delegation is under way from the call that starts
        it until its report is delivered and the turn that report wakes has
        finished, so zero means nothing more will happen in the session until
        somebody sends it something. The count is the announcing host
        process's own: a delegation still under way when its shutdown begins
        is never counted out, and a host started after it counts from zero.
        The envelope's laneId is the delegate's lane. `narya -p` exits only
        once its own turn has finished and this reads zero.
      required: [running]
      properties:
        running:
          type: integer
          minimum: 0
        unsettled:
          type: boolean
          description: >-
            True on a delegation's end whose report did not settle cleanly;
            the host's log says how. `narya -p` does not exit 0 past one.
      examples:
        - running: 1

    DelegationsChangedEvent:
      description: Envelope for delegations-changed.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: delegations-changed
            payload:
              $ref: '#/components/schemas/DelegationsChangedPayload'
      examples:
        - id: '1011'
          type: delegations-changed
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
          timestamp: 2026-08-08T12:31:00Z
          payload:
            running: 0

    TurnFinishedPayload:
      type: object
      description: >-
        A turn ended, with why, what it consumed, and — when it died — what
        killed it. usage is the LAST supplier round's figures rather than a
        sum across the turn's rounds (a turn that uses tools resends the
        whole growing conversation on every round, so those counts read as
        "how full is the context now"); costUsd beside it is the whole
        turn's money. error is the same NRY envelope every operation returns,
        so a client renders a failed turn through the code path it already
        has for a failed request. It is optional because a turn that
        completed has no cause to report, and it is the ONLY account of the
        cause any client gets: without it a failed turn reaches every
        watcher as stopReason "error" and nothing else, while the real
        error stays in the host process's own stderr — which, for a
        detached daemon, nobody reads.
      required: [turnId, stopReason]
      properties:
        turnId:
          type: string
          format: uuid
        stopReason:
          type: string
          description: >-
            max-tokens is the honest "truncated" outcome: the supplier stopped on its own output ceiling, not because the model reached a natural end of turn.

            content-filter is its sibling, and the reason this value exists at all: the provider's own safety system ended the turn — an openai family's finish_reason content_filter, an anthropic family's stop_reason refusal. Without it both fold into "completed", so a censored or half-censored answer reaches every client as a turn the model finished. Like max-tokens it is NOT a failure: whatever text arrived before the filter fired is real output, so it carries no error envelope and a one-shot still exits 0 with that text on stdout — the incompleteness is stated, not turned into a fault nobody can act on.

            split is the third non-failure: the turn's own work did not fit the model even after the conversation behind it was summarised, so it was cut here and a successor turn was opened on the compacted window, carrying a note that says so. The work continues — a client that sees two turns where a person sent one message reads this as the reason, and the turn that carries it has no error envelope because nothing failed. It is bounded at one split per chain: a turn that still does not fit after a split ends as error instead. budget is the fourth, and it is a bound being honoured rather than anything going wrong. One bound is the turn's own spend ceiling — a scheduled fire's, today the only caller that sets one — reached by the first supplier round that took the turn's running cost to that number; a turn bounded that way may not delegate, because nothing sums another lane's spend into the turn that asked for it. The other is the person's daily spend ceiling, operator configuration unset by default: what narya metered for the person who acted — whoever started the turn, not the session's owner — this UTC day reached it, or includes a round nobody could price. A colleague's message that steers a turn already running joins it and its rounds stay the starter's, so a colleague whose day is spent can lengthen that turn until it ends. The daily ceiling can stop a turn before its first round, resets at 00:00 UTC and refuses no delegation. Whatever text arrived is real output, so it carries no error envelope, and the tool calls the last round was still asking for were not run. Overshoot past a turn's own ceiling is that one round, because a round's price is known only once it has ended, plus up to two unbilled dropped rounds the turn continued past, which sit outside the total. A round cut before the supplier reports its usage — an interrupt or a dropped stream — is billed by the supplier but never counted in the person's day: on a supplier that reports usage only at the end of a round that is any round cut mid-answer, on Anthropic only the cut round's output. Overshoot past the daily ceiling is therefore one round per lane of that person in flight when the day filled — other sessions, delegates, refinement, warms and scheduled fires each finish the round they had started — plus every round cut before its usage arrived.

            A budget turn reporting a costUsd spent a ceiling. A budget turn reporting none either ran no round at all, or stopped because no round of it could be priced — a ceiling that cannot be measured is not a ceiling, so the work stops rather than running on unmetered.
          enum: [completed, interrupted, error, max-tokens, content-filter, split, budget]
        usage:
          $ref: '#/components/schemas/TokenUsage'
        costUsd:
          type: number
          minimum: 0
          description: >-
            What the WHOLE turn cost, in US dollars — every supplier round
            it ran, summed, not just the last one (usage.costUsd). A turn
            that calls tools runs several rounds and each is metered and
            priced separately, so on a ten-round turn the last round's
            price is a fraction of the turn's. Absent unless every round
            could be priced: a sum over only the priced rounds is smaller
            than what the turn actually cost, and a readout that quietly
            understates money is worse than one that says it does not know.
        elapsedMs:
          type: integer
          minimum: 0
          description: >-
            How long the host had this turn in hand, in milliseconds, read
            off its own monotonic clock: from the instant it took the turn
            up — the instant turn-started announces — to this event. The
            measurement is the HOST's, which is the whole point of carrying
            it. A watcher timing the gap between two deliveries times its
            own queue as well, and its stopwatch restarts on every
            turn-started, so a turn that was split reads as nothing but its
            tail. Absent only on an event replayed from a journal written
            before this field existed.
        chainElapsedMs:
          type: integer
          minimum: 0
          description: >-
            How long the whole unit of work took, in milliseconds: from the
            start of the turn that was SPLIT to this turn's end. Present
            ONLY on a turn that continued a split, so a client can say what
            one message really cost in time without mislabelling elapsedMs,
            which stays honest about the turn it is printed beside. A
            message queued behind a turn is a new unit of work rather than a
            continuation, and carries none.
        contextWindow:
          type: integer
          minimum: 1
          description: >-
            The context window, in tokens, of the model THIS turn ran on — the
            denominator usage is a fraction of. It rides here rather than on
            usage because usage is also a session total across many turns and
            possibly many models, where a single window would have no owner.

            It exists because a client cannot resolve the pair itself: a turn
            run under an agent recipe that overrides provider and model, or on
            a delegated lane, does not run on the session's model, and a
            client dividing that turn's tokens by the session model's window
            reports a percentage of the wrong number. The host knows the
            effective pair at the instant the turn ends, so it states the
            window and the client divides what it received.

            Absent when narya knows no window for the model that ran (the
            catalogue states none and no refusal has taught one), and on any
            event replayed from a journal written before this field
            existed — a client that finds it absent falls back to whatever
            denominator it used before, which is what it does today.
        error:
          $ref: '#/components/schemas/Error'
      examples:
        - turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
          stopReason: completed
          usage:
            inputTokens: 15000
            outputTokens: 900
            costUsd: 0.12
          costUsd: 0.47
          elapsedMs: 8400
        - turnId: 9c1d3e5f-0a2b-4c4d-8e6f-1a3b5c7d9e0f
          stopReason: error
          error:
            code: NRY-0018
            title: No usable model
            message: >-
              No model is selected. Sign in with `narya auth login <provider>`
              to use its default model, or set default_model in
              ~/.config/narya/config.toml, then send your message again.
              `narya providers` lists what this machine can use.
          elapsedMs: 240
        - turnId: 2f0a4b6c-8d1e-4f3a-9b5c-7d9e1f3a5b7c
          stopReason: completed
          costUsd: 0.31
          elapsedMs: 51200
          chainElapsedMs: 184000

    TurnFinishedEvent:
      description: Envelope for turn-finished.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: turn-finished
            payload:
              $ref: '#/components/schemas/TurnFinishedPayload'
      examples:
        - id: '1011'
          type: turn-finished
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:05:00Z
          payload:
            turnId: 4d8e2f6a-0b1c-4d3e-9f5a-7b9c1d3e5f0a
            stopReason: completed
            usage:
              inputTokens: 15000
              outputTokens: 900
              costUsd: 0.12
            costUsd: 0.47

    IntercomMessagePayload:
      type: object
      description: >-
        An inter-session message arrived — a one-way message, a question, or
        the answer to an earlier ask. A question and its answer both carry the
        ask's askId.
      required: [from, content, kind]
      properties:
        from:
          type: string
          description: The sending session's addressable name.
        content:
          type: string
        kind:
          type: string
          enum: [message, ask, answer]
        askId:
          type: string
          format: uuid
          description: >-
            The ask a question opens or an answer resolves, the same id on
            both, so a watcher can pair them. Absent on a one-way message.
      examples:
        - from: midaz-research
          content: Yes, the fee engine rounds half-even.
          kind: answer
          askId: 8a6b4c2d-0e1f-4a3b-9c5d-7e9f1a3b5c7d

    IntercomMessageEvent:
      description: Envelope for intercom-message.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: intercom-message
            payload:
              $ref: '#/components/schemas/IntercomMessagePayload'
      examples:
        - id: '1012'
          type: intercom-message
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T12:31:00Z
          payload:
            from: midaz-research
            content: Yes, the fee engine rounds half-even.
            kind: answer
            askId: 8a6b4c2d-0e1f-4a3b-9c5d-7e9f1a3b5c7d

    WorkflowProgressPayload:
      type: object
      description: >-
        A workflow run advanced — the run or one of its steps changed state —
        or, as `withdrawn`, left this reader's view.
      required: [runId, status]
      properties:
        runId:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid
          description: >-
            The run's conversation, absent once it is gone and on `withdrawn`.
            The stream delivers this event only to a caller who may read that
            conversation; a run whose conversation is gone reaches the holder
            of `sessions:read-private` alone.
        status:
          type: string
          enum: [pending, running, completed, failed, stopped, withdrawn]
          description: >-
            The run's overall status after this update. `withdrawn` is not a
            status the run reaches: a stream that showed this run to a reader
            who may no longer read its conversation (it was made private
            mid-run) sends that reader this once, carrying `runId` alone, and
            nothing more about the run. A client takes the run off its live
            line and says nothing else.
        step:
          $ref: '#/components/schemas/WorkflowStep'
          description: The step that changed, when the update concerns one step.
        completedSteps:
          type: integer
          minimum: 0
        totalSteps:
          type: integer
          minimum: 0
      examples:
        - runId: 2d4e6f8a-0b2c-4d6e-8f0a-2b4c6d8e0f2a
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          status: running
          step:
            name: run-tests
            status: completed
            output: 412 tests passed
          completedSteps: 1

    WorkflowProgressEvent:
      description: Envelope for workflow-progress.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: workflow-progress
            payload:
              $ref: '#/components/schemas/WorkflowProgressPayload'
      examples:
        - id: '1013'
          type: workflow-progress
          timestamp: 2026-08-08T13:04:00Z
          payload:
            runId: 2d4e6f8a-0b2c-4d6e-8f0a-2b4c6d8e0f2a
            status: running
            completedSteps: 1

    LadderChangedEvent:
      description: >-
        Envelope for ladder-changed. Host-level, never session-scoped: what
        changed is a set of files on this machine, and every client on it is
        looking at the same set.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: ladder-changed
            payload:
              $ref: '#/components/schemas/LadderChangedPayload'
      examples:
        - id: '1015'
          type: ladder-changed
          timestamp: 2026-08-08T13:06:00Z
          payload:
            notices:
              - 'packages: /review updated, /skill:extract added'

    LadderChangedPayload:
      type: object
      description: >-
        An installed package's files changed on disk and the host republished
        them, so what a person can type has moved. A client re-reads
        listCommands and listSkills when this arrives and shows the notices;
        it is never sent for a write that changed nothing anybody can type.
      required: [notices]
      properties:
        notices:
          type: array
          description: >-
            What changed, in the words a person reads — "packages: /review
            updated" — alongside anything that went wrong doing it. Empty is
            possible for a client that only wants the re-read signal.
          items:
            type: string

    CheckpointCreatedEvent:
      description: >-
        Envelope for checkpoint-created: the working tree as it stood at one
        step boundary is now recoverable.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: checkpoint-created
            payload:
              $ref: '#/components/schemas/CheckpointCreatedPayload'
      examples:
        - id: '1017'
          type: checkpoint-created
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-24T13:06:00Z
          payload:
            turnId: 7c0a7e3f-2d4b-4a6c-8e0f-3b9d5f7a1c2e
            entryId: 9e2c1a4b-6d8f-40a1-b3c5-7e9f1a3b5d7f
            treeHash: 4b825dc642cb6eb9a060e54bf8d69288fbee4904
            kind: step

    CheckpointCreatedPayload:
      type: object
      description: >-
        A restore point exists: everything the working tree held at this step
        can be put back, per file or whole. The tree named here lives in
        narya's shadow repository beside the checkout and never in the
        person's own git history, so nothing in `git log` knows about it.
        Checkpoints are never swept — not on a timer, not at startup, not
        because a threshold was crossed — because a rewind to state from
        months ago has to work.
      required: [turnId, treeHash, kind]
      properties:
        turnId:
          type: string
          format: uuid
          description: The turn whose step this restore point closes.
        entryId:
          type: string
          format: uuid
          description: >-
            The transcript entry the checkpoint stands at. Absent for one
            that stands at no moment — a session's baseline, taken before
            anything was said.
        treeHash:
          type: string
          description: >-
            The git tree object in the shadow repository. Two consecutive
            checkpoints carrying the same hash is how a step that changed no
            file says so.
          maxLength: 64
        kind:
          type: string
          enum: [step, pre-rewind, manual]
          description: >-
            Why the checkpoint exists. step is an ordinary step boundary;
            pre-rewind is the working tree as it was immediately before a
            rewind restored over it, standing at the moment that timeline left
            off, which is what makes a rewind undoable — rewinding the code of
            the abandoned session at that entry puts the tree back byte for
            byte; manual is one somebody asked for.

    ExtensionEventPayload:
      type: object
      description: >-
        An event an extension published onto the stream. The envelope's type
        is always `extension`; which extension spoke and what it said live
        here. This is how a host extension feeds its client half (or any
        subscriber) without the core contract knowing its vocabulary.
      required: [extension, name]
      properties:
        extension:
          type: string
          description: Namespaced name of the publishing extension.
          pattern: '^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$'
        name:
          type: string
          description: The extension's own event name, unique within it.
          maxLength: 128
        payload:
          type: object
          description: Free-form payload — the extension's own contract.
          additionalProperties: true
      examples:
        - extension: lerian/ring-review
          name: review-progress
          payload:
            findingsSoFar: 4
            filesRemaining: 11

    ExtensionEvent:
      description: Envelope for extension-published events.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: extension
            payload:
              $ref: '#/components/schemas/ExtensionEventPayload'
      examples:
        - id: '1014'
          type: extension
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-08-08T13:05:00Z
          payload:
            extension: lerian/ring-review
            name: review-progress
            payload:
              findingsSoFar: 4
              filesRemaining: 11

    RefinementProposedPayload:
      type: object
      description: >-
        A round of proposed refinements is standing, waiting on a person.

        NOTHING IS BLOCKED ON IT, which is what separates it from a question:
        the loop that proposed it has already moved on, no tool call waits, no
        timeout closes it, and a restart leaves it alone. It exists so a client
        already attached draws the affordance without polling — a round
        proposed by last night's unattended fire is answered the next morning
        either way.

        There is no `refinement-answered` beside it. A round is answered
        through `answerRefinementRound`, by the client that was showing it.
      required: [roundId, itemCount]
      properties:
        roundId:
          type: string
          format: uuid
          description: >-
            The round's id, which is what an answer is posted against
            (`POST /v1/refinements/rounds/{roundId}/answer`).
        sessionId:
          type: string
          format: uuid
          description: >-
            The conversation the round was learned from. Absent on a round proposed over the contract.
        itemCount:
          type: integer
          format: int32
          minimum: 1
          description: How many edits the round is asking about.
      examples:
        - roundId: 2f7c1b93-4a5e-4d6f-8b0c-1e3a5c7d9f2b
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          itemCount: 3

    RefinementProposedEvent:
      description: Envelope for refinement-proposed.
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          required: [payload]
          properties:
            type:
              const: refinement-proposed
            payload:
              $ref: '#/components/schemas/RefinementProposedPayload'
      examples:
        - id: '1402'
          type: refinement-proposed
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          timestamp: 2026-09-07T02:14:00Z
          payload:
            roundId: 2f7c1b93-4a5e-4d6f-8b0c-1e3a5c7d9f2b
            sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
            itemCount: 3

    Extension:
      type: object
      description: >-
        An extension this home REGISTERED: a program at an HTTPS address.
        Never its secret, on any response that uses this schema.
      required: [name]
      properties:
        name:
          type: string
          description: Namespaced name, publisher/extension form.
          pattern: '^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$'
        endpoint:
          type: string
          format: uri
          description: The HTTPS address this host calls.
        points:
          type: array
          description: The decision points this extension sits on.
          items:
            type: string
        slots:
          type: array
          description: The slots it occupies.
          items:
            type: string
        position:
          type: integer
          description: Where it sits in the ordered pipeline, lowest first.
        decisionTimeoutMs:
          type: integer
          description: >-
            The deadline in force for each of its decision and transformation
            points, in milliseconds: what the owner declared, or 2000.
        grants:
          type: array
          description: >-
            The permission slugs granted ON this registration. An extension reaches every unmarked session in its home; a private one only where `sessions:read-private` is granted here.
          items:
            type: string
        models:
          type: array
          description: >-
            What a registered supplier serves. Absent unless the extension occupies a supplier slot — and absent there too while that slot is CLAIMED AND UNSERVED. A supplier with nothing here offers nothing to any turn: the catalogue skips such a row exactly as it skips a disabled one, so a slot nobody has served yet is not selectable and not a provider anybody sees.
          items:
            $ref: '#/components/schemas/ExtensionModel'
        servingTools:
          type: boolean
          description: >-
            Whether this home is serving the tools this registration publishes RIGHT NOW — that is, whether a model in this home can call them. Present on every extension that occupies the `tool-server` slot and absent on every one that does not, because it is a fact about that claim and about nothing else.


            It is false far more often than an owner expects, and that is the reason this field exists. The write that registers a tool server reaches the endpoint not at all, so the slot is stored CLAIMED AND UNSERVED and stays that way until a sweep has read the endpoint's tool listing; and a listing whose names collide with tools this home already serves is refused by the tool registry, which leaves the registration standing and publishing nothing.


            It is the last answer this home RECORDED, never a guess made at read time, so it survives a restart: a home brought back up under a standing collision still says it is refusing those tools rather than reporting them unread.
        notServingBecause:
          type: string
          description: >-
            Why this home is not serving that registration's tools, in this host's own words — never a sentence the far side wrote. Present exactly when `servingTools` is false, and absent when it is true, because a reason for something that is not the case would be the two fields disagreeing.


            It can say: that no sweep of this home has read the listing yet (every claim starts here, and a host built without a registered-extension pool stays here); that the tool registry refused the listing because another source already owns one of those names, naming both claimants; that the endpoint could not be reached or would not answer; that the listing carried no tool this host can serve; or that the owner has the registration switched off.
        enabled:
          type: boolean
          description: >-
            The owner's switch. A disabled registration is still listed, and
            still says where it pointed, because a registry that hid one would
            answer "what reads our conversations" with less than the truth.
        installedByOperator:
          type: boolean
          description: >-
            True when Lerian installed this on a home Lerian hosts, without the
            owner's act. It travels on every read because it is the
            notice standing where consent would otherwise be: a member can
            always tell which extensions are theirs and which are Lerian's.
        registeredBy:
          type: string
          description: >-
            The subject of the verified token that registered it. Absent on an
            operator install, where there is no person to name.
        registeredAt:
          type: string
          format: date-time
      examples:
        - name: lerian/redactor
          endpoint: https://redactor.example.com/narya
          points: [before-model-request, after-tool-call]
          slots: [tool-server]
          position: 10
          grants: [sessions:read-private]
          enabled: true
          installedByOperator: false
          registeredBy: user_01J8XQ
          registeredAt: '2026-09-17T10:04:00Z'

    ExtensionModel:
      type: object
      description: >-
        One model a registered supplier serves. Stored on the registration
        rather than asked for per turn, because narya's catalogue holds no
        entry for a model it has never heard of.
      required: [id]
      properties:
        id:
          type: string
          description: The id the endpoint itself accepts, with no "/" in it.
          maxLength: 128
        name:
          type: string
          description: The label a picker shows. Absent falls back to the id.
          maxLength: 128
        contextWindow:
          type: integer
          minimum: 0
        maxOutput:
          type: integer
          minimum: 0
        pricing:
          allOf:
            - $ref: '#/components/schemas/ExtensionModelPricing'
          description: >-
            What the model costs. **A model registered without it is refused**,
            and this is optional in the schema rather than required for that
            very reason: a required object arrives as a value that cannot be
            absent, so "no price" would reach the server indistinguishable from
            a price of zero — which means free. Absent means unknown and is
            refused; zero is a price and is accepted.

    ExtensionModelPricing:
      type: object
      description: >-
        What a model costs, in US dollars per million tokens, carried through unconverted so a value here reads against a published price page without arithmetic.


        **A model registered without one is refused, and zero is a price.** Absent means unknown, and a turn on an unpriced model reports no cost at all. A model that is genuinely free says so by carrying zeros.


        The field is OPTIONAL in `ExtensionModel` rather than required, and that is what makes the refusal reachable: a required object generates as a value that cannot be absent, so an omitted price would arrive indistinguishable from a price of zero and be stored as free.
      required: [input, output]
      properties:
        input:
          type: number
          format: double
          minimum: 0
        output:
          type: number
          format: double
          minimum: 0
        cacheRead:
          type: number
          format: double
          minimum: 0
        cacheWrite:
          type: number
          format: double
          minimum: 0

    ExtensionRegistration:
      type: object
      description: A request to make a program at an address a principal of this home.
      required: [name, endpoint]
      properties:
        name:
          type: string
          description: Namespaced name, publisher/extension form.
          pattern: '^[a-z0-9][a-z0-9-]*\/[a-z0-9][a-z0-9-]*$'
        endpoint:
          type: string
          format: uri
          description: >-
            Where this host calls it. `https://` with no exception, loopback
            included — a developer terminating TLS locally is one command, and
            a leaked secret is not one command back.
        points:
          type: array
          description: The decision points to sit on.
          items:
            type: string
        slots:
          type: array
          description: >-
            The slots to occupy. A slot claimed here is stored CLAIMED AND UNSERVED
            (a `model-supplier` given `models` in this body serves them already): this write reaches the endpoint not at all, because a call
            made here would be signed under a secret the caller has not been
            given yet. For `model-supplier`, send `refreshModels: true` once the
            far side holds the secret and the list is read on that update so
            long as the row it produces is enabled; until it is read, the slot
            offers no model to any turn. For `tool-server`, this registration
            reads no `/tools` listing: the first LATER update whose resulting
            row is enabled and that moves the endpoint, newly claims the slot or
            switches on a row already holding it reads it, and so does the tool
            sweep on its own clock; until it is
            read this home serves none of that registration's tools, and a tool
            name this home already holds is refused on the update that reads
            the listing.
          items:
            type: string
        position:
          type: integer
          description: Where in the ordered pipeline. Defaults to 0.
        grants:
          type: array
          description: >-
            Permission slugs to grant ON this registration.
          items:
            type: string
        models:
          type: array
          description: >-
            What this extension serves, if it occupies a supplier slot. Stating
            it here is the one way to register a supplier already SERVING,
            because these are on the row rather than at the far side. Leaving
            it out stores the slot claimed and unserved until an enabled
            update carrying `refreshModels`, or one moving the `endpoint`,
            reads the endpoint's catalogue.
          items:
            $ref: '#/components/schemas/ExtensionModel'
        enabled:
          type: boolean
          description: Whether it starts enabled. Defaults to true.
        decisionTimeoutMs:
          type: integer
          description: >-
            How long each of this extension's decision and transformation
            points waits for it, in milliseconds, before the effect is refused.
            Absent or `0` is 2000. At most 60000, the ceiling a local hook's
            `timeout_ms` has too; a larger or negative value is refused `422`
            and nothing is stored. Only the home's owner writes it. An
            expired deadline still fails closed: the turn stops with the
            extension named.
      examples:
        - name: lerian/redactor
          endpoint: https://redactor.example.com/narya
          points: [before-model-request]
          slots: [tool-server]
          position: 10

    ExtensionRegistered:
      type: object
      description: >-
        A registration and the secret it was minted with. **The only other
        response in this contract that carries a secret is the rotation's.**
      required: [extension, secret]
      properties:
        extension:
          $ref: '#/components/schemas/Extension'
        secret:
          type: string
          description: >-
            The shared secret, shown exactly once. The host signs its outbound calls under it and so does the extension when it calls back. It is stored sealed and no read path in this contract can produce it again; a caller that loses it rotates.

    ExtensionUpdate:
      type: object
      description: >-
        What a change to a registration may carry. An omitted property is left
        alone; a change carrying nothing at all is refused rather than answered
        200, because a caller who believes they changed something and did not
        is exactly who that saves.
      properties:
        endpoint:
          type: string
          format: uri
        points:
          type: array
          items:
            type: string
        slots:
          type: array
          items:
            type: string
        position:
          type: integer
        decisionTimeoutMs:
          type: integer
          description: >-
            Replaces the decision deadline, under the same rule as at
            registration (absent here leaves it alone; `0` is 2000; above
            60000 or negative is refused `422`).
        grants:
          type: array
          items:
            type: string
        models:
          type: array
          items:
            $ref: '#/components/schemas/ExtensionModel'
        enabled:
          type: boolean
          description: >-
            The owner's switch, and the only property this operation may carry
            on an operator-installed row.


            **Switching a `tool-server` row ON reads its tool listing**, and is
            refused `422` if that listing cannot be read or `409` if a name in
            it is one this home already serves. An enable is the moment this
            home starts serving a registration's tools, and it is otherwise
            the one road onto that with nothing checking it: a slot claimed
            while the row was switched off is never a slot NEWLY claimed on any
            write that reads, so `{"enabled": false, "slots": ["tool-server"]}`
            followed by `{"enabled": true}` would otherwise land a live row
            publishing a name nothing had contested. **The cost is stated rather than
            hidden**: an owner switching a tool server back on is held up while
            that endpoint is down, and must wait for it or drop the slot.


            **Switching OFF reaches the endpoint not at all**, in either
            direction of that rule and whatever else the same body carries. The
            moment an owner most needs to disable an extension is the moment it
            has stopped answering, so a kill switch that first waited on its own
            subject would work only while the thing it kills is healthy.
        refreshModels:
          type: boolean
          description: >-
            Ask this host to call the extension's list-models operation and replace the models stored on its registration.


            **On a supplier whose catalogue has never been read it is the FIRST read rather than a re-read**, and it is one of the two mechanisms that perform that first read: claiming `model-supplier` stores the slot unserved, and this flag is the direct way to make it serve anything at all. The other is a moved `endpoint` in the same update or a later one, which reads the list for its own reason — a new address is a new list — and so serves a claimed-and-unserved slot as a side effect. Either works only while the row the update produces is enabled. Send it once the far side holds the secret the registration response handed over.


            **It is a flag on a write rather than a background poll**, so the catalogue moves when the owner says it moved and nobody has to reason about a model list that changed on its own. The host also refreshes on its own when this update MOVES the endpoint, because a new address is a new list by definition.


            **Every other change contacts the extension not at all, with two exceptions this flag is not part of**: a `tool-server` slot this update claims that the row did not already hold reads that registration's tool listing, for the same reason a moved `endpoint` does — a new claim is a list this home has never read — and so does an `enabled` going from `false` to `true` on a row already holding that slot. A disable, a narrowed grant and a moved position are writes against a row in this home and complete whether or not anything answers at that address — which is what makes the owner's disable a kill switch that works at the moment it is most needed, rather than one that works only while the thing it kills is healthy.


            **Carried alongside `enabled: false`, this flag does nothing**, and neither does a moved endpoint in the same body: the resulting row is off, so this host asks that address nothing. The move is stored; the refresh is not deferred and not queued. Ask again — the flag on the update that switches the row back on, or on a later one — when the extension is live and you want its list read.
      examples:
        - enabled: false
        - refreshModels: true

    ExtensionSecret:
      type: object
      description: A freshly rotated secret, shown exactly once.
      required: [secret]
      properties:
        secret:
          type: string
          description: The new secret. Nothing in this contract will produce it again.
        previousValidUntil:
          type: string
          format: date-time
          description: >-
            When the superseded secret stops being accepted.

    ExtensionPage:
      type: object
      description: >-
        Every extension this home has registered, in one answer. **It is not
        paged, and that is deliberate**: a reader who saw a page of what reads
        their conversations would believe they had seen all of it, and a home
        with more registered extensions than one answer holds is not a shape
        this product has. `limit` and the cursors are therefore absent rather
        than filled with a number that would mean nothing.
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Extension'
        limit:
          type: integer
          minimum: 1
          description: Absent; this answer is never paged.
        nextCursor:
          type: string
          description: Absent; this answer is never paged.
        prevCursor:
          type: string
          description: Absent; this answer is never paged.
      examples:
        - items:
            - name: lerian/ring-review
              package: ring-review
              operations:
                - name: start-review

    ExtensionInvokeRequest:
      type: object
      description: A request to run one operation a host extension exposes.
      required: [operation]
      properties:
        operation:
          type: string
          description: The operation name, as listed on the extension.
          maxLength: 128
        payload:
          type: object
          description: Free-form input — the extension's own contract.
          additionalProperties: true
      examples:
        - operation: start-review
          payload:
            sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d

    ExtensionInvokeResult:
      type: object
      description: The operation's result, as the extension defined it.
      properties:
        payload:
          type: object
          description: Free-form output — the extension's own contract.
          additionalProperties: true
      examples:
        - payload:
            accepted: true

    SessionAttention:
      type: object
      description: >-
        One prompt standing on a conversation, waiting for a person.
      required: [kind, askId, since]
      properties:
        kind:
          type: string
          enum: [permission, question]
          description: >-
            Which of the two things a person answers is holding this
            conversation: a permission request for an action the agent wants to
            take, or a question the agent put to the user.


            A refinement round is deliberately not one of the values. It blocks
            no tool call, so a conversation standing on one is not waiting for
            anybody and must not be drawn as though a turn were parked.
        askId:
          type: string
          format: uuid
          description: >-
            The prompt itself — the permission id or the question id, according
            to `kind` — so a client acts on it directly instead of paging a
            listing to find which of several it just drew.
        since:
          type: string
          format: date-time
          description: >-
            When the PROMPT was raised, which is not when the session last changed. "Waiting forty minutes" has to mean the person has been kept waiting forty minutes; read off `updatedAt` it would mean only that nothing has happened here for forty minutes, which is true of an idle conversation nobody is blocked on.


            Where several prompts stand on one conversation this is the OLDEST, for the same reason: the question an overview of sessions asks is how long somebody has been waiting, not when the most recent thing appeared.

    SessionPage:
      type: object
      description: One page of sessions.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Session'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
          description: Cursor for the next page; absent on the last page.
        prevCursor:
          type: string
          description: Cursor for the previous page; absent on the first page.
        spend:
          $ref: '#/components/schemas/SessionPageSpend'
      examples:
        - items:
            - id: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              repository: /Users/dev/repos/midaz
              status: running
              clientKind: inline
              createdAt: 2026-08-08T12:00:00Z
              updatedAt: 2026-08-08T12:34:56Z
          limit: 25
          nextCursor: b3BhcXVlLWN1cnNvcg
          spend:
            filtered:
              inputTokens: 41200000
              outputTokens: 1830000
              costUsd: 301.22
            page:
              inputTokens: 980000
              outputTokens: 44000
              costUsd: 7.15

    SessionPageSpend:
      type: object
      description: >-
        What this listing costs, so a client drawing an overview of sessions does not have to sum the rows it happens to have paged — a figure that grows as the reader scrolls and is wrong on every page but the last.

        It is not a second ledger. Every figure here sums the same model rounds the consumption report sums, at the same prices, over the conversations this request's filters name; where the two surfaces could disagree this one defers, because two spend screens that drift is a product people stop trusting. They answer an UNPRICED set differently, and deliberately: the report serves a floor with `costComplete: false` and the count beside it, while this envelope serves no `costUsd` at all with the count instead — because the rows in this same body have been all-or-nothing since they existed, and a footer reporting a floor over rows reporting absence would not equal the sum of the rows above it.

        THE WHOLE ENVELOPE IS ABSENT WHEN THE REQUEST CARRIES `search`, `repository` OR `environment`, and never partially present. Those three narrow by a name a person chose — a conversation's title, its working directory, and an environment identifier whoever created it invented, which `listEnvironments` serves to its owner alone — while these figures count the conversations somebody marked private under no permission at all. Served together they would answer what the read rule refuses: `search=Longbow` costing eleven dollars where `search=Longbox` costs nothing reads a private title one request at a time, `environment=acme-acquisition` reads a name the same way, and `repository` and `environment` both attribute that money to a named resource. Under those three the absence follows the FILTER and never what it matched, so it signals nothing itself — every such request is answered without an envelope, whether it matched a private conversation, a readable one or nothing. Spend grouped by repository lives on the consumption report, which is the operation built for it and which puts a private conversation's money in one opaque group rather than under a name. `status` and `mine` are not content and keep the envelope: a run's state is not something a person wrote, and `mine` narrows to the reader's own work.

        Separately, and for a different reason, a ledger this host cannot sum is answered with the rows and no envelope. That is one bit, the same under every filter and for every caller, and it names nothing.
      required: [filtered, page]
      properties:
        filtered:
          allOf:
            - $ref: '#/components/schemas/SpendTotal'
          description: >-
            What the whole filtered set cost — every conversation these filters match, not just the ones on this page. This is the number an overview of sessions shows for "spending".

            It counts the conversations somebody marked private too, under no permission at all, because money inside an organisation is the organisation's and a total that changed with the reader would not be a total. Nothing but money crosses that mark: this figure names no conversation, no repository, no environment and no person — and where a filter would turn it into a probe for one of those names, the whole envelope is withheld rather than narrowed, which is the `search`, `repository` and `environment` rule stated above.
        page:
          allOf:
            - $ref: '#/components/schemas/SpendTotal'
          description: >-
            What the conversations in `items` cost — summed over exactly the
            rows served here, so it reconciles with them to the cent.
        private:
          allOf:
            - $ref: '#/components/schemas/SpendTotal'
          description: >-
            What this page matched and did not serve: the conversations the
            filters selected and the read rule withheld, because somebody
            marked them private and this caller is neither their owner nor
            named on a share. The first page also carries every product
            session of another member, which no page lists. Absent when this
            page withheld nothing, which is every page of a home where nobody
            has marked anything.

            It exists so the parts still add up to the whole. Without it,
            `filtered` would exceed the pages by however much private work
            cost, with nothing in the answer saying where the difference went
            — and a report whose parts do not sum to its whole is the shape
            this product refuses. The money is disclosed; the names are not.

    SpendTotal:
      type: object
      description: >-
        Tokens consumed and money billed over a set of conversations.

        costUsd is ALL OR NOTHING, and `unpricedRounds` is why. A model this
        host cannot price records no cost at all, so a sum that skipped those
        rounds would be smaller than the real bill while looking exactly like
        it — and a zero would read as free. So the money is served only when
        every round in the set was priced, and otherwise it is absent with the
        count of the rounds that were not. One unpriceable round in a thousand
        and a thousand out of a thousand are both unknown; only the count says
        which. EXACTLY ONE OF THE TWO IS ALWAYS PRESENT, and the `oneOf` below
        says so where a reader generating a client can see it: neither field
        is optional in the sense of "may be missing" — a set is priced or it
        is counted.

        The money is a DOUBLE here while the per-session figure on a session
        row (`TokenUsage`) is a single. That is deliberate rather than
        inconsistent: `costUsd` on this schema sums a whole organisation and
        grows for the life of a home, and a single's step passes one cent at
        about 131,072 dollars, which would break the reconciliation this
        envelope promises. A session's own total is one conversation's spend
        and stays orders of magnitude below that, so it keeps the type every
        client already reads.
      required: [inputTokens, outputTokens]
      properties:
        inputTokens:
          type: integer
          minimum: 0
          description: Input tokens consumed across the set.
        outputTokens:
          type: integer
          minimum: 0
          description: Output tokens produced across the set.
        cacheReadTokens:
          type: integer
          minimum: 0
          description: Tokens served from the provider's prompt cache across the set.
        cacheWriteTokens:
          type: integer
          minimum: 0
          description: Tokens written to the provider's prompt cache across the set.
        costUsd:
          type: number
          format: double
          minimum: 0
          description: >-
            Total cost in US dollars. Absent when `unpricedRounds` is
            present: unknown, never a zero standing in for free.

            Double precision, because this figure sums a whole organisation
            and never stops growing; the per-session figure on a session row
            is a single and says why in this schema's own description.
        unpricedRounds:
          type: integer
          minimum: 1
          description: >-
            How many model rounds in this set recorded no cost at all, which
            is why `costUsd` is absent. Absent itself when every round was
            priced, which is when `costUsd` is the whole of it.
      oneOf:
        - required: [costUsd]
        - required: [unpricedRounds]
      examples:
        - inputTokens: 41200000
          outputTokens: 1830000
          cacheReadTokens: 22000000
          cacheWriteTokens: 1400000
          costUsd: 301.22

    EntryPage:
      type: object
      description: >-
        One page of a session's transcript, oldest first, plus the seam a
        client attaches the live event stream at.
      required: [items, limit, hasMore, nextCursor, lastEventId]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Entry'
        limit:
          type: integer
          minimum: 1
        hasMore:
          type: boolean
          description: >-
            Whether more entries already exist after this page. False does
            not mean the transcript is finished — an append-only log always
            can grow — it means nothing more had been written when the page
            was read.
        nextCursor:
          type: string
          description: >-
            Where the next read resumes. Always present, including on the
            last page: a client that has caught up keeps it and re-reads
            from it later to collect whatever committed in between.
        lastEventId:
          type: string
          description: >-
            The event-stream position this page was read at — the id of the
            newest event this session had at the moment the entries were
            read, in the same database snapshot, or just before the message
            still streaming when one is. Subscribe to GET /v1/events
            for this session with Last-Event-ID set to the last page's value
            and the stream continues exactly where the transcript stops.
            "0" when the session has no events at all.
      examples:
        - items:
            - id: 9c2b7a10-3e4d-4f6a-8b1c-5d7e9f0a2b3c
              sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              laneId: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
              sequence: 1
              kind: user-message
              createdAt: 2026-08-08T12:01:03Z
              text: where does the ledger validate a transaction's asset code?
          limit: 25
          hasMore: false
          nextCursor: c2VxOjE
          lastEventId: '1005'

    FileChangeSet:
      type: object
      description: >-
        Every file a session changed, in the order the session first met them.
        Not paginated: the set is bounded by what one conversation's tools
        actually mutated, and a client that wants one file asks for it by path.
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/FileChange'
      examples:
        - items:
            - path: /home/dev/ledger/validate.go
              op: modified
              beforeState: text
              beforeBytes: 4211
              linesAdded: 12
              linesRemoved: 3

    FileChange:
      type: object
      description: >-
        One file a session changed, measured from the content it held when the
        session first touched it to what is on disk now.
      required: [path, op, beforeState, beforeBytes]
      properties:
        path:
          type: string
          description: The file's absolute path.
        op:
          type: string
          enum: [added, modified, deleted, renamed]
          # Named explicitly so these constants keep their names: left to the
          # generator they are Added/Modified/Deleted, which a later enum
          # carrying any of those values would silently rename in a package
          # third parties import.
          x-enum-varnames:
            - FileChangeOpAdded
            - FileChangeOpModified
            - FileChangeOpDeleted
            - FileChangeOpRenamed
          description: >-
            What the session did to the file, at file granularity: it was not
            there and now is, it was there and changed, it was there and is
            gone, or it is the same bytes under another name. This is the
            file-level fact a change list shows, not the per-call
            read/write/edit a tool receipt carries.

            renamed is reported only where it can be known for certain and
            costs nothing to know: inside one change set, a file that went
            and a file that arrived whose content is byte-identical are one
            row naming both paths, and previousPath carries the name it had.
            There is no rename DETECTION behind this — no git, no similarity
            score — so a file that was moved AND edited is still reported as
            an added row beside a deleted one, which is what a reader without
            this value gets for every move. A client that does not know the
            value degrades to whatever it draws for an op it cannot name.
        beforeState:
          type: string
          enum: [absent, text, not-text, too-large]
          x-enum-varnames:
            - FileChangeBeforeStateAbsent
            - FileChangeBeforeStateText
            - FileChangeBeforeStateNotText
            - FileChangeBeforeStateTooLarge
          description: >-
            What is known about the content the file held before the session
            changed it, so an unavailable diff is never mistaken for an empty
            one. absent — the file did not exist, and the whole of it is
            added. text — beforeContent carries it when this read asked about
            a single path. not-text — the bytes were kept but are not valid
            UTF-8, so they are never served as a string and no line count
            over them would mean anything. too-large — the file was past the
            1 MiB bound and nothing was kept; beforeBytes says how large it
            was.
        beforeBytes:
          type: integer
          format: int64
          minimum: 0
          description: >-
            The file's size when the session first touched it, 0 when it did
            not exist. True even when the content itself was too large to
            keep.
        beforeContent:
          type: string
          description: >-
            The content the file held before the session changed it. Present
            only when the request named a single path or one entry AND
            beforeState is text or absent — a file the step created has no
            before side, so absent pairs with the empty string here, which is
            the side a created file is diffed against. A client diffs it
            against afterContent when present, and otherwise against the file
            it reads from disk itself, which it may edit.
        afterContent:
          type: string
          description: >-
            The other side. Present on an entryId read, because one step's
            change ends at a moment that has since been edited over, so the
            host serves both ends or the diff cannot be drawn at all. Present
            on a path read of a session whose code is on another machine (a
            container) while that file is text, because the client shares no
            disk with it, and shows that diff read-only. Absent on a path read
            of a session on this host's own machine, whose file the client
            reads from disk itself.
        linesAdded:
          type: integer
          minimum: 0
          description: >-
            Lines this session added, counted from the before content against
            the file as it is now. Absent when no diff could be computed —
            see beforeState — because a count nobody could compute is not
            zero.
        linesRemoved:
          type: integer
          minimum: 0
          description: Lines this session removed, on the same terms as linesAdded.
        previousPath:
          type: string
          description: >-
            The absolute path the file had before the session moved it, on a
            renamed row and nowhere else. path is where the file is now, so a
            reader states the move from the two together. The row's before
            side — beforeState, beforeBytes, beforeContent — belongs to this
            path, since that is the file the content was measured from.
      examples:
        - path: /home/dev/ledger/validate.go
          op: modified
          beforeState: text
          beforeBytes: 4211
          beforeContent: "package ledger\n"
          linesAdded: 12
          linesRemoved: 3

    LanePage:
      type: object
      description: One page of lanes.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Lane'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - id: 1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d
              sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              kind: main
              status: running
              createdAt: 2026-08-08T12:00:00Z
          limit: 25

    AgentPage:
      type: object
      description: One page of agent recipes.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Agent'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
        notices:
          type: array
          description: >-
            What this listing could not include, in the words a person reads:
            the recipes of the repository a session's container cloned, while
            that container cannot be read.
          items:
            type: string
      examples:
        - items:
            - name: reviewer
              description: Reviews diffs for correctness and style.
          limit: 25

    PermissionPage:
      type: object
      description: One page of permission asks.
      required: [items, limit, lastEventId]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Permission'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
        lastEventId:
          type: string
          description: >-
            The event-stream position this page was read at — the id of the
            newest event the host had recorded, in the same database snapshot
            as the asks. Every permission-asked and permission-decided event
            at or before it is already reflected in the page, and one just
            past it may be too, because an ask is stored before the event
            announcing it; so a client holding the page applies the stream's
            events past this id. "0" when the host has recorded no events.
      examples:
        - items:
            - id: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
              sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              action: shell
              resource: 'git push origin main'
              status: pending
              requestedAt: 2026-08-08T12:20:00Z
          limit: 25
          lastEventId: '4211'

    PermissionDecisionPage:
      type: object
      description: One page of the decision audit.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/PermissionDecisionRecord'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - id: 3f1e5d7c-2b4a-4c6e-8d0f-9a1b3c5d7e9f
              permissionId: 7e5d3c1b-9a8f-4e6d-b2c4-0f1a3b5c7d9e
              decision: deny
              decidedBy: user
              requestedAt: 2026-08-08T12:20:58Z
              decidedAt: 2026-08-08T12:21:00Z
          limit: 25

    PeerPage:
      type: object
      description: One page of addressable peer sessions.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Peer'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              name: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              status: running
              liveness: addressable
          limit: 25

    PackagePage:
      type: object
      description: One page of installed packages.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Package'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - name: lerian/review-pack
              enabled: true
              carriesCode: true
          limit: 25

    Schedule:
      type: object
      description: >-
        Work the host's own clock starts, with nobody present.

        A schedule is durable configuration, never swept: what a fire produces is a session, governed by the rules sessions already have, while the schedule itself stays until somebody cancels it. The owner is never something a caller names — a fire has to be attributable to somebody who is not there, and an attribution the reader cannot check reads exactly like a true one.
      required: [id, owner, repository, prompt, rule, status, createdAt, runCount]
      properties:
        id:
          type: string
          format: uuid
        owner:
          type: string
          description: >-
            Who this schedule's fires are billed to and attributed to: the
            person who created it, as the host authenticated them. A fire runs
            with nobody present, so this is read from the row rather than from
            whoever is signed in at the time — which is what lets a schedule
            outlive its author being away.
        repository:
          type: string
          description: >-
            The directory a fired session runs in, relative to the workspace
            on a hosted home. Somebody has vouched for it — creation
            refuses one nobody has — and the host reads that answer again at
            every fire, because trust is revocable.
        prompt:
          type: string
          description: What the fired session is asked to do.
        agent:
          type: string
          description: The agent recipe a fire runs under. Absent runs the host's default.
        environment:
          $ref: '#/components/schemas/EnvironmentId'
          description: >-
            The environment every fire runs inside, read again at each fire.
            Absent is a schedule whose fires run on the host's own machine,
            or on a hosted home in a container of each fire's own.
        clone:
          type: string
          description: >-
            The repository cloned afresh into each fire's own container on a
            hosted home, which is also the schedule's `repository`.
        rule:
          $ref: '#/components/schemas/ScheduleRule'
        status:
          type: string
          description: >-
            active fires; completed is what a single-instant schedule becomes
            once that instant is spent; cancelled is what a person does to
            one, and it stops the fires without removing the record; paused
            keeps the row and stops the fires, which is the difference
            between changing your mind and changing it back.
          enum: [active, paused, completed, cancelled]
        ceilingUsd:
          type: number
          format: double
          minimum: 0
          description: >-
            What ONE fire may spend. A hard stop on the turn rather than a
            price — see setScheduleBudget for what it does and does not
            bound. Absent means this schedule has no ceiling and its fires run
            as any unbounded turn does. Zero means this schedule does not
            fire. A fire on a model this host has no price for runs without
            it, and notice says so.
        createdAt:
          type: string
          format: date-time
        nextRunAt:
          type: string
          format: date-time
          description: >-
            The instant a fire is owed at. Absent once a single instant has
            been spent. A cancelled schedule KEEPS the instant it was going
            to run at — its status is what stops the fire, and the instant is
            what somebody reads when they wonder whether they stopped the
            right one. It is advanced from the moment of the fire rather
            than from the instant that was missed,
            which is why a host that was off for three days fires once rather
            than three times.
        lastRunAt:
          type: string
          format: date-time
          description: >-
            When this schedule last CLAIMED an instant. Absent until it has.
            It is stamped at the claim, before the fire's session exists, so
            it says when the host last acted on this schedule rather than
            when work last started: a fire refused for a missing repository
            or a missing key moves it too, and lastError says which.
        lastFiredSessionId:
          type: string
          format: uuid
          description: >-
            The session the last fire created, which is where the morning's
            reading starts. It may name a session that has since been purged.
        lastSkippedAt:
          type: string
          format: date-time
          description: >-
            When a due fire was refused, with lastSkipReason saying which
            refusal it was. Recorded rather than silent: a skip nobody can
            read is indistinguishable from a scheduler that stopped working.
            Written once while the refusal lasts, so it answers "since when",
            and cleared by any fire that gets through.
        lastSkipReason:
          type: string
        lastError:
          type: string
          description: >-
            How a fire that had already claimed its instant failed to reach a
            session. The instant stays spent, so this is what the morning
            reads instead of an unexplained gap.
        notice:
          type: string
          description: >-
            What the last fire that opened a session says about how this
            schedule's fires run, absent when it says nothing. Two facts earn
            it: the model the fires run on has no price on this host, so they
            run with no spend ceiling until a fire on a priced model clears it;
            or the agent they run comes from the repository each fire's turn
            clones, so its price is judged when the turn clones and a priced
            model keeps the ceiling.
        runCount:
          type: integer
          minimum: 0
          description: >-
            How many instants this schedule has CLAIMED, including fires that
            never reached a session. The count is incremented inside the same
            transaction that spends the instant, which is what makes a fire
            unrepeatable across a crash — so a fire refused after the claim,
            or a host that died in the gap before the session existed, is
            counted here and explained in lastError. A consumer counting
            SESSIONS wants the sessions themselves, not this.
      examples:
        - id: 8f2c1a4e-6b0d-4e2f-9a3c-5d7e1f0b2a4c
          owner: user_01HZX3Q8N4KDPYV2A7C9M5T6RB
          repository: /Users/dana/repos/midaz
          prompt: read yesterday's failed builds and write the summary
          rule:
            kind: every
            everySeconds: 86400
          status: active
          ceilingUsd: 5
          createdAt: 2026-09-03T09:00:00Z
          nextRunAt: 2026-09-04T09:00:00Z
          runCount: 0

    ScheduleRule:
      type: object
      description: >-
        When a schedule fires: a kind and the one parameter that kind takes.
        Exactly one parameter is ever set.

        There is no cron expression here and the absence is a decision, not a
        gap: a cron expression drags a timezone, a daylight-saving policy and
        a no-match semantics behind it, and an agent turn costs money and
        takes minutes, so the units that matter are hours and days.
      required: [kind]
      properties:
        kind:
          type: string
          enum: [every, at]
          x-enum-varnames: [ScheduleRuleKindEvery, ScheduleRuleKindAt]
        everySeconds:
          type: integer
          minimum: 1
          description: >-
            The gap between fires, present only when kind is every. Seconds
            rather than the duration a person typed, because the only
            arithmetic anybody does with it is addition.
        at:
          type: string
          format: date-time
          description: >-
            The single instant, present only when kind is at. It is what the
            person asked for and stays put after the fire, while nextRunAt is
            what the clock did.

    ScheduleCreate:
      type: object
      description: >-
        A schedule being asked for, in the words the caller has: the rule
        arrives as the strings a person typed and one parser reads them, so
        every surface refuses `--every whenever` in the same sentence.

        There is no owner field and its absence is the rule rather than an
        omission — the host writes the owner from its own identity.
      required: [prompt]
      properties:
        repository:
          type: string
          maxLength: 4096
          x-go-type-skip-optional-pointer: true
          description: >-
            Absolute path of the directory a fire runs in, required unless
            `clone` is given and refused (422) beside it. Somebody must have
            vouched for it already; one nobody has is refused with the
            sentence that names the gesture.
        prompt:
          type: string
          description: >-
            What the fired session is asked to do. Required: a schedule with
            nothing to do starts a session at three in the morning and does
            nothing in it.
        agent:
          type: string
          description: The agent recipe a fire runs under. Empty runs the host's default.
        every:
          type: string
          description: >-
            An interval — "15m", "2h", "24h", "7d". Exactly one of every and
            at is given. The shortest a schedule may fire on is five minutes,
            and anything under it is refused naming the floor: an agent turn
            takes minutes and spends money, so a schedule measured in seconds
            is an incident rather than an aggressive setting. An interval's
            first fire is one interval away, never immediately.
        at:
          type: string
          description: >-
            One absolute RFC3339 instant, fired once, after which the
            schedule is completed. An instant already behind the clock is
            refused rather than fired late.
        ceilingUsd:
          type: number
          format: double
          minimum: 0
          description: >-
            What one fire may spend. Absent takes the operator's configured
            `[schedules] ceiling_usd`, and with none configured the schedule
            has no ceiling. Zero is not absent and is never promoted: it is a
            schedule that does not fire, which is how one is parked until
            somebody prices it.
        environment:
          $ref: '#/components/schemas/EnvironmentId'
          description: >-
            Run every fire inside this environment, which must already exist
            (createEnvironment) and is read again at each fire. Absent means
            the host's own machine on a person's own home, whose `repository`
            must exist there, and a container of each fire's own on a hosted
            home (createSession).
        clone:
          type: string
          maxLength: 200
          pattern: '^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$'
          description: >-
            A repository cloned afresh into each fire's own container, exactly
            as createSession's `clone`: on a hosted home, without
            `environment`, and the schedule's repository, which reads back as
            `owner/name`.
        setupScript:
          type: string
          minLength: 1
          maxLength: 65536
          description: >-
            Run once at each fire in place of the clone's own
            `.narya/setup.sh`, as createSession's `setupScript`. Only with
            `clone` (422 otherwise).
        idempotencyKey:
          type: string
          minLength: 1
          maxLength: 255
          description: >-
            A key the client chooses for this schedule, so a create whose
            answer was lost can be repeated safely. A create carrying a key
            its caller already created a schedule under records nothing: it
            is answered 201 with that schedule, whatever else this body says.
            Absent, every create records a schedule.
      examples:
        - repository: /Users/dana/repos/midaz
          prompt: read yesterday's failed builds and write the summary
          every: 24h

    ScheduleBudget:
      type: object
      description: >-
        What one fire of a schedule may spend. One number, because a schedule
        has no separate on-and-off: zero is a schedule that does not fire.
        Absent or null takes the ceiling away, so its fires run with none.
      properties:
        ceilingUsd:
          type: [number, 'null']
          format: double
          minimum: 0
      examples:
        - ceilingUsd: 2.5

    SchedulePage:
      type: object
      description: One page of schedules.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Schedule'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - id: 8f2c1a4e-6b0d-4e2f-9a3c-5d7e1f0b2a4c
              owner: user_01HZX3Q8N4KDPYV2A7C9M5T6RB
              repository: /Users/dana/repos/midaz
              prompt: read yesterday's failed builds and write the summary
              rule:
                kind: every
                everySeconds: 86400
              status: active
              ceilingUsd: 5
              createdAt: 2026-09-03T09:00:00Z
              runCount: 3
          limit: 25

    WorkflowRunPage:
      type: object
      description: One page of workflow runs.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/WorkflowRun'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - id: 2d4e6f8a-0b2c-4d6e-8f0a-2b4c6d8e0f2a
              status: completed
          limit: 25

    ProviderPage:
      type: object
      description: One page of providers.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Provider'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - name: anthropic
              authState: authenticated
          limit: 25

    ModelPage:
      type: object
      description: One page of the flat model catalogue.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Model'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - id: claude-sonnet-4-5
              provider: anthropic
          limit: 25

    RecordSearchPage:
      type: object
      description: One page of full-text search matches.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/RecordSearchResult'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              excerpt: '...the test fails only when the clock crosses midnight UTC...'
              timestamp: 2026-08-08T12:15:00Z
          limit: 25

    RecordFilePage:
      type: object
      description: One page of sessions that touched the queried file.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/RecordFileResult'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              path: /Users/dev/repos/midaz/components/ledger/clock.go
              touchedAt: 2026-08-08T12:18:00Z
          limit: 25

    SessionSharePreview:
      type: object
      description: >-
        What publishing one session would send, and what it would hold back.
        The same object the publish then sends, computed by the same function
        in the host.
      required: [entries, withheld, ruleCounts]
      properties:
        entries:
          type: array
          description: >-
            The session as it would leave this machine, oldest first. Every
            entry of the transcript is here, redacted where a rule matched:
            the list is the document, not a selection from it.
          items:
            $ref: '#/components/schemas/SessionSharePreviewEntry'
        withheld:
          type: array
          description: >-
            Every place a rule held something back, in transcript order — one
            item per entry per rule. This is what a client draws beside the
            conversation so a reader can see WHERE something was withheld;
            ruleCounts answers how much in total.
          items:
            $ref: '#/components/schemas/SessionShareWithheld'
        ruleCounts:
          type: object
          description: >-
            How many spans each rule withheld across the whole session, keyed
            by the rule's name. Empty when nothing was withheld.
          additionalProperties:
            type: integer
            minimum: 1
      examples:
        - entries:
            - id: 3f1c9a52-8d4e-4b21-9c07-5e2a1d8b6f40
              kind: user-message
              sequence: 1
              text: 'deploy with ANTHROPIC_API_KEY=[redacted: masked-environment]'
            - id: 7a2d4e61-9b3c-4f18-8e05-1c6b3a9d2f57
              kind: tool-result
              sequence: 2
              text: 'ok  github.com/example/pkg  0.4s'
          withheld:
            - entryId: 3f1c9a52-8d4e-4b21-9c07-5e2a1d8b6f40
              rule: masked-environment
              count: 1
          ruleCounts:
            masked-environment: 1

    SessionSharePreviewEntry:
      type: object
      description: >-
        One entry of a session as it would leave this machine: already
        redacted, still in its place.
      required: [id, kind, sequence]
      properties:
        id:
          type: string
          format: uuid
          description: >-
            The transcript entry's own id, so the preview a person read and
            the entries that reach the organisation's home name the same rows.
        kind:
          type: string
          enum: [user-message, assistant-message, tool-call, tool-result, summary, system, intercom, monitor-event]
          description: >-
            The transcript row's kind, unchanged by redaction: a reader
            following a conversation needs to know whose voice a withheld line
            was in.
        sequence:
          type: integer
          format: int64
          minimum: 1
          description: The entry's place in this session's transcript.
        text:
          type: string
          description: >-
            What would be sent for this entry, with every rule applied. A
            withheld span reads as a marker naming the rule that took it.

            Absent for an entry that carries nothing this surface publishes —
            an assistant round's reasoning blocks, whose provider signatures
            never leave the host — which is a real state and not an omission.
            Such an entry is still listed, so the sequence a reader follows
            stays honest.

    SessionShareWithheld:
      type: object
      description: One rule holding something back in one entry.
      required: [entryId, rule, count]
      properties:
        entryId:
          type: string
          format: uuid
          description: The entry the rule withheld something from.
        rule:
          type: string
          description: >-
            Which rule recognised it, by name. A name rather than a number
            because it travels to a reader: "stored-credential" is something a
            person can act on, and "rule 2" is not. The four the product
            starts with are named-path, stored-credential,
            masked-environment and credential-shape.
        count:
          type: integer
          minimum: 1
          description: >-
            How many spans that rule took out of this entry. One for a whole
            entry withheld by name.

    SessionSharePublish:
      type: object
      description: >-
        One person asking for one session to be published to the
        organisation's home.

        THERE IS NO ORGANISATION FIELD AND NO OWNER FIELD, and their absence
        is the design rather than an omission. One organisation is one home,
        so which organisation this is arrives with the token and the home
        refuses a token for any other before a handler runs; and the person
        publishing is the verified actor, so an owner a caller could name
        would be an owner a caller could get wrong.
      required: [mode]
      properties:
        mode:
          type: string
          enum: [once, sync]
          description: >-
            Where this publish stops. `once` copies the entries that exist and
            is finished. `sync` leaves the share subscribed, so entries said
            afterwards ride the same queue to the same place.

            There is no third value, because the difference between these two
            is a stopping rule and not a mechanism.
        expiresAt:
          type: string
          format: date-time
          description: >-
            When this share stops. Absent takes the host's configured window,
            which is the point rather than a convenience: a share nobody
            thought about still stops, so there is no way to ask for one that
            never does.

            An instant already past is refused rather than accepted, because a
            person told their session is shared, whose copy is deleted a moment
            later, has been told something false.
        recipients:
          type: array
          items:
            type: string
          description: >-
            The colleagues this share is for, each named by the subject their sign-in reports.

            ABSENT OR EMPTY IS THE WHOLE ORGANISATION, and that is the default rather than a special case: a person publishing to their team names nobody, and narrowing is what somebody has to ask for.

            Naming colleagues is also what makes a session its owner marked PRIVATE readable — by them, bounded to the entries the share has actually delivered.

            A value no sign-in could ever report — a blank, an address with a space in it, a pasted line — is refused by name, since a share written for a mistyped colleague is a share readable by nobody with a 201 in front of it. Whether a well-formed subject belongs to this organisation is not checked here: that is a directory call this home cannot make, and a grant it cannot exercise anyway.
      examples:
        - mode: once
        - mode: sync
        - mode: sync
          expiresAt: 2026-10-08T09:15:00Z
        - mode: sync
          recipients: [user_grace, user_auditor]

    SessionShare:
      type: object
      description: >-
        One session published to the organisation's home, as this home records
        it.
      required: [shareId, sessionId, mode, state, createdAt]
      properties:
        shareId:
          type: string
          description: >-
            The share's id, which addresses it everywhere outside this home —
            a URL, the organisation's home, whatever a colleague pastes into a
            chat. Drawn from a cryptographic source and unrelated to sessionId
            by construction, so it discloses nothing about the session or the
            machine it came from.
          minLength: 26
          maxLength: 26
          pattern: '^[A-Z2-7]{26}$'
        sessionId:
          type: string
          format: uuid
          description: >-
            The session in THIS home that was published. It is provenance: at
            the organisation's home the same share is addressed by shareId and
            that session does not exist there.
        mode:
          type: string
          enum: [once, sync]
          description: Where this publish stops.
        state:
          type: string
          enum: [pending, active, revoked]
          description: >-
            How far it has got. `pending` is a share whose first entry has not
            reached the organisation's home — which is what a 503 NRY-0029
            leaves behind. `active` is a share the organisation's home holds:
            complete for `once`, still following for `sync`. `revoked` is
            terminal.

            Whether a share has EXPIRED is deliberately not a state here: that
            is a comparison against a clock, and a stored copy of it would go
            stale on its own.
        createdAt:
          type: string
          format: date-time
          description: When the person asked for it.
        expiresAt:
          type: string
          format: date-time
          readOnly: true
          description: >-
            When this share stops: the instant the publish named, or the
            host's configured window from when it was asked for. It is the
            host's answer and never a client's calculation.

            A share whose expiry has passed is closed whether or not the sweep
            that deletes it has run yet — which is why `state` carries no
            `expired` value. Once the sweep HAS run, the copied entries are
            deleted and the state reads `revoked`, because that is what
            happened to it.
        recipients:
          type: array
          readOnly: true
          items:
            type: string
          description: >-
            Who this share is for, as the host now holds it: the subjects named on it, or ABSENT for the whole organisation.

            It is the host's record and never a client's copy of what it asked for.
      examples:
        - shareId: K7QX2ZM4YB6PWNRJ5TFHCDA3EV
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          mode: sync
          state: active
          createdAt: 2026-09-08T09:15:00Z
          expiresAt: 2026-10-08T09:15:00Z
          recipients: [user_grace, user_auditor]

    SessionShareList:
      type: object
      description: >-
        The live shares of one session, newest first.

        NO CURSOR AND NO LIMIT. A session is published once, occasionally twice, and every row is one a person may act on — so paging here would be a mechanism guarding a list that is already short.
      required: [shares]
      properties:
        shares:
          type: array
          items:
            $ref: '#/components/schemas/SessionShare'
      examples:
        - shares:
            - shareId: K7QX2ZM4YB6PWNRJ5TFHCDA3EV
              sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              mode: sync
              state: active
              createdAt: 2026-09-08T09:15:00Z
              expiresAt: 2026-10-08T09:15:00Z
              recipients: [user_grace]

    ShareRecipients:
      type: object
      description: >-
        Who a published share is for, after this request.

        THE WHOLE LIST AND NOT A CHANGE TO ONE. What is sent is what the share names afterwards, so adding a colleague and removing another is a single act with a single answer — a body that carried "add these, remove those" would be two lists that can contradict each other and a third rule for what happens when they do.

        ABSENT OR EMPTY IS THE WHOLE ORGANISATION, the ordinary publish and the default — never "a share nobody may read". It is how a share narrowed by mistake is widened again without republishing the conversation.

        Naming somebody twice is the same grant said twice and is kept once. A value no sign-in could report — a blank, a pasted line — is refused by name with 422, because a share addressed to a typo is a share readable by nobody answered with a success.
      properties:
        recipients:
          type: array
          items:
            type: string
          description: >-
            The subjects of the colleagues this share is for. They are members
            of the same organisation: there is no cross-organisation share,
            because one organisation is one home and a token for
            another is refused before any handler runs.
      examples:
        - recipients: [user_grace, user_auditor]
        - recipients: []

    ShareDelivery:
      type: object
      description: >-
        One batch of a shared session crossing to the organisation's home.

        It carries the share's own facts alongside the entries because the
        first delivery is what announces the share at the receiving end —
        there is no earlier registration and no second operation for one. What
        it does NOT carry is who owns the share or which organisation it
        belongs to: the owner is the verified actor whose token carried the
        request, and the organisation is the home that took it.
      required: [sessionId, mode, entries]
      properties:
        sessionId:
          type: string
          format: uuid
          description: >-
            The session these entries came from, at the home they came from.
            Provenance only — no session with this id exists at the receiving
            end, and none is created.
        mode:
          type: string
          enum: [once, sync]
          description: >-
            Where the publish stops, so the receiving home's record is honest
            about whether more is coming.
        entries:
          type: array
          description: >-
            The entries of this batch, in the order the conversation happened
            in. One batch carries everything a drain found owed rather than
            one entry per request: a chatty session must not become a request
            per token.
          items:
            $ref: '#/components/schemas/ShareDeliveryEntry'
        expiresAt:
          type: string
          format: date-time
          description: >-
            When this share stops, carried so the receiving home stops it by
            its OWN clock. The sweep that deletes these entries runs there, so
            a copy that arrived without this would be a conversation whose
            expiry depended on the publishing machine being switched on.

            Absent means a share with no expiry, which nothing this product
            publishes produces.
        recipients:
          type: array
          items:
            type: string
          description: >-
            Who this share is for, as the publishing home holds it: the subjects of the colleagues it names, or ABSENT for the whole organisation — one spelling of "everybody here", matching the publishing home's own record.

            IT HAS TO TRAVEL, because the READ road is at this end. A copy that arrived without the list would be readable by every member of the organisation however narrowly the person who shared it chose, which is the one direction a targeted share must not be wrong in.

            It is read on the FIRST delivery, which is what announces the share here; a later batch does not re-open who a share this home already holds is for.
      examples:
        - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          mode: sync
          entries:
            - id: 3f1c9a52-8d4e-4b21-9c07-5e2a1d8b6f40
              kind: user-message
              sequence: 1
              text: 'deploy with ANTHROPIC_API_KEY=[redacted: masked-environment]'

    ShareDeliveryEntry:
      type: object
      description: >-
        One entry as it crosses: already redacted, still in its place.

        It is the same four facts SessionSharePreviewEntry reports, because it
        is the same computation — what a person was shown before the bytes
        left is what the bytes are.
      required: [id, kind, sequence]
      properties:
        id:
          type: string
          format: uuid
          description: >-
            The entry's own id at the home it was published from. It is what
            makes a delivery idempotent: the receiving home recognises a retry
            by it, which is the one thing the sending home cannot do for
            itself when an answer is lost.
        kind:
          type: string
          enum: [user-message, assistant-message, tool-call, tool-result, summary, system, intercom, monitor-event]
          description: The transcript row's kind, unchanged by redaction.
        sequence:
          type: integer
          format: int64
          minimum: 1
          description: >-
            The entry's place in the transcript at the publishing home.
            Carried rather than re-derived: entries arrive in batches a drain
            decided the shape of, so arrival order is the drain's history and
            not the conversation's.
        text:
          type: string
          description: >-
            What is being sent for this entry, with every rule already
            applied. Absent for an entry that carries nothing this surface
            publishes — such an entry still crosses, so the sequence a reader
            follows stays honest.

    ReceivedShare:
      type: object
      description: >-
        One shared session as the home that RECEIVED it holds it.

        It is not SessionShare read from the other end, and the difference is which home is speaking. What a publishing home records is what it sent and what it still owes; what this records is what ARRIVED — so the instant on it is when this home took the first delivery, and the position on it is how far the copy here reaches rather than how far a delivery has been acknowledged.

        THERE IS NO ORGANISATION ON IT, deliberately. One organisation is one home: this home is stamped with the organisation it serves and refuses a token for any other before a handler runs, so the organisation a share belongs to is the organisation asking.
      required: [shareId, sessionId, publisher, mode, state, receivedAt, throughSeq]
      properties:
        shareId:
          type: string
          minLength: 26
          maxLength: 26
          pattern: '^[A-Z2-7]{26}$'
          description: >-
            The share's id, which is how this copy is addressed here and
            everywhere else. It carries no encoding of anything, so it
            discloses nothing about the session or the machine it came from.
        sessionId:
          type: string
          format: uuid
          description: >-
            The session this was published FROM, at the home it was published
            from. It is provenance and nothing more: no session with this id
            exists here, none is created, and asking this host about it
            answers nothing.
        publisher:
          type: string
          description: >-
            The subject of whoever published it, read off the verified token
            that carried the first delivery and never off a payload. It is an
            identifier and not a display name: names are the identity
            provider's to answer and would go stale here.
        mode:
          type: string
          enum: [once, sync]
          description: >-
            Where the publish stops, so a reader knows whether more of this
            conversation is still coming.
        state:
          type: string
          enum: [pending, active, revoked]
          description: >-
            How far it has got. On this road it is always `active`: a revoked
            share and one whose expiry has passed answer 404, because the
            conversation they carried has been deleted and confirming that
            such a share ever existed is itself a disclosure. The field
            carries the full enum so a client holds one vocabulary for a share
            at either home.
        receivedAt:
          type: string
          format: date-time
          description: >-
            When THIS home took the first delivery, by this home's own clock.
            It is deliberately not when the person asked for the share: the
            publishing machine's clock is not a fact this home can vouch for,
            and what a reader here asks is when the organisation received it.
        expiresAt:
          type: string
          format: date-time
          description: >-
            When this copy stops and is deleted, carried from the publishing
            home so this home enforces it by its own clock. A share whose
            expiry has passed is closed whether or not the sweep has been
            round yet — which is why `state` has no `expired` value.

            Absent for a share with no expiry, which nothing this product
            publishes produces.
        throughSeq:
          type: integer
          format: int64
          minimum: 0
          description: >-
            How far into the conversation this home reaches, in the publishing
            home's own numbering — the sequence of the last entry held. It is
            the same number the last row of a read of the entries carries, and
            the number share-received announces, so a client can tell "there
            is more" from "I already have this" without a request.
      examples:
        - shareId: K7QX2ZM4YB6PWNRJ5TFHCDA3EV
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          publisher: user_ada
          mode: sync
          state: active
          receivedAt: 2026-09-08T09:16:00Z
          expiresAt: 2026-10-08T09:15:00Z
          throughSeq: 42

    ReceivedSharePage:
      type: object
      description: >-
        One page of the shared sessions this home holds for whoever is asking,
        newest first.

        THE PAGE CAN BE SHORTER THAN ITS LIMIT. The audience rule is asked
        once per row and the rows a caller may not read are left out, so a
        page's length says nothing about whether there is another: a client
        pages until nextCursor is absent.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ReceivedShare'
        nextCursor:
          type: string
          description: >-
            Pass as `cursor` for the next page. Absent when this home holds
            nothing older.
        limit:
          type: integer
          minimum: 1
          description: The page size this host applied.

    ShareEntryPage:
      type: object
      description: >-
        One page of a shared session's copied entries, oldest first.

        THE ROWS ARE ShareDeliveryEntry, which is the shape they CROSSED in
        rather than a second vocabulary for the same four facts. What is held
        here is exactly what was delivered — already redacted, still in its
        place — so a reading of it that differed from the delivery's would be
        this home describing somebody's conversation in words nobody sent.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ShareDeliveryEntry'
        nextCursor:
          type: string
          description: >-
            Pass as `cursor` for the next page. Absent at the end of the
            conversation as this home holds it — which, for a `sync` share
            still being carried, is where the next delivery continues from.
        limit:
          type: integer
          minimum: 1
          description: The page size this host applied.

    PlatformAccess:
      type: object
      description: >-
        One time Lerian's platform reached this home: which operation, when, and how it ended.

        It names no session, no repository, no title and no argument.
      required: [seq, operation, actor, outcome, status, at]
      properties:
        seq:
          type: integer
          format: int64
          description: The position in this home's record, and the paging cursor.
        operation:
          type: string
          description: >-
            The operation that was called.
        actor:
          type: string
          description: >-
            Who the plane said it was.
        outcome:
          type: string
          enum: [served, refused]
          description: >-
            `served` when the call ended below status 400. `refused` when it ended at 400 or above, or the host failed while serving it.
          x-enum-varnames:
            - PlatformAccessOutcomeServed
            - PlatformAccessOutcomeRefused
        status:
          type: integer
          minimum: 100
          maximum: 599
          description: >-
            The HTTP status the call ended on, so a refusal says which way.

            EVERY ROW CARRIES ONE and this is never absent: a row is written
            after the handler returns, so the status is a fact the writer
            already holds, and the column it lands in refuses anything
            outside 100..599.
        at:
          type: string
          format: date-time
          description: When it happened.

    PlatformAccessPage:
      type: object
      description: >-
        One page of this home's platform accesses, newest first.

        An EMPTY page never means the record is unavailable: a home that cannot read its own audit answers 500 rather than an empty list.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/PlatformAccess'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
          description: >-
            The position the next page starts from, present only when this home
            holds an older entry. There is no prevCursor: the record is
            append-only and read backwards from the newest, so the page a
            client came from is the one it already has.

    Disclosure:
      type: object
      description: >-
        One access to a shared session: who reached what, and when.

        It names no share id and no session id.
      required: [actor, reached, at]
      properties:
        actor:
          type: string
          description: >-
            The issuer's subject for whoever read it. It is an identifier and
            not a display name: names are the identity provider's to answer,
            they change, and a
            row in a table that never changes would go stale carrying one. A
            client that wants a face resolves it, exactly as it does for a
            session's owner.
        reached:
          type: string
          description: >-
            What was served, in the words of the road that served it: the
            session itself, an entry page, one media key. Deliberately not a
            fixed vocabulary — the honest answer is which surface was asked
            for, and an enum would have to be extended by every operation
            added afterwards, with an audit recording the wrong surface in the
            meantime.
        at:
          type: string
          format: date-time
          description: When the read was served, on the clock of the home that served it.

    DisclosurePage:
      type: object
      description: >-
        One page of a shared session's audit, newest first.

        An EMPTY page never means the audit is unavailable: a home that cannot read its own audit answers 500 rather than an empty list.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Disclosure'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
          description: >-
            The position the next page starts from, present only when this home
            holds an older entry. There is no prevCursor: the audit is
            append-only and read backwards from the newest, so the page a
            client came from is the one it already has.
      examples:
        - items:
            - actor: user_01HZX3Q8N4KDPYV2A7C9M5T6RB
              reached: entries
              at: 2026-09-08T11:04:21Z
            - actor: user_01HZX3Q8N4KDPYV2A7C9M5T6RB
              reached: media/screenshot.png
              at: 2026-09-08T11:04:19Z
          limit: 25

    RefinementKind:
      type: string
      enum: [memory, skill, agent, command]
      # Named explicitly for ClientKind's reason: left to the generator these
      # are Memory/Skill/Agent/Command in the package third parties import,
      # which any later enum carrying one of those words would rename.
      x-enum-varnames:
        - RefinementKindMemory
        - RefinementKindSkill
        - RefinementKindAgent
        - RefinementKindCommand
      description: >-
        What a refinement IS. Four values and no more — one vocabulary is what makes "everything this repository taught narya" one question.

    RefinementScope:
      type: string
      enum: [repository, global]
      x-enum-varnames:
        - RefinementScopeRepository
        - RefinementScopeGlobal
      description: >-
        How far a refinement reaches. `repository` is keyed on the canonical
        checkout path, the same key a trust answer uses; `global` is the
        owner's.

        There is no `session` here and the absence is a decision: a
        session-scoped fact lives exactly as long as the conversation that
        gave it, so it is never a stored row at all.

    RefinementState:
      type: string
      enum: [pending, active, rolled-back, unverifiable]
      x-enum-varnames:
        - RefinementStatePending
        - RefinementStateActive
        - RefinementStateRolledBack
        - RefinementStateUnverifiable
      description: >-
        Where a refinement stands. `pending` is proposed and nothing else — never rendered, never executed, never on a ladder. `active` is consented and firing. `rolled-back` is kept for the record.

        `unverifiable` is the one that surprises: such a fact is STILL FIRING and says so. Every session it cited has been purged, which is a person deleting a transcript rather than a person retracting a belief.

    RefinementOrigin:
      type: string
      enum: [loop, api, import]
      x-enum-varnames:
        - RefinementOriginLoop
        - RefinementOriginApi
        - RefinementOriginImport
      description: >-
        Which road a refinement came in on: narya's own review of a
        conversation, a client teaching it through this contract, or an
        import. Both roads land pending and pass the same consent.

    RefinementAction:
      type: string
      enum: [created, activated, updated, promoted, demoted, rolled-back, deleted]
      x-enum-varnames:
        - RefinementActionCreated
        - RefinementActionActivated
        - RefinementActionUpdated
        - RefinementActionPromoted
        - RefinementActionDemoted
        - RefinementActionRolledBack
        - RefinementActionDeleted
      description: >-
        What happened to a refinement. There is no `restored`: restoring what
        somebody undid is a rollback OF a rollback, so undo and redo are one
        verb aimed at different events and the log nests instead of branching.

    RefinementCitation:
      type: object
      description: >-
        One pointer into the transcript that justifies a refinement. The
        admitted user entry's id IS the turn id, so citing the turns needs
        no second identifier.

        THE POINTER MAY DANGLE, and that is the design: nothing cascades from
        a purged session to a fact learned in it, because a person deleting
        one noisy conversation must not silently lose weeks of learning. A
        fact whose evidence is gone becomes `unverifiable` and keeps firing.
      required: [sessionId, entryId]
      properties:
        sessionId:
          type: string
          format: uuid
        entryId:
          type: string
          format: uuid
        note:
          type: string
          description: What the review said about this turn.
          maxLength: 2000
      examples:
        - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          entryId: 8a1d3f5b-7c9e-4a2b-8d6f-0c2e4a6b8d0f
          note: the third attempt at the same import cycle

    RefinementEventEntry:
      type: object
      description: >-
        One thing that happened to one refinement. The log is APPEND-ONLY:
        nothing in it is ever updated or deleted, because "who taught narya
        this, and who took it away" has to be answerable from the log alone.
      required: [id, action, at]
      properties:
        id:
          type: string
          format: uuid
        action:
          $ref: '#/components/schemas/RefinementAction'
        actor:
          type: string
          description: Who acted. Empty when narya's own loop did.
        at:
          type: string
          format: date-time
        causedByEventId:
          type: string
          format: uuid
          description: >-
            The event this one inverts, absent when it inverts nothing. It is
            what makes a rollback of a rollback readable as a redo.
      examples:
        - id: 5c7e9a1b-3d5f-4a7c-9e1b-3d5f7a9c1e3b
          action: activated
          actor: local
          at: 2026-09-07T09:12:00Z

    Refinement:
      type: object
      description: >-
        One thing the harness learned and a person allowed: a distilled memory, or a skill, agent or command definition the model authored.

        The owner is never something a caller names.

        `script` and `events` are present on getRefinement and absent from the listing. A page carrying every authored program's source is a page nobody can read; the hash is on both, which is what "what was consented to is what runs" is checked against.
      required: [id, kind, scope, owner, title, body, state, origin, createdAt, hits]
      properties:
        id:
          type: string
          format: uuid
        kind:
          $ref: '#/components/schemas/RefinementKind'
        scope:
          $ref: '#/components/schemas/RefinementScope'
        repository:
          type: string
          description: >-
            The canonical checkout path, absent at the owner's global scope;
            on a hosted home, relative to its workspace.
        owner:
          type: string
          description: Whose learning this is.
        name:
          type: string
          description: >-
            The ladder identity of the three definition kinds, absent on a memory.
          maxLength: 128
        title:
          type: string
          maxLength: 200
        body:
          type: string
          description: The fact's text, or the definition's prompt or description.
        script:
          type: string
          description: >-
            The program the model wrote, IN FULL — never a digest and never a
            path. A person cannot consent to what they were not shown, and
            what they consented to is what the confined runtime later runs,
            byte for byte. Present on getRefinement only.
        scriptSha256:
          type: string
          description: >-
            The hash of that program, on the listing as well as the read. It
            is what the runtime checks the text against before running it.
          pattern: '^[0-9a-f]{64}$'
        state:
          $ref: '#/components/schemas/RefinementState'
        origin:
          $ref: '#/components/schemas/RefinementOrigin'
        originSessionId:
          type: string
          description: >-
            The conversation this was learned from. It may name a session that
            has since been purged, which is what `unverifiable` reads from.
        originEntryId:
          type: string
          description: The turn this was learned in.
        originActor:
          type: string
          description: Who was acting when this came in over the contract.
        citations:
          type: array
          description: The turns this stands on, in the order they were written.
          items:
            $ref: '#/components/schemas/RefinementCitation'
        events:
          type: array
          description: >-
            Everything that has happened to this refinement, newest first.
            Present on getRefinement only.
          items:
            $ref: '#/components/schemas/RefinementEventEntry'
        createdAt:
          type: string
          format: date-time
        activatedAt:
          type: string
          format: date-time
          description: When a person consented. Absent while pending.
        lastUsedAt:
          type: string
          format: date-time
          description: >-
            When this last fired into a prompt. It is what eviction is ordered
            by — a fact that fires often is a fact that earned its place —
            and it is deliberately never rewound by a rollback.
        hits:
          type: integer
          format: int32
          minimum: 0
          description: How many turns this has fired into.
        supersededBy:
          type: string
          format: uuid
          description: The row that replaced this one, if any.
      examples:
        - id: 7d1e3a5c-9b2f-4e6a-8c0d-2f4a6c8e0b2d
          kind: memory
          scope: repository
          repository: /Users/dana/repos/midaz
          owner: local
          title: Migrations and the schema document are one change
          body: >-
            A new migration file without the matching block in schema.sql
            fails two tests the moment it lands.
          state: active
          origin: loop
          originSessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          originEntryId: 8a1d3f5b-7c9e-4a2b-8d6f-0c2e4a6b8d0f
          citations:
            - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              entryId: 8a1d3f5b-7c9e-4a2b-8d6f-0c2e4a6b8d0f
          createdAt: 2026-09-07T09:12:00Z
          activatedAt: 2026-09-07T09:12:00Z
          hits: 4

    RefinementPage:
      type: object
      description: One page of refinements.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Refinement'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
        prevCursor:
          type: string
      examples:
        - items:
            - id: 7d1e3a5c-9b2f-4e6a-8c0d-2f4a6c8e0b2d
              kind: memory
              scope: repository
              repository: /Users/dana/repos/midaz
              owner: local
              title: Migrations and the schema document are one change
              body: A new migration without its schema block fails two tests.
              state: active
              origin: loop
              createdAt: 2026-09-07T09:12:00Z
              hits: 4
          limit: 25

    RefinementProposal:
      type: object
      description: >-
        One refinement a client is asking narya to learn. It becomes a pending
        item and nothing more until a person allows it.
      required: [kind, scope, title, citations]
      properties:
        kind:
          $ref: '#/components/schemas/RefinementKind'
        scope:
          $ref: '#/components/schemas/RefinementScope'
        repository:
          type: string
          description: >-
            The checkout a repository-scoped proposal belongs to. Required at
            that scope and meaningless at the owner's global one.
          maxLength: 4096
        name:
          type: string
          description: >-
            The ladder identity the three definition kinds are resolved by.
            Required for them, refused on a memory, and it carries no space,
            slash, backslash or colon.
          maxLength: 128
        title:
          type: string
          minLength: 1
          maxLength: 200
        body:
          type: string
          description: >-
            The fact's text, or the definition's prompt. A proposal with
            neither a body nor a script teaches nothing and is refused.
        script:
          type: string
          description: >-
            A program to author, IN FULL. Only a skill runs one: an agent
            recipe is a system prompt and a command expands into one, so a
            script on either would be inert.
        citations:
          type: array
          minItems: 1
          description: >-
            The turns this stands on. At least one is required — an edit
            citing no turn never becomes a row, whoever proposed it.
          items:
            $ref: '#/components/schemas/RefinementCitation'
      examples:
        - kind: memory
          scope: repository
          repository: /Users/dana/repos/midaz
          title: Migrations and the schema document are one change
          body: A new migration without its schema block fails two tests.
          citations:
            - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              entryId: 8a1d3f5b-7c9e-4a2b-8d6f-0c2e4a6b8d0f

    RefinementRound:
      type: object
      description: >-
        A round of proposed refinements standing on the consent surface,
        waiting on a person. It is what proposeRefinement answers with, and
        the id an answer is posted against.
      required: [roundId, itemCount]
      properties:
        roundId:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid
          description: >-
            The conversation the round was learned from.
        repository:
          type: string
          description: >-
            The canonical checkout path the round is answered against, absent at the owner's global scope — the ledger's own `repository` column, relative to the workspace on a hosted home. It is what keeps a round a complete question after the session that proposed it has been purged.
        itemCount:
          type: integer
          format: int32
          minimum: 1
      examples:
        - roundId: 2f7c1b93-4a5e-4d6f-8b0c-1e3a5c7d9f2b
          repository: /Users/dana/repos/midaz
          itemCount: 1

    RefinementRoundPage:
      type: object
      description: One page of rounds waiting on a person.
      required: [items, limit]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/RefinementRound'
        limit:
          type: integer
          minimum: 1
        nextCursor:
          type: string
      examples:
        - items:
            - roundId: 2f7c1b93-4a5e-4d6f-8b0c-1e3a5c7d9f2b
              sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              repository: /Users/dana/repos/midaz
              itemCount: 4
          limit: 25

    RefinementRoundDetail:
      type: object
      description: >-
        One round whole: what the review proposed, what the service dropped
        and why, and every item at the index an answer names it by.


        It is the only read that carries an item's body and an authored
        program's TEXT. The listing carries neither, for the reason
        listRefinements carries no script: a page nobody can read is not a
        page. Here the whole point is that everything is readable.
      required: [roundId, itemCount, proposed, items]
      properties:
        roundId:
          type: string
          format: uuid
        sessionId:
          type: string
          format: uuid
          description: >-
            The ONE conversation every citation in this round points into. It
            is on the round rather than on each citation because a review may
            only cite what it read, and what it read is one conversation.


            Absent on a round whose stored conversation is not addressable —
            a round proposed with no session, or one whose id this host can no
            longer parse. A client then draws the round without the jump.
        repository:
          type: string
          description: >-
            The canonical checkout path the round is answered against, absent at the owner's global scope — the ledger's own `repository` column, relative to the workspace on a hosted home. It is what keeps a round a complete question after the session that proposed it has been purged.
        origin:
          $ref: '#/components/schemas/RefinementOrigin'
        originActor:
          type: string
          description: >-
            Who asked for this round, on the roads where somebody did. Absent
            on `loop`, where the answer is nobody: narya read a conversation
            and proposed of its own accord, which is the fact that separates
            that road from the other two.


            It is not the consenting actor. Who ALLOWED a fact is on its
            events, where an answer belongs; who asked for it is here and on
            the row, where provenance belongs, and the two are the same person
            only by coincidence.
          maxLength: 128
        itemCount:
          type: integer
          format: int32
          minimum: 1
          description: >-
            How many items this round carries — the length of `items`.


            Never zero, and the bound says so because the engine's three
            parking roads all guarantee it: the loop's review refuses to park a
            round with no surviving edits, and the other two — a proposal over
            this contract, and asking to widen one fact's reach — each park
            exactly one item. The document is written once and never edited
            afterwards, so a round that stands is a round with items. Same
            bound, same reason, as the listing's own `itemCount`, which counts
            the very same list.
        proposed:
          type: integer
          format: int32
          minimum: 0
          description: >-
            How many edits the review proposed, which is `itemCount` plus
            everything the service dropped. "6 proposed, 2 kept" is what tells
            somebody whether the loop is worth having, and it is the service's
            own count rather than a client's arithmetic.
        dropped:
          type: array
          description: >-
            What the service refused and how much of it, by reason, ordered by
            reason so two reads of one round agree. Empty when it kept
            everything the review returned.
          items:
            $ref: '#/components/schemas/RefinementRoundDropped'
        rationale:
          type: string
          description: What the gate said when it decided this round was worth running.
        items:
          type: array
          description: >-
            The items in the order the engine put them, which is the order a
            client draws them in and the order the indices count. A client
            that sorted these rows would post somebody's approval onto a
            different edit.
          items:
            $ref: '#/components/schemas/RefinementRoundItem'
      examples:
        - roundId: 2f7c1b93-4a5e-4d6f-8b0c-1e3a5c7d9f2b
          sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
          repository: /Users/dana/repos/midaz
          origin: loop
          itemCount: 1
          proposed: 3
          dropped:
            - reason: no-citations
              count: 2
          items:
            - index: 0
              action: create
              kind: memory
              scope: repository
              title: Migrations and the schema document are one change
              body: A new migration without its schema block fails two tests.
              citations:
                - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
                  entryId: 8a1d3f5b-7c9e-4a2b-8d6f-0c2e4a6b8d0f

    RefinementRoundDropped:
      type: object
      description: >-
        One reason the service refused part of what the review returned, and
        how many edits it cost. Reported rather than hidden: "the gate
        proposed platitudes" has to be a visible number rather than a feeling.
      required: [reason, count]
      properties:
        reason:
          type: string
        count:
          type: integer
          format: int32
          minimum: 1
      examples:
        - reason: no-citations
          count: 2

    RefinementRoundItem:
      type: object
      description: >-
        One proposed edit, at the index an answer marks it by.


        The index is WRITTEN rather than left to the array position, because
        the mark that comes back names it and a client that had to count would
        be a client doing arithmetic the host already did.


        `action`, `kind` and `scope` are the MODEL's own words and are plain
        strings on purpose: the review keeps them untyped, and they are typed
        at the moment a person's answer turns a proposal into a row. Declaring
        them as the ledger's enums would make a generated client refuse to
        display a round it is being asked to judge.
      required: [index, action, kind, scope, title, citations]
      properties:
        index:
          type: integer
          format: int32
          minimum: 0
        action:
          type: string
          description: >-
            What the edit does — `create`, `update` or `delete` as the review
            wrote it, or `promote`, which a round raised by
            `promoteRefinement` carries and which points at a row the
            ledger already holds rather than describing a new one.
        kind:
          type: string
          description: What the edit is about — a memory, a skill, an agent or a command, as the review wrote it.
        scope:
          type: string
          description: The reach the edit asks for, as the review wrote it.
        name:
          type: string
          description: The ladder identity a definition would take. Empty on a memory.
        title:
          type: string
        body:
          type: string
          description: >-
            The fact's text, or the definition's prompt, IN FULL. Never a
            summary and never cut: this is the text a person is consenting to.
        script:
          type: string
          description: >-
            The program narya authored, IN FULL. Never a hash, never a path
            and never a head: what a person allows here is what the confined
            runtime later runs, byte for byte.
        baseline:
          type: string
          description: >-
            The definition this edit would REPLACE, as the review saw it, so
            the screen is a diff rather than a list of proposals. Empty when
            the edit creates something that does not exist yet.
        citations:
          type: array
          description: >-
            The turns this edit stands on. Never empty on a round that reached
            a person: an edit citing no turn is dropped before the round is
            parked.
          items:
            $ref: '#/components/schemas/RefinementCitation'
      examples:
        - index: 0
          action: create
          kind: skill
          scope: repository
          name: rerun-migrations
          title: Re-run the migrations against a scratch store
          body: Use it before touching the schema document.
          script: |
            const out = await sh("goose up");
            return out.stdout;
          citations:
            - sessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d
              entryId: 8a1d3f5b-7c9e-4a2b-8d6f-0c2e4a6b8d0f

    RefinementRoundAnswer:
      type: object
      description: >-
        What a person decided about one round. Three verbs and four answers on
        the screen: accepting the round, accepting the marked items, rejecting
        the round, and rejecting it with a message — the last two write exactly
        the same rows (none) and differ only in what the next review reads.
      required: [roundAnswer]
      properties:
        roundAnswer:
          type: string
          enum: [accept-round, accept-marked, reject-round]
          x-enum-varnames:
            - RefinementRoundAnswerAcceptRound
            - RefinementRoundAnswerAcceptMarked
            - RefinementRoundAnswerRejectRound
        marks:
          type: array
          description: >-
            One entry per item a person marked, naming it by the index the
            round sent. Refused alongside `accept-round`, which means all of
            it and carries no marks on purpose: a client saying "all of it"
            must not have to enumerate the round back.
          items:
            $ref: '#/components/schemas/RefinementMark'
        message:
          type: string
          description: >-
            What the person typed. It is not decoration: the next review reads
            it as a correction, which is what stops the same rejected fact
            being proposed again tomorrow night.
          maxLength: 4000
      examples:
        - roundAnswer: accept-marked
          marks:
            - index: 0
              accepted: true
            - index: 1
              accepted: false
          message: the second one is about my editor, not about this repository

    RefinementMark:
      type: object
      description: One item's answer, named by the index the round sent.
      required: [index, accepted]
      properties:
        index:
          type: integer
          format: int32
          minimum: 0
          description: >-
            The item's position in the round exactly as the engine sent it. A
            client that reordered the items would post somebody's approval
            onto a different edit.
        accepted:
          type: boolean
      examples:
        - index: 0
          accepted: true

    RefinementRefusal:
      type: object
      description: >-
        One accepted mark that did not become a row, and why. It is reported
        rather than raised: a round is answered as a whole, so one edit narya
        cannot apply must not throw away the person's answer to the other four.


        Usually a judgment — a verb this host does not apply at consent, a
        baseline the ledger has moved past, a name no ladder could resolve. It
        is also where a STORE fault lands when one arrives part way through a
        round: the answer is settled and cannot be given again, so the item the
        ledger would not take is named here with the store's own words, and
        nothing after it in the round was attempted.
      required: [index, reason]
      properties:
        index:
          type: integer
          format: int32
          minimum: 0
        reason:
          type: string
      examples:
        - index: 2
          reason: 'a repository-scoped fact was proposed in a round with no repository'

    RefinementRoundOutcome:
      type: object
      description: What one answered round left behind.
      required: [written, refused]
      properties:
        written:
          type: array
          description: One row per accepted mark that survived, in the round's own order.
          items:
            $ref: '#/components/schemas/Refinement'
        refused:
          type: array
          items:
            $ref: '#/components/schemas/RefinementRefusal'
        appliedToSessionId:
          type: string
          format: uuid
          description: >-
            The ONE conversation that takes this round on its next turn — the
            one the round was taught in, which has already paid for the prompt
            prefix this changes. Every other session, including another window
            of the same person's, takes the facts from its next start. Absent
            when nothing was written.
      examples:
        - written:
            - id: 7d1e3a5c-9b2f-4e6a-8c0d-2f4a6c8e0b2d
              kind: memory
              scope: repository
              owner: local
              title: Migrations and the schema document are one change
              body: A new migration without its schema block fails two tests.
              state: active
              origin: loop
              createdAt: 2026-09-07T09:12:00Z
              hits: 0
          refused: []
          appliedToSessionId: 6b9f6d2e-1c3a-4f5b-9d7e-2a8c4e6f0b1d

    RefinementRollback:
      type: object
      description: How far back to undo.
      properties:
        toEventId:
          type: string
          format: uuid
          description: >-
            Undo down to and INCLUDING this event. Absent undoes the whole
            history, back to the creation, which leaves the row in
            `rolled-back` rather than deleting it.
      examples:
        - toEventId: 5c7e9a1b-3d5f-4a7c-9e1b-3d5f7a9c1e3b

    RefinementDemotion:
      type: object
      description: >-
        Where a narrowing leaves the fact. The ledger keeps exactly two
        scopes, so a demotion has nothing to say except which checkout the
        fact keeps applying in — and it has to say that, because a narrowing
        to nowhere would leave the row where it was with nothing reported.
      required: [repository]
      properties:
        repository:
          type: string
          minLength: 1
          description: >-
            The checkout the fact keeps applying in, canonicalised by the
            host the same way a trust answer's path is.

            `""` IS NOT A NARROWING TO THE OWNER'S GLOBAL SCOPE by another
            spelling, and the host refuses it with a 422 rather than reading
            it as one — as it refuses anything else that canonicalises to
            nothing. Required and non-empty are one statement here: the only
            thing a demotion can be missing is its destination, and a row
            silently left where it was is the write nobody asked for.
      examples:
        - repository: /Users/dev/repos/ledger

    DroppedRefinement:
      type: object
      description: One learned fact the budget could not fit into this prompt.
      required: [id, reason]
      properties:
        id:
          type: string
          format: uuid
        reason:
          type: string
          description: Why it did not fire.
      examples:
        - id: 7d1e3a5c-9b2f-4e6a-8c0d-2f4a6c8e0b2d
          reason: the budget for this model's context window was already spent

    AssembledPrompt:
      type: object
      description: >-
        The exact system prompt this session's next turn will send, and the
        ledger's accounting beside it.

        THE ACCOUNTING IS DATA AND NOT A METHOD. Which facts fired and which
        were dropped are facts about this prompt; the order they were composed
        in, the wrapper they were drawn with, the formula that sized the budget
        and the rule that picked them are the engine's and are deliberately not
        here.

        A fact the budget dropped is STILL STORED and still listed — the
        ledger is not evicted, the render is — so it comes back the moment
        there is room.
      required: [system, fired, dropped, droppedCount]
      properties:
        system:
          type: string
          description: The prompt, verbatim.
        fired:
          type: array
          description: The refinements that reached this prompt, in the order they were drawn.
          items:
            type: string
            format: uuid
        dropped:
          type: array
          items:
            $ref: '#/components/schemas/DroppedRefinement'
        droppedCount:
          type: integer
          format: int32
          minimum: 0
          description: >-
            How many were dropped. It is on the wire beside the list for the
            reason the model is told the same number: a person reading a short
            list wants to know whether it is short because little was learned
            or because little fits.
      examples:
        - system: "You are narya…\n\n<learned from=\"session 4f2a, turn 17\">…</learned>"
          fired:
            - 7d1e3a5c-9b2f-4e6a-8c0d-2f4a6c8e0b2d
          dropped: []
          droppedCount: 0
