# Execution API — OpenAPI description
#
# Hand-written from the shipped implementation in
# src/Content/Presentation/Presentation.ExecutionApi/. This host does NOT serve a generated
# OpenAPI document at runtime (no Swashbuckle / AddOpenApi registration in Program.cs), so
# this file is the machine-readable contract.
#
# FILENAME NOTE: this file is still called bundle-api.yaml because it is published at a stable
# URL (<site>/assets/openapi/bundle-api.yaml) that external consumers and chapter 17 both link
# to. The host outgrew the name when it gained workflows and tool discovery; renaming the file
# would break every existing link for no contract benefit. The `info.title` below is the
# authoritative name.
#
# This file is one of several hand-maintained descriptions of the same contract. The canonical
# list of everything that must change together lives in the "Change the wire contract" section
# of src/Content/Presentation/Presentation.ExecutionApi/README.md -- follow it, not just this note.
#
# Deployed alongside the developer guide at:
#   <site>/assets/openapi/bundle-api.yaml
openapi: 3.1.0

info:
  title: Microsoft Agentic Harness — Execution API
  version: "2.0.0"
  summary: >-
    Run externally-authored agents, submit and run workflows, and discover the tools a credential
    may invoke — each under a per-caller capability grant.
  description: |
    The Execution API is the harness's HTTP front door for **automation the host did not write**.
    It exposes three families of operation, all sharing one authentication scheme, one owner-binding
    rule, and one capability envelope:

    | Family | Routes | What it is for |
    |---|---|---|
    | **Bundles** | `/api/bundles` | Upload an agent the host did not write, then run it |
    | **Workflows** | `/api/workflows` | Submit a DAG of steps, run it, watch it, cancel it |
    | **Tools** | `/api/tools` | Discover which tool names *this* credential may invoke |

    Start with **Tools** if you are writing a workflow: a `ToolUse` step names a tool and an
    operation, and `GET /api/tools` is the authoritative answer to which names this host accepts
    from you.

    ---

    The Bundle family is the harness's HTTP front door for **running agents the host did not write**.

    A caller uploads a *bundle* (a zip containing an `AGENT.md`, optional nested `skills/*/SKILL.md`,
    and optional plugin manifests), receives a short-lived **handle**, and then starts **runs** against
    that handle. A run is a bounded multi-turn conversation with the bundle's ephemeral agent.

    Three properties define the security model, and clients should design against them:

    1. **The bundle declares; the host grants.** A bundle's own `allowed-tools` and autonomy
       declarations are *requests*. The authoritative grant is the **capability envelope** the host
       resolves from the calling credential (`AppConfig:AI:BundleExecution:Envelopes`). Anything
       outside the envelope is denied. An unconfigured envelope grants nothing.
    2. **The envelope is resolved from the credential that starts the run**, not the one that
       registered the bundle — so a leaked handle cannot escalate privilege.
    3. **Handles and runs are owner-bound.** Only the caller that registered a handle may run,
       poll, stream, or delete it. A foreign or unknown handle/run is reported identically as
       `404` (or a silent no-op for `DELETE`), so the API never confirms that someone else's
       resource exists.

    The whole subsystem is **off by default**. When `AppConfig:AI:BundleExecution:Enabled` is
    `false`, every operation below returns `403`.

    Runs are **not durable**. Handles, run records, and results live in memory on a single host
    and are evicted by TTL; a host restart loses in-flight and completed runs. Treat the API as a
    request/response execution surface, not a system of record.
  license:
    name: See repository LICENSE
  contact:
    name: Microsoft Agentic Harness
    url: https://github.com/MCKRUZ/microsoft-agentic-harness

servers:
  - url: "{scheme}://{host}"
    description: >-
      The Presentation.ExecutionApi host. It ships no launch profile, so the bind address is entirely
      deployment-defined — set ASPNETCORE_URLS explicitly. The defaults below match the local
      quickstart in chapter 17 of the developer guide (`ASPNETCORE_URLS=http://localhost:5000`);
      use `https` for any deployment that is not a developer's own machine.
    variables:
      scheme:
        default: http
        enum: [https, http]
      host:
        default: localhost:5000

security:
  - bearerAuth: []

tags:
  - name: Bundles
    description: Register, delete, and inspect staged agent bundles.
  - name: Runs
    description: Start, poll, and stream bundle runs.
  - name: Workflows
    description: Submit workflow definitions and manage their runs.
  - name: Evals
    description: >-
      Run the host's evaluation suites and poll their reports. Requires the
      `Harness.Evals.Execute` role - unlike every other route here, this one spends the host's model
      budget on the operator's suites rather than running the caller's own work. Also off unless
      `AppConfig:AI:Evaluation:Enabled` is set, and the host refuses to start when it is set without
      any `DatasetRoots`.
  - name: Tools
    description: >-
      Read-only discovery of the tools the calling credential may invoke. Distinct from AgentHub's
      `/api/mcp/tools`, which lists tools published by external MCP servers.

paths:
  /api/bundles:
    post:
      tags: [Bundles]
      operationId: registerBundle
      summary: Register a bundle archive and receive a handle
      description: |
        Uploads a zip archive as `multipart/form-data`. The field name **must be `file`**.

        The archive is validated against the hostile-input guards *before* anything in it is parsed
        (size, entry count, total uncompressed size, compression ratio, zip-slip, escaping symlinks),
        extracted to an isolated staging directory, and then parsed with the harness's ordinary
        `AGENT.md` / `SKILL.md` / `plugin.json` parsers. Any guard failure deletes the partial
        extraction and returns `400`.

        The archive **must** contain `AGENT.md` at its root, and that manifest must resolve to a
        non-empty id (via its `id:` or `name:` front-matter key).

        The returned handle has a **sliding** TTL (default 30 minutes): it is refreshed each time the
        handle is looked up or a run against it starts, so an actively-used bundle stays alive and an
        abandoned one is swept along with its staging directory.

        Rate limit: 10 requests per minute per caller.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The bundle zip archive. Must be non-empty and within `MaxArchiveBytes`.
      responses:
        "201":
          description: The bundle was staged and a handle issued.
          headers:
            Location:
              description: >-
                `/api/bundles/{handle}`. Note this URL answers only `DELETE`; there is no
                GET-a-bundle endpoint. Use the handle to start runs.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/RegisterBundleResponse" }
        "400":
          $ref: "#/components/responses/ValidationProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/DisabledProblem"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerProblem"

  /api/bundles/{handle}:
    parameters:
      - $ref: "#/components/parameters/Handle"
    delete:
      tags: [Bundles]
      operationId: deleteBundle
      summary: Delete a staged bundle
      description: |
        Removes the handle and (once no in-flight run holds a lease on it) deletes its staging
        directory. The TTL sweeper also reclaims handles, so this is an optimisation, not an
        obligation.

        **Idempotent and non-disclosing.** An unknown handle, an expired handle, and a handle owned
        by a different caller all return `204` — the endpoint never reveals that a handle exists for
        someone else.
      responses:
        "204":
          description: The handle is gone (deleted now, already absent, or not yours to delete).
        "400":
          $ref: "#/components/responses/ValidationProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/DisabledProblem"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/bundles/{handle}/runs:
    parameters:
      - $ref: "#/components/parameters/Handle"
    post:
      tags: [Runs]
      operationId: startBundleRun
      summary: Start a run of a staged bundle
      description: |
        Creates a run job and returns immediately with a job id. The capability envelope for the
        **calling** credential is resolved here, at the transport boundary, and captured on the run —
        it is what confines the agent for the whole run.

        Two dispatch modes, chosen by `stream`:

        * `stream: false` (default) — the run is enqueued and executed by the host's background
          dispatcher. Poll `statusUrl` for the result. The dispatcher drains its queue **one run at a
          time**, so a queued run waits behind other queued runs on the same host.
        * `stream: true` — the run is **reserved but not enqueued**. Nothing executes until you open
          `streamUrl`; opening it is what drives the run. If you never open it, the reservation is
          reclaimed after `StreamReservationTtl` (default 5 minutes).

        A run against an unknown, expired, or foreign handle returns `404`.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/RunBundleRequest" }
            examples:
              poll:
                summary: Background run, poll for the result
                value:
                  userMessages: ["Summarise the attached policy in three bullets."]
                  maxTurns: 3
              stream:
                summary: Live run, streamed over SSE
                value:
                  userMessages: ["Walk me through what you found."]
                  maxTurns: 5
                  stream: true
              continue:
                summary: Continue a durable conversation — send only the new message
                value:
                  userMessages: ["And what about the second clause?"]
                  maxTurns: 3
                  conversationId: "voice-session-8f14e45f"
      responses:
        "202":
          description: The run was created. It is queued for background dispatch, or reserved awaiting its stream.
          headers:
            Location:
              description: The run's status URL.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartRunResponse" }
        "400":
          $ref: "#/components/responses/ValidationProblem"
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/DisabledProblem"
        "404":
          $ref: "#/components/responses/NotFoundProblem"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/ServerProblem"

  /api/bundles/{handle}/runs/{jobId}:
    parameters:
      - $ref: "#/components/parameters/Handle"
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Runs]
      operationId: getBundleRun
      summary: Poll a run's status and result
      description: |
        Returns the run's lifecycle state and, once terminal, its outcome. The response is a
        deliberate projection: it never echoes the capability envelope, the seed messages, or any
        other execution input.

        A run is readable only by the caller that started it, under the handle it was started
        against. A mismatch on either is reported as `404`.

        A completed run stays pollable for `RunRecordTtl` (default 30 minutes) and is then swept.

        **`Succeeded` is about the run, not the answer.** A conversation that completed but whose
        agent reported a failed turn is `Succeeded` with `result.conversationSucceeded: false` and
        the reason in `result.conversationError`. Only an unhandled exception, an expired handle, or
        cancellation makes the run itself `Failed`.
      responses:
        "200":
          description: The current state of the run.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BundleRunResponse" }
              examples:
                succeeded:
                  summary: Completed successfully
                  value:
                    jobId: "0b8f1c2d3e4f5a6b7c8d9e0f1a2b3c4d"
                    status: Succeeded
                    createdAt: "2026-07-24T14:03:11.412Z"
                    startedAt: "2026-07-24T14:03:11.980Z"
                    completedAt: "2026-07-24T14:03:19.117Z"
                    result:
                      conversationSucceeded: true
                      finalResponse: "Three bullets: ..."
                      turnCount: 1
                      totalToolInvocations: 2
                      budgetExhausted: false
                completedButFailedTurn:
                  summary: Run succeeded, conversation did not
                  value:
                    jobId: "0b8f1c2d3e4f5a6b7c8d9e0f1a2b3c4d"
                    status: Succeeded
                    createdAt: "2026-07-24T14:03:11.412Z"
                    startedAt: "2026-07-24T14:03:11.980Z"
                    completedAt: "2026-07-24T14:03:14.002Z"
                    result:
                      conversationSucceeded: false
                      finalResponse: ""
                      turnCount: 2
                      totalToolInvocations: 0
                      budgetExhausted: false
                      conversationError: "Turn 2 failed: ..."
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/DisabledProblem"
        "404":
          $ref: "#/components/responses/NotFoundProblem"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/bundles/{handle}/runs/{jobId}/stream:
    parameters:
      - $ref: "#/components/parameters/Handle"
      - $ref: "#/components/parameters/JobId"
    get:
      tags: [Runs]
      operationId: streamBundleRun
      summary: Drive and stream a reserved run over Server-Sent Events
      description: |
        Opens the live SSE feed for a run started with `stream: true`. **Opening this endpoint is
        what executes the run** — it is driven inline on this connection for the whole conversation.

        Only a run that is still `Queued` *and* was reserved for streaming is driveable. A background
        run, a run already being streamed, and a finished run all return `409` — poll the status
        endpoint instead.

        Closing the connection cancels the run (the request-abort token flows into the executor) and
        the run is recorded as `Failed` with reason `The run was cancelled.`

        Frames are `data: {json}\n\n` with camelCase properties and a `type` discriminator matching
        the AG-UI wire vocabulary — a hand-written client that switches on `type` works unchanged.
        A strict `@ag-ui/client`-style consumer is not guaranteed to: tool-call frames can arrive
        while a `TEXT_MESSAGE` is still open (see below), which some AG-UI client libraries reject,
        and `TOOL_CALL_RESULT`'s fields (`toolCallId`, `result`) have no AG-UI precedent to match
        against. Exactly one terminal frame is emitted: `RUN_FINISHED` or `RUN_ERROR`.

        A tool call interleaves as `TOOL_CALL_START` → `TOOL_CALL_ARGS` → `TOOL_CALL_END`, then
        `TOOL_CALL_RESULT` once the tool has run — freely interleaved with `TEXT_MESSAGE_*` frames,
        and possibly *before* the first `TEXT_MESSAGE_START` if the agent calls a tool before
        producing any text. A client must not assume the assistant message opens first.

        **Concurrency limit, not a rate limit.** Each open stream holds a permit for its lifetime;
        a caller may hold `MaxConcurrentStreamsPerCaller` (default 4) at once. Excess connections
        are rejected outright with `429`, never queued.
      responses:
        "200":
          description: The SSE stream. Ends after exactly one `RUN_FINISHED` or `RUN_ERROR` frame.
          headers:
            Cache-Control:
              schema: { type: string, examples: ["no-cache"] }
            X-Accel-Buffering:
              description: Set to `no` so intermediary proxies do not buffer frames.
              schema: { type: string, examples: ["no"] }
          content:
            text/event-stream:
              schema:
                type: string
              examples:
                run:
                  summary: A complete streamed run
                  value: |
                    data: {"type":"RUN_STARTED","threadId":"h-9f3c...","runId":"0b8f1c2d..."}

                    data: {"type":"TOOL_CALL_START","toolCallId":"c-1a2b...","toolCallName":"query_knowledge_graph"}

                    data: {"type":"TOOL_CALL_ARGS","toolCallId":"c-1a2b...","delta":"{\"query\":\"HPWH A004\"}"}

                    data: {"type":"TOOL_CALL_END","toolCallId":"c-1a2b..."}

                    data: {"type":"TOOL_CALL_RESULT","toolCallId":"c-1a2b...","result":"3 matching entries"}

                    data: {"type":"TEXT_MESSAGE_START","messageId":"7e2a...","role":"assistant"}

                    data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"7e2a...","delta":"Here"}

                    data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"7e2a...","delta":" is"}

                    data: {"type":"TEXT_MESSAGE_END","messageId":"7e2a..."}

                    data: {"type":"RUN_FINISHED","threadId":"h-9f3c...","runId":"0b8f1c2d..."}
        "401":
          $ref: "#/components/responses/UnauthorizedProblem"
        "403":
          $ref: "#/components/responses/DisabledProblem"
        "404":
          $ref: "#/components/responses/NotFoundProblem"
        "409":
          description: The run is not awaiting a stream (background run, already streaming, or finished).
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/ProblemDetails" }
              example:
                title: Run not streamable
                status: 409
                detail: This run is not awaiting a stream. Poll its status endpoint for the result.
        "429":
          description: >-
            The caller already holds `MaxConcurrentStreamsPerCaller` open streams. Close one and retry.

  # ---------------------------------------------------------------------------
  # Tools — read-only discovery
  # ---------------------------------------------------------------------------
  /api/tools:
    get:
      tags: [Tools]
      operationId: listTools
      summary: List the tools this credential may invoke
      description: |
        Returns the intersection of what the host registers and what your capability envelope
        grants. **This is not the host's tool inventory** — two credentials will legitimately see
        different listings and neither sees the whole.

        An empty list is a successful answer, not an error: the shipped default envelope grants no
        tools, so a host whose operator has configured no grants for you answers `200` with
        `{"tools": []}`.

        Tools the host registers but cannot construct are omitted. That is not a failure to report
        them — a tool that cannot be built cannot be invoked either. In practice this affects
        `dashboard_control` and the `render_*` tools, which require an interactive client that only
        the agent hub provides.
      responses:
        "200":
          description: The granted tools, ordered by name.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ToolCatalogResponse" }
              examples:
                granted:
                  summary: A credential granted two tools
                  value:
                    tools:
                      - name: document_search
                        description: Searches ingested documents.
                        operations: [search]
                        riskTier: Low
                        isReadOnly: true
                        isConcurrencySafe: true
                        isDirectlyInvocable: true
                      - name: file_system
                        description: Sandboxed file operations.
                        operations: [read, write, list]
                        riskTier: High
                        isReadOnly: false
                        isConcurrencySafe: false
                        isDirectlyInvocable: true
                nothingGranted:
                  summary: The fail-closed default — a successful, empty answer
                  value: { tools: [] }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/tools/{name}:
    get:
      tags: [Tools]
      operationId: getTool
      summary: Describe a single tool this credential may invoke
      description: |
        **A tool you are not granted answers `404`, identically to one that does not exist.** This is
        deliberate and you should not treat the two as distinguishable: a `403` would confirm the
        tool exists, letting any authenticated caller map the host's inventory one name at a time.
      parameters:
        - $ref: "#/components/parameters/ToolName"
      responses:
        "200":
          description: The tool descriptor.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ToolCatalogEntry" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "404":
          description: No such tool, **or** the tool exists and you are not granted it.
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/tools/{name}/invoke:
    post:
      tags: [Tools]
      operationId: invokeTool
      summary: Run one operation of one tool, synchronously
      description: |
        Executes a tool in the host and returns its result. **This is the most privileged surface
        this API exposes** - it runs host-side code on the host's own resources because you asked it
        to - so it is gated three times, and none of the three substitutes for another:

        1. You must authenticate.
        2. You must hold the **`Harness.Tools.Invoke`** role. Discovery (`GET /api/tools`) does not
           require it: listing a tool tells you what the host *could* do, invoking makes it happen,
           and an operator can hand out the first without the second.
        3. The tool must be granted by your capability envelope - enforced twice, once by the
           catalog lookup and again independently by the invocation governor.

        **It is off by default.** A host whose operator has not set
        `AppConfig:AI:DirectToolInvocation:Enabled` answers `403` to every call, including one from a
        caller holding the role. Unlike bundle execution and workflow submission, this is *not*
        enabled in the shipped configuration.

        **Read the status codes carefully - they are not interchangeable.**

        | Status | Meaning |
        |--------|---------|
        | `200` + `succeeded: true` | The tool ran and reported success |
        | `200` + `succeeded: false` | The tool ran and reported failure. It executed; it said no |
        | `400` | Malformed request - an operation the tool does not declare, or too many parameters |
        | `403` | Governance refused a tool you *are* granted, **or** the host has this surface off |
        | `404` | No such tool, **or** not granted to you, **or** not offered on this surface |
        | `504` | The tool did not finish inside its deadline |

        A common `403` worth recognising: an envelope whose `autonomyCeiling` is `Supervised` or
        `Restricted` currently suspends tool execution entirely, because mid-run approval routing is
        deferred. Only `Autonomous` permits invocation today.

        **Output is sanitized and bounded, never compressed.** The harness's tool-output compression
        summarises results and replaces content with pointers an agent can expand - pointers you
        cannot follow from out here. You get a truthful prefix with `outputTruncated: true` instead.
      parameters:
        - $ref: "#/components/parameters/ToolName"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ToolInvocationRequest" }
            examples:
              read:
                summary: Read a file through the sandboxed file tool
                value:
                  operation: read
                  parameters: { path: "notes/todo.md" }
              noParameters:
                summary: An operation that takes no arguments
                value: { operation: list }
      responses:
        "200":
          description: >-
            The invocation completed. Check `succeeded` - a tool that ran and reported failure also
            answers `200`, because the HTTP status describes the invocation, not the tool's verdict.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ToolInvocationResponse" }
              examples:
                ok:
                  summary: The tool succeeded
                  value:
                    tool: file_system
                    operation: read
                    succeeded: true
                    output: "- ship T2"
                    outputTruncated: false
                    durationMs: 12
                toolSaidNo:
                  summary: The tool ran and refused - still a 200
                  value:
                    tool: file_system
                    operation: read
                    succeeded: false
                    error: "Path is outside the configured sandbox."
                    outputTruncated: false
                    durationMs: 3
        "400":
          description: The request is malformed, or names an operation the tool does not declare.
        "401":
          description: >-
            No credential, **or** a credential carrying no identifier usable as a permission subject —
            no `oid`/`sub`, or one longer than 128 characters or outside `[A-Za-z0-9._:-]` (some
            identity providers emit base64url `sub` values containing `+` or `/`). Both mean the same
            thing: present a different token.
        "403":
          description: >-
            You lack `Harness.Tools.Invoke`, or governance refused this invocation, or the host has
            direct invocation disabled. Deliberately not told apart — which one applied would tell you
            whether you *would* be permitted if the operator switched the surface on.
        "404":
          description: >-
            No such tool, **or** you are not granted it, **or** it is not offered on this surface
            (`isDirectlyInvocable: false`). The three are deliberately indistinguishable.
        "413":
          description: >-
            The request body exceeded `AppConfig:AI:DirectToolInvocation:MaxRequestBytes` (64 KiB by
            default). Enforced by the server before the body is read, so an oversized request costs a
            length check rather than a parse.
        "429":
          description: >-
            You already have the maximum number of invocations **executing at once** (4 per caller).
            This endpoint is bounded by concurrency rather than by request rate, because an invocation
            occupies a server thread for its whole duration — a per-minute cap would not stop calls
            that are still running when the window rolls. Retry when one of yours finishes.
        "504":
          description: >-
            The invocation did not complete within its deadline and was cancelled. The deadline covers
            the **whole** invocation — authorization and the data-classification check as well as the
            tool itself.

  # ---------------------------------------------------------------------------
  # Workflows — submission and runs
  # ---------------------------------------------------------------------------
  /api/workflows:
    post:
      tags: [Workflows]
      operationId: submitWorkflow
      summary: Submit a workflow definition and receive its identifier
      description: |
        Admits a DAG of steps, maps it to an executable plan, and stores it under **your** scope.
        Submitting stores a workflow and nothing more — running it is a separate call that spends
        the host's model and tool credentials.

        **Every identifier is minted server-side.** There is no owner field on the wire and none is
        accepted from the body; ownership comes from your token. Step names you supply are yours to
        choose and are returned mapped to the identifiers the harness assigned.

        The definition is validated structurally before it is stored — cycles, unreachable steps, and
        incomplete conditional branches are rejected here rather than at first run.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/WorkflowDefinition" }
            examples:
              twoStep:
                summary: A two-step sequential workflow
                value:
                  name: draft-and-review
                  steps:
                    - name: draft
                      type: LlmCall
                      configuration:
                        type: LlmCall
                        prompt: Draft a summary of the attached notes.
                    - name: review
                      type: LlmCall
                      configuration:
                        type: LlmCall
                        prompt: Review the draft for factual errors.
                  edges:
                    - from: draft
                      to: review
                      type: ControlFlow
      responses:
        "201":
          description: The workflow was admitted and stored.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/SubmitWorkflowResponse" }
        "400": { $ref: "#/components/responses/ValidationProblem" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403": { $ref: "#/components/responses/DisabledProblem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/workflows/{workflowId}/runs:
    post:
      tags: [Workflows]
      operationId: startWorkflowRun
      summary: Queue a run of a stored workflow
      description: |
        Accepts the run and returns immediately with a job id — execution is asynchronous. Poll the
        returned `statusUrl`, or open the stream endpoint for live progress.

        **Only one live run per workflow.** A second run while one is queued, running, or parked on
        a human gate is refused with `409`. Two runs of one workflow would share a single plan state
        machine, so the second would re-execute live steps and adopt the first's outputs.

        The capability envelope is resolved from **the credential that starts the run**, and the run
        executes under it regardless of what the workflow's steps request.
      parameters:
        - $ref: "#/components/parameters/WorkflowId"
      responses:
        "202":
          description: The run was accepted and queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartWorkflowRunResponse" }
        "400": { $ref: "#/components/responses/ValidationProblem" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403": { $ref: "#/components/responses/DisabledProblem" }
        "404":
          description: No such workflow, **or** it belongs to another caller.
        "409":
          description: >-
            This workflow already has a live run, or the caller holds the maximum concurrent runs
            allowed per owner.
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/workflows/{workflowId}/runs/{jobId}:
    get:
      tags: [Workflows]
      operationId: getWorkflowRun
      summary: Poll a run's state
      parameters:
        - $ref: "#/components/parameters/WorkflowId"
        - $ref: "#/components/parameters/RunJobId"
      responses:
        "200":
          description: The run's current state.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WorkflowRunResponse" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403": { $ref: "#/components/responses/DisabledProblem" }
        "404":
          description: No such run, **or** it belongs to another caller.
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Workflows]
      operationId: cancelWorkflowRun
      summary: Cancel a run, and any approval it is waiting on
      description: |
        A run that has not started yet is cancelled outright. A run already executing is **signalled**
        to stop — the response reports `stopped: false` and you should poll the status endpoint to
        confirm. A run that has already finished answers `409`.

        If the run is parked on a human gate, its outstanding approval requests are withdrawn too,
        and the count is reported. The order matters and is a correctness property of the host: the
        run is stopped *before* its approvals are withdrawn, because withdrawing first would resolve
        the gate while the run was still parked on it — which is exactly the condition that would
        wake the run back up.
      parameters:
        - $ref: "#/components/parameters/WorkflowId"
        - $ref: "#/components/parameters/RunJobId"
      responses:
        "200":
          description: The run was cancelled or signalled to stop.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CancelWorkflowRunResponse" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403": { $ref: "#/components/responses/DisabledProblem" }
        "404":
          description: No such run, **or** it belongs to another caller.
        "409":
          description: The run has already reached a terminal state.
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/workflows/{workflowId}/runs/{jobId}/stream:
    get:
      tags: [Workflows]
      operationId: streamWorkflowRun
      summary: Stream a run's progress as server-sent events
      description: |
        Opens a `text/event-stream`. The first frame is always a `SNAPSHOT` of the run's state at the
        moment you connected, so you never have to reconcile "did I miss anything before I attached".

        The stream is bounded by the run, not by your patience: it closes when the run reaches a
        terminal state (`FINISHED`) or parks on a human gate (`PARKED`). A `PARKED` frame releases
        your connection without claiming the run ended — the run keeps its job id, and you re-attach
        after answering the gate.

        Frames are buffered per watcher and the oldest are dropped under pressure; a `GAP` frame
        reports how many were lost rather than letting you believe you saw everything.

        **Authorization is checked once, when the stream opens.** A token revoked mid-run keeps
        receiving until the run ends. This is bounded by run duration, and frames carry nothing you
        could not already read from the run's status endpoint.
      parameters:
        - $ref: "#/components/parameters/WorkflowId"
        - $ref: "#/components/parameters/RunJobId"
      responses:
        "200":
          description: The event stream.
          content:
            text/event-stream:
              schema: { $ref: "#/components/schemas/WorkflowProgressEvent" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403": { $ref: "#/components/responses/DisabledProblem" }
        "404":
          description: No such run, **or** it belongs to another caller.
        "503":
          description: >-
            The host or this caller is already at its concurrent-stream ceiling
            (`MaxConcurrentProgressStreams` / `MaxProgressStreamsPerOwner`). Close a stream and retry.

  /api/evals/datasets:
    get:
      tags: [Evals]
      operationId: listEvalDatasets
      summary: List the dataset names this host will evaluate
      description: |
        The datasets are whatever an operator placed at the **top level** of
        `AppConfig:AI:Evaluation:DatasetRoots`; a name is that file's name without its extension.

        **A host with no roots configured answers an empty list.** It does not fall back to
        enumerating its working directory - listing is a disclosure, and there is nothing bounded to
        disclose until an operator says what the bounds are. An empty list is therefore a truthful
        answer to "what could be run here", not an error.

        Not recursive. A dataset in a subdirectory would need a name that carried structure, and a
        name with structure is a path with extra steps - which is exactly what this surface avoids.
      responses:
        "200":
          description: The dataset names, in a stable order.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EvalDatasetsResponse" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/evals/runs:
    post:
      tags: [Evals]
      operationId: startEvalRun
      summary: Queue an evaluation run over named datasets
      description: |
        Accepts the run and returns immediately with a job id - execution is asynchronous. A suite is
        hundreds of governed agent turns at the default ceilings, so the response says the work was
        taken, not that it finished.

        **Datasets are named, never pathed.** There is no path field on this contract, and that is
        the security property rather than a convenience: a path would make every request a filesystem
        reference with one guard between it and an arbitrary read. A name cannot express "outside the
        roots" at all. The server resolves a name by enumerating the configured roots and matching -
        never by concatenating the name onto a root.

        The capability envelope is resolved from **the credential that starts the run** and is armed
        around the whole evaluation. Every case is a governed agent turn that can invoke tools, so
        without it a caller could reach, through an eval case, tools it is denied directly.

        Unknown dataset names are refused **before** anything is queued, so a bad name costs an
        immediate `404` rather than a `202` followed by a failure the caller has to poll for.

        Cost is bounded at admission, not interrupted afterwards: `MaxCaseExecutionsPerRun` counts
        cases **x repeats** and refuses rather than truncating, because quietly evaluating a subset
        would report a pass rate for a suite that never ran.

        On completion the report is filed into the host's durable eval store in-process, so the
        dashboard sees it without the caller posting anything back.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/StartEvalRunRequest" }
      responses:
        "202":
          description: The run was accepted and queued.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/StartEvalRunResponse" }
        "400":
          description: >-
            The request breached a configured ceiling (`MaxDatasetsPerRun`, `MaxRepeats`,
            `MaxParallelism`), or the caller already holds the maximum concurrent runs allowed per
            owner.
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403":
          description: >-
            The caller lacks the `Harness.Evals.Execute` role, or evaluation is disabled on this host
            (`AppConfig:AI:Evaluation:Enabled`).
        "404":
          description: >-
            One of the named datasets is not one this host serves. Unknown and malformed names are
            deliberately indistinguishable.
        "429": { $ref: "#/components/responses/RateLimited" }

  /api/evals/runs/{jobId}:
    get:
      tags: [Evals]
      operationId: getEvalRun
      summary: Poll an evaluation run, and read its report once finished
      description: |
        Returns counts, verdict, duration and cost - **not** per-case results. Those hold every
        case's input and the agent's full output, which is not something a status poll should carry;
        use the framework's own reporters for transcripts.

        A run whose submission has already been reclaimed is still readable: the record and the
        submission are dropped by the same sweep but not atomically, and a caller polling in that
        window is owed the run's status rather than a failure. `datasets` is empty and `report` is
        absent in that case.
      parameters:
        - $ref: "#/components/parameters/EvalRunJobId"
      responses:
        "200":
          description: The run's current state, and its report if it has produced one.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/EvalRunResponse" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403":
          description: Evaluation is disabled on this host.
        "404":
          description: >-
            No such run, **or** it belongs to another caller, **or** it is not an evaluation run.
            The three are one answer so a caller cannot enumerate work it was not given the id for.
        "429": { $ref: "#/components/responses/RateLimited" }
    delete:
      tags: [Evals]
      operationId: cancelEvalRun
      summary: Cancel a queued evaluation run
      description: |
        A run that has not started is cancelled outright (`stopped: true`). A run **already
        executing** answers `200` with `stopped: false` and continues to completion.

        That is weaker than cancelling a workflow, and the difference is real rather than an
        omission: a workflow in flight can be signalled through the plan cancellation registry,
        whereas an evaluation is a suite of agent turns with no equivalent. Reporting `stopped: true`
        would tell a caller the spend had stopped when it had not. What bounds a runaway suite is
        `MaxCaseExecutionsPerRun`, applied before any case runs.
      parameters:
        - $ref: "#/components/parameters/EvalRunJobId"
      responses:
        "200":
          description: The run was cancelled, or reported as already executing.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/CancelEvalRunResponse" }
        "401": { $ref: "#/components/responses/UnauthorizedProblem" }
        "403":
          description: Evaluation is disabled on this host.
        "404":
          description: No such run, **or** it belongs to another caller, **or** it is not an evaluation run.
        "409":
          description: The run has already reached a terminal state.
        "429": { $ref: "#/components/responses/RateLimited" }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        A Microsoft Entra ID access token for **this API's own audience** — `api://{ClientId}`,
        where `ClientId` is `AppConfig:AI:BundleExecution:Auth:ClientId`. The bundle API is
        deliberately isolated behind its own audience and never shares a credential surface with the
        MCP server or the agent hub.

        Validation is strict: issuer, audience, lifetime, and signing key are all checked, and
        `ClockSkew` is zero (no grace window) per the repository security baseline.

        The host is **fail-closed**: it refuses to start unless a scheme is configured (both
        `TenantId` and `ClientId`) or a developer explicitly sets `Auth:AllowAnonymous: true`.
        Running under `Environment=Development` does not by itself disable authentication.

        In the anonymous development mode every request authenticates as one synthetic principal, so
        **all callers share a single owner identity and there is no cross-caller isolation** — and
        because that principal carries no subject claim, every anonymous run resolves to the
        fail-closed `Default` envelope.

  parameters:
    Handle:
      name: handle
      in: path
      required: true
      description: The opaque handle returned by `registerBundle`.
      schema: { type: string }
    JobId:
      name: jobId
      in: path
      required: true
      description: The opaque run job id returned by `startBundleRun`.
      schema: { type: string }
    WorkflowId:
      name: workflowId
      in: path
      required: true
      description: The server-minted workflow identifier returned by `submitWorkflow`.
      schema: { type: string, format: uuid }
    RunJobId:
      name: jobId
      in: path
      required: true
      description: The run job id returned by `startWorkflowRun`.
      schema: { type: string }
    EvalRunJobId:
      name: jobId
      in: path
      required: true
      description: The run job id returned by `startEvalRun`.
      schema: { type: string }
    ToolName:
      name: name
      in: path
      required: true
      description: >-
        The tool name, matched case-insensitively. This is the same string used in a workflow
        `ToolUse` step and in an envelope's `AllowedTools`.
      schema: { type: string }
      example: file_system

  responses:
    ValidationProblem:
      description: >-
        The request was rejected. For `registerBundle` this includes every archive-guard rejection
        (too large, too many entries, expands too far, compression ratio, zip-slip, escaping symlink,
        not a zip, empty, no `AGENT.md`, unresolvable agent id) — the `detail` names the guard, never
        archive content.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetails" }
          examples:
            noAgentManifest:
              value:
                title: Validation failed
                status: 400
                detail: Bundle has no AGENT.md at its root.
            tooLarge:
              value:
                title: Validation failed
                status: 400
                detail: Bundle archive exceeds the maximum accepted size of 10485760 bytes.
            noMessages:
              value:
                title: Validation failed
                status: 400
                detail: UserMessages must contain at least one message.
    UnauthorizedProblem:
      description: >-
        No valid bearer token, or an authenticated principal carrying no usable identity to own
        resources under (no `oid`, `sub`, name-identifier, or name claim).
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetails" }
          example:
            title: Unauthorized
            status: 401
            detail: The authenticated principal carries no usable identity.
    DisabledProblem:
      description: Bundle execution is disabled on this host.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetails" }
          example:
            title: Forbidden
            status: 403
            detail: Bundle execution is disabled. Set AppConfig.AI.BundleExecution.Enabled = true to enable it.
    NotFoundProblem:
      description: >-
        The handle or run does not exist, has expired, or belongs to a different caller — the three
        cases are deliberately indistinguishable.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetails" }
          examples:
            handle:
              value:
                title: Not found
                status: 404
                detail: Bundle handle not found or expired. Register the bundle again to obtain a fresh handle.
            run:
              value:
                title: Not found
                status: 404
                detail: Bundle run not found. It may never have existed, expired, or belongs to a different caller.
    RateLimited:
      description: >-
        The caller's rate limit was exceeded. Limits are partitioned per caller (by stable id, or by
        remote IP when the principal has none): 10/minute for register, 60/minute for run, poll, and
        delete. Each endpoint gets exactly one policy — a per-endpoint policy replaces the
        controller-wide one rather than stacking with it — so the stream endpoint is bounded only by
        its open-connection cap, not by a request rate.
    ServerProblem:
      description: >-
        An unexpected server error. The body is intentionally generic — the detail is in the host's
        logs, never on the wire.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/ProblemDetails" }
          example:
            title: Bundle operation failed
            status: 500
            detail: An error occurred processing the request. See server logs for details.

  schemas:
    RegisterBundleResponse:
      type: object
      required: [handle, expiresAt]
      properties:
        handle:
          type: string
          description: Opaque handle for the staged bundle. Use it to start runs and to delete it.
        expiresAt:
          type: string
          format: date-time
          description: >-
            The **earliest** instant the handle expires. The TTL is sliding, so using the handle
            pushes this out; treat it as a floor, not a deadline.
      example:
        handle: "h-9f3c7d1e84b24c0f9a6e2d5b8c1f4a37"
        expiresAt: "2026-07-24T14:33:11.412Z"

    RunBundleRequest:
      type: object
      required: [userMessages]
      properties:
        userMessages:
          type: array
          description: >-
            The user messages this run contributes — one turn per message. At least one, at most 100,
            none empty. Without `conversationId` these are the whole conversation; with it, they are
            appended to what the conversation already holds.
          minItems: 1
          maxItems: 100
          items:
            type: string
            minLength: 1
        maxTurns:
          type: integer
          description: >-
            Maximum number of turns **this run** may take. It does not bound the conversation: with
            `conversationId` set the conversation outlives any one run, and its total length is bounded
            by the conversation-lifetime token budget instead.
          default: 10
          minimum: 1
          maximum: 100
        conversationId:
          type: string
          description: >-
            Continues a durable conversation instead of running one-shot. The agent is given the
            conversation's recent history before the first turn, and this run's turns are saved — so a
            caller driving a multi-turn session sends only what is new rather than replaying the whole
            transcript every time, which is what the 100-message cap would otherwise force it to do.


            The id is yours to choose and opaque to the API; a GUID is the obvious pick. It is created
            on first use and owned by the caller that first used it. Naming a conversation owned by
            someone else returns `404`, identically to an unknown handle, so the endpoint cannot be
            used to discover other people's conversation ids.


            Turns on one conversation are serialised: a second run naming a conversation that is
            mid-turn waits for it rather than interleaving.
          pattern: "^[A-Za-z0-9_-]+$"
          maxLength: 200
          example: "voice-session-8f14e45f"
        stream:
          type: boolean
          description: >-
            When true the run is reserved for a live stream instead of background dispatch, and does
            not execute until the caller opens the returned `streamUrl`.
          default: false

    StartRunResponse:
      type: object
      required: [jobId, statusUrl]
      properties:
        jobId:
          type: string
          description: Opaque id of the run job.
        statusUrl:
          type: string
          description: Relative URL to poll for status and result.
        streamUrl:
          type: [string, "null"]
          description: >-
            Relative URL of the live SSE feed. Present only when the run was started with
            `stream: true`; opening it is what drives the run.
      example:
        jobId: "0b8f1c2d3e4f5a6b7c8d9e0f1a2b3c4d"
        statusUrl: "/api/bundles/h-9f3c7d1e84b24c0f9a6e2d5b8c1f4a37/runs/0b8f1c2d3e4f5a6b7c8d9e0f1a2b3c4d"
        streamUrl: null

    BundleRunResponse:
      type: object
      required: [jobId, status, createdAt]
      properties:
        jobId: { type: string }
        status:
          $ref: "#/components/schemas/BundleRunStatus"
        error:
          type: [string, "null"]
          description: >-
            A stable, caller-safe reason when the run failed outright; null otherwise. Never a raw
            exception message. Known values include
            `The bundle handle expired before the run started.`, `The run was cancelled.`, and
            `bundle_run.unhandled_exception`.
        createdAt: { type: string, format: date-time }
        startedAt:
          type: [string, "null"]
          format: date-time
          description: When execution began; null while still `Queued`.
        completedAt:
          type: [string, "null"]
          format: date-time
          description: When the run reached a terminal state; null before then.
        result:
          oneOf:
            - $ref: "#/components/schemas/BundleRunOutcomeResponse"
            - type: "null"
          description: The outcome once the run has `Succeeded`; null before then.

    BundleRunStatus:
      type: string
      description: |
        The run's lifecycle state. Serialized as a name, not an ordinal.

        * `Queued` — created, not yet picked up (or, for a streaming run, awaiting its stream).
        * `Running` — executing under its capability envelope.
        * `Succeeded` — terminal; the run completed. See `result.conversationSucceeded` for the
          conversation's own outcome.
        * `Failed` — terminal; see `error`.
      enum: [Queued, Running, Succeeded, Failed]

    BundleRunOutcomeResponse:
      type: object
      required: [conversationSucceeded, finalResponse, turnCount, totalToolInvocations]
      properties:
        conversationSucceeded:
          type: boolean
          description: >-
            Whether the conversation itself reported success. Distinct from the run completing —
            see `BundleRunStatus`.
        finalResponse:
          type: string
          description: The final agent response, or empty when no turn produced one.
        turnCount:
          type: integer
        totalToolInvocations:
          type: integer
        budgetExhausted:
          type: boolean
          description: >-
            Whether the conversation stopped early on its lifetime token budget. A graceful stop,
            not a failure — the turns that ran are still reflected in `turnCount`.
          default: false
        conversationError:
          type: [string, "null"]
          description: The conversation-level error when `conversationSucceeded` is false.

    ProblemDetails:
      type: object
      description: RFC 7807 problem document, as produced by ASP.NET Core `ControllerBase.Problem(...)`.
      properties:
        type: { type: string }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }

    # ---- Server-Sent Events payloads (documented for client authors; not returned as JSON bodies) ----
    StreamEvent:
      description: >-
        The JSON payload carried by one `data:` frame on the stream endpoint. `type` is the
        discriminator and matches the AG-UI wire vocabulary.
      oneOf:
        - $ref: "#/components/schemas/RunStartedEvent"
        - $ref: "#/components/schemas/TextMessageStartEvent"
        - $ref: "#/components/schemas/TextMessageContentEvent"
        - $ref: "#/components/schemas/TextMessageEndEvent"
        - $ref: "#/components/schemas/ToolCallStartEvent"
        - $ref: "#/components/schemas/ToolCallArgsEvent"
        - $ref: "#/components/schemas/ToolCallEndEvent"
        - $ref: "#/components/schemas/ToolCallResultEvent"
        - $ref: "#/components/schemas/RunFinishedEvent"
        - $ref: "#/components/schemas/RunErrorEvent"
      discriminator:
        propertyName: type

    RunStartedEvent:
      type: object
      description: Emitted once at the start of a streamed run, before any text.
      required: [type, threadId, runId]
      properties:
        type: { type: string, const: RUN_STARTED }
        threadId:
          type: string
          description: The bundle handle, stable across the run.
        runId:
          type: string
          description: The job id, echoed in the terminal frame.

    TextMessageStartEvent:
      type: object
      description: >-
        Opens the assistant message. Emitted lazily on the first real delta, so a run that produces
        no text emits no message frames at all.
      required: [type, messageId, role]
      properties:
        type: { type: string, const: TEXT_MESSAGE_START }
        messageId: { type: string }
        role: { type: string, const: assistant }

    TextMessageContentEvent:
      type: object
      description: A text delta to append to the in-progress assistant message.
      required: [type, messageId, delta]
      properties:
        type: { type: string, const: TEXT_MESSAGE_CONTENT }
        messageId: { type: string }
        delta: { type: string }

    TextMessageEndEvent:
      type: object
      description: Closes the assistant message.
      required: [type, messageId]
      properties:
        type: { type: string, const: TEXT_MESSAGE_END }
        messageId: { type: string }

    ToolCallStartEvent:
      type: object
      description: Emitted when the agent decides to call a tool.
      required: [type, toolCallId, toolCallName]
      properties:
        type: { type: string, const: TOOL_CALL_START }
        toolCallId:
          type: string
          description: The provider-assigned id for this call, shared by every event for it.
        toolCallName: { type: string }

    ToolCallArgsEvent:
      type: object
      description: >-
        Carries a tool call's arguments. Unlike `TextMessageContentEvent`, `delta` is NOT an
        incremental delta — the underlying model-provider integration only ever exposes a tool
        call's arguments once fully assembled, so this frame always carries the complete JSON
        payload in one shot, immediately followed by `TOOL_CALL_END`. The field is still named
        `delta` to match the AG-UI wire vocabulary exactly. Above a server-side size ceiling the
        real arguments are withheld rather than truncated — `delta` is the fixed placeholder `"{}"`
        and `withheld` is `true` — since cutting a JSON payload mid-token would hand the client
        invalid data.
      required: [type, toolCallId, delta]
      properties:
        type: { type: string, const: TOOL_CALL_ARGS }
        toolCallId: { type: string }
        delta:
          type: string
          description: The tool call's complete arguments, serialized as JSON, or `"{}"` if withheld.
        withheld:
          type: boolean
          description: >-
            Present and `true` only when the real arguments exceeded the streaming size ceiling and
            were withheld. Absent on every normal frame.

    ToolCallEndEvent:
      type: object
      description: Signals that a tool call's arguments are complete.
      required: [type, toolCallId]
      properties:
        type: { type: string, const: TOOL_CALL_END }
        toolCallId: { type: string }

    ToolCallResultEvent:
      type: object
      description: Emitted once a tool call has actually run and produced a result.
      required: [type, toolCallId, result]
      properties:
        type: { type: string, const: TOOL_CALL_RESULT }
        toolCallId: { type: string }
        result:
          type: string
          description: >-
            A redacted, truncated preview of the tool's output — never the raw payload. Secret-shaped
            values are replaced with "[REDACTED]" before truncation. If the tool call failed, this is a
            generic failure message, not the underlying exception text.

    RunFinishedEvent:
      type: object
      description: Terminal frame on success. No further frames follow.
      required: [type, threadId, runId]
      properties:
        type: { type: string, const: RUN_FINISHED }
        threadId: { type: string }
        runId: { type: string }

    RunErrorEvent:
      type: object
      description: >-
        Terminal frame on failure; no `RUN_FINISHED` follows. The message is always caller-safe —
        never a raw exception.
      required: [type, message]
      properties:
        type: { type: string, const: RUN_ERROR }
        message:
          type: string
          examples:
            - The run could not be found. Start it again to obtain a new job id.
            - The run is already being streamed or has already completed.
            - The bundle handle expired before the run could start.
            - The agent run did not complete successfully.

    # -------------------------------------------------------------------------
    # Tools
    # -------------------------------------------------------------------------
    ToolCatalogResponse:
      type: object
      description: >-
        The tools the calling credential may invoke. Not the host's inventory — see the endpoint
        description.
      required: [tools]
      properties:
        tools:
          type: array
          description: Granted tools, ordered by name. Empty is a valid, successful answer.
          items: { $ref: "#/components/schemas/ToolCatalogEntry" }

    ToolCatalogEntry:
      type: object
      required:
        [name, description, operations, riskTier, isReadOnly, isConcurrencySafe,
         isDirectlyInvocable]
      properties:
        name:
          type: string
          description: >-
            The tool's name and the identifier used to invoke it. This is the string a workflow
            `ToolUse` step must supply.
          examples: [file_system, document_search]
        description:
          type: string
          description: What the tool does. This is the same text the model is shown.
        operations:
          type: array
          description: >-
            The operations this tool accepts. An invocation naming anything else is rejected by the
            tool itself.
          items: { type: string }
        riskTier:
          $ref: "#/components/schemas/BlastRadius"
        isReadOnly:
          type: boolean
          description: >-
            Whether the tool only reads state. Fail-closed — `false` unless the tool declares
            otherwise, so an unclassified tool is never treated as safer than it is.
        isConcurrencySafe:
          type: boolean
          description: Whether the tool is safe to invoke alongside other calls. Fail-closed, as above.
        isDirectlyInvocable:
          type: boolean
          description: >-
            Whether `POST /api/tools/{name}/invoke` will run this tool. **`false` does not mean you
            lack permission** - it means the tool's result is not meaningful outside the process.
            The `render_*` family and `dashboard_control` return directives for a browser attached to
            a live agent run, and `delegate_task` expands one call into an open-ended sequence of
            agent turns. Such a tool is still listed here because it remains fully usable from a
            workflow `ToolUse` step; invoking it directly answers `404`.

    ToolInvocationRequest:
      type: object
      description: >-
        One shape covers every tool: `ITool` exposes a single invocation signature, so there is no
        per-tool request type and no contract that changes when the host registers a new tool.
      required: [operation]
      properties:
        operation:
          type: string
          description: >-
            The operation to perform. Must be one the tool declares in its catalog entry; anything
            else answers `400` with the accepted list.
          examples: [read, search, list]
        parameters:
          type: object
          additionalProperties: true
          description: >-
            The operation's arguments. Omit for operations that take none. Values are converted to
            CLR types by the same code the agent path uses, so a tool sees identical arguments
            however it was reached; nested objects and arrays reach the tool as their raw JSON text,
            because the tool parameter contract is flat.
        timeoutSeconds:
          type: integer
          minimum: 1
          description: >-
            An optional **shorter** deadline. Omit for the host's ceiling (30 s by default). A value
            above the ceiling is **refused with `400`, not clamped** - a caller quietly given less
            time than they asked for would see a timeout with nothing in the response to explain it.

    ToolInvocationResponse:
      type: object
      required: [tool, operation, succeeded, outputTruncated, durationMs]
      properties:
        tool:
          type: string
          description: The tool this response came from.
        operation:
          type: string
          description: The operation that was run.
        succeeded:
          type: boolean
          description: >-
            Whether the tool reported success. `false` here is a *completed* invocation of a tool
            that said no - distinct from every 4xx/5xx status, all of which mean it never ran.
        output:
          type: string
          nullable: true
          description: The tool's output, sanitized. Null when the tool reported failure.
        error:
          type: string
          nullable: true
          description: >-
            The tool's failure message, sanitized on the same path as the output. Null on success.
            Never raw exception text.
        outputTruncated:
          type: boolean
          description: >-
            Whether `output` was cut short at the host's ceiling. **Check this.** A caller that
            cannot tell a complete result from a prefix of one will parse the prefix as complete.
        durationMs:
          type: integer
          format: int64
          description: How long the invocation took, in milliseconds.

    BlastRadius:
      type: string
      description: >-
        How much damage one call can do. Sent as a **name**, not an ordinal — an ordinal would
        silently change meaning if a value were ever inserted into the enum. Treat this as a ceiling
        on what the host will auto-approve, not a promise that any given call succeeds.
      enum: [Trivial, Low, Medium, High, Critical]

    # -------------------------------------------------------------------------
    # Workflows
    # -------------------------------------------------------------------------
    WorkflowDefinition:
      type: object
      description: |
        A DAG of steps. Deliberately narrower than the harness's internal plan model: isolation
        level, retrieval collection, and inline sub-plan definitions are **not** on the wire, because
        each would let a caller reach past the confinement its envelope establishes.
      required: [name, steps, edges]
      properties:
        name:
          type: string
          description: Your name for the workflow. Not an identifier — the id is minted server-side.
        steps:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/WorkflowStep" }
        edges:
          type: array
          description: >-
            Dependencies between steps, referencing steps by the `name` you gave them. Must form a
            DAG; cycles are rejected at submission.
          items: { $ref: "#/components/schemas/WorkflowEdge" }
        configuration:
          $ref: "#/components/schemas/WorkflowExecutionSettings"

    WorkflowStep:
      type: object
      required: [name, type, configuration]
      properties:
        name:
          type: string
          description: Your name for the step, unique within the workflow. Edges reference it.
        type:
          $ref: "#/components/schemas/StepType"
        configuration:
          type: object
          description: >-
            Step-type-specific settings, discriminated by its own `type` property which must match
            the step's `type`. A `ToolUse` configuration names a tool and an operation — use
            `GET /api/tools` to find valid names for your credential.
          additionalProperties: true
        retry:
          type: object
          description: Retry policy for this step. Omitted means the host default applies.
          additionalProperties: true
        timeout:
          type: string
          description: Per-step timeout as an ISO-8601 duration.
        requiredAutonomyLevel:
          $ref: "#/components/schemas/AutonomyLevel"

    WorkflowEdge:
      type: object
      required: [from, to, type]
      properties:
        from: { type: string, description: Source step name. }
        to: { type: string, description: Target step name. }
        type: { $ref: "#/components/schemas/EdgeType" }
        condition:
          type: string
          description: >-
            Only meaningful on `ConditionalTrue` / `ConditionalFalse` edges. Evaluated against a
            restricted expression grammar shared by admission and execution, so an expression
            accepted here is the same one that will run.

    WorkflowExecutionSettings:
      type: object
      description: Optional execution settings. Host caps apply regardless of what you request.
      properties:
        planTimeout:
          type: string
          description: Whole-workflow timeout as an ISO-8601 duration.
        maxParallelSteps:
          type: integer
          minimum: 1
          description: Ceiling on concurrently executing steps within this workflow.

    StepType:
      type: string
      description: >-
        `LlmCall` routes through the full governed agent turn. `ToolUse` and `Retrieval` execute
        under your capability envelope. `HumanGate` parks the run until someone approves it.
      enum: [LlmCall, ToolUse, HumanGate, ConditionalBranch, SubPlanInvocation, Retrieval]

    EdgeType:
      type: string
      enum: [DataFlow, ControlFlow, ConditionalTrue, ConditionalFalse]

    AutonomyLevel:
      type: string
      description: >-
        Requested autonomy for a step. Enforced as a **ceiling** against your envelope's
        `AutonomyCeiling` and the host's own gates — it can only tighten, never loosen.
      enum: [Restricted, Supervised, Autonomous]

    SubmitWorkflowResponse:
      type: object
      required: [workflowId, name]
      properties:
        workflowId:
          type: string
          format: uuid
          description: The server-minted identifier. Use it to start runs.
        name:
          type: string
          description: The name you supplied, echoed back.

    StartWorkflowRunResponse:
      type: object
      required: [jobId, statusUrl]
      properties:
        jobId: { type: string, description: Server-minted identifier of the queued run. }
        statusUrl: { type: string, description: Where to poll for this run's state. }

    EvalDatasetsResponse:
      type: object
      required: [datasets]
      properties:
        datasets:
          type: array
          items: { type: string }
          description: Dataset names this host serves. Empty when no roots are configured.
          example: ["router-accuracy", "safety-refusals"]

    StartEvalRunRequest:
      type: object
      description: >-
        Deliberately narrower than the in-process `EvalRunOptions`. `InvocationOverrides` (a free
        dictionary flowing into model invocation) and `ForceDeterministic` (local replay) are not on
        the wire: omitting them means they cannot be sent, which is a stronger statement than
        validating them away.
      required: [datasets]
      properties:
        datasets:
          type: array
          items: { type: string }
          minItems: 1
          description: >-
            Names as `listEvalDatasets` reports them - never file paths. Bounded by
            `MaxDatasetsPerRun`.
          example: ["router-accuracy"]
        repeats:
          type: integer
          default: 1
          description: >-
            How many times each case is re-invoked, with median-across-repeats aggregation. Bounded
            by `MaxRepeats`. Multiplies cost linearly.
        parallelism:
          type: integer
          default: 1
          description: How many cases run at once. Bounded by `MaxParallelism`.
        tagFilter:
          type: array
          items: { type: string }
          description: When non-empty, only cases carrying one of these tags run.
        failRateThreshold:
          type: number
          format: double
          default: 0
          description: >-
            Fraction of failed cases tolerated before the run's overall verdict is Fail. `0` is
            strict - any failure fails the run.

    StartEvalRunResponse:
      type: object
      required: [jobId, statusUrl]
      properties:
        jobId: { type: string, description: Server-minted identifier of the queued run. }
        statusUrl: { type: string, description: Where to poll for this run's state and report. }

    CancelEvalRunResponse:
      type: object
      required: [jobId, stopped]
      properties:
        jobId: { type: string }
        stopped:
          type: boolean
          description: >-
            `false` means the run was already executing and will run to completion - an evaluation in
            flight cannot be interrupted.

    EvalRunResponse:
      type: object
      description: >-
        A projection of the stored run, not the record itself. The record carries the resolved
        capability envelope and tenant - the host's authorization state, deliberately never echoed
        back.
      required: [jobId, status, datasets, createdAt]
      properties:
        jobId: { type: string }
        status:
          type: string
          enum: [Queued, Running, Blocked, Succeeded, Failed, Cancelled]
          description: >-
            `Succeeded` means the evaluation **ran**, not that it passed. A failing suite is a
            succeeded run whose report carries a `Fail` verdict.
        datasets:
          type: array
          items: { type: string }
          description: The datasets the run was asked to evaluate. Empty once the submission is reclaimed.
        error:
          type: string
          nullable: true
          description: Caller-safe failure reason. Never raw exception text.
        createdAt: { type: string, format: date-time }
        startedAt: { type: string, format: date-time, nullable: true }
        completedAt: { type: string, format: date-time, nullable: true }
        report:
          allOf: [{ $ref: "#/components/schemas/EvalRunSummaryResponse" }]
          nullable: true
          description: Absent until the run has produced one.

    EvalRunSummaryResponse:
      type: object
      description: >-
        Counts and a verdict, not the per-case results - those hold every case's input and the
        agent's full output.
      required: [runId, verdict, passed, failed, warned, errored, passRate, duration, totalCostUsd, warnings]
      properties:
        runId:
          type: string
          description: The framework's identifier for the evaluation, as it appears in written reports.
        verdict: { type: string, enum: [Pass, Warn, Fail] }
        passed: { type: integer }
        failed: { type: integer }
        warned: { type: integer }
        errored: { type: integer, description: Cases that could not be scored because execution errored. }
        passRate: { type: number, format: double, description: Passed as a fraction of scored cases. }
        duration: { type: string, description: ISO-8601 duration. }
        totalCostUsd:
          type: number
          description: >-
            Cumulative spend across every case, repeat, and metric. Included because it is the number
            a caller most needs after triggering spend on the host's credentials.
        warnings:
          type: array
          items: { type: string }
          description: Advisory notes such as cost caveats. Never a failure reason.

    WorkflowRunResponse:
      type: object
      description: >-
        A projection of the stored run, not the record itself. The record carries the resolved
        capability envelope and tenant — the host's authorization state, deliberately never echoed
        back.
      required: [jobId, workflowId, status, createdAt]
      properties:
        jobId: { type: string }
        workflowId: { type: string }
        status: { $ref: "#/components/schemas/RunStatus" }
        error:
          type: string
          nullable: true
          description: Caller-safe failure summary when the run failed. Never raw exception text.
        createdAt: { type: string, format: date-time }
        startedAt: { type: string, format: date-time, nullable: true }
        completedAt:
          type: string
          format: date-time
          nullable: true
          description: Set only on terminal states. A parked run has no completion time.

    RunStatus:
      type: string
      description: |
        Sent as a name. **`Blocked` is a live state, not a terminal one** — a run parked on a human
        gate keeps its job id and its hold on the workflow, and resumes on that same id once the gate
        is answered. Treat only `Succeeded`, `Failed`, and `Cancelled` as final.
      enum: [Queued, Running, Succeeded, Failed, Cancelled, Blocked]

    CancelWorkflowRunResponse:
      type: object
      required: [jobId, stopped, withdrawnApprovals]
      properties:
        jobId: { type: string }
        stopped:
          type: boolean
          description: >-
            Whether the run had actually stopped when this was answered. `false` means it has been
            signalled and will stop — poll the status endpoint to confirm. A caller told otherwise
            would immediately start a replacement run, which the one-live-run rule refuses.
        withdrawnApprovals:
          type: integer
          description: How many pending approval requests were withdrawn along with the run.

    WorkflowProgressEvent:
      oneOf:
        - $ref: "#/components/schemas/WorkflowSnapshotFrame"
        - $ref: "#/components/schemas/WorkflowStepFrame"
        - $ref: "#/components/schemas/WorkflowFinishedFrame"
        - $ref: "#/components/schemas/WorkflowParkedFrame"
        - $ref: "#/components/schemas/WorkflowGapFrame"
      discriminator:
        propertyName: type
        mapping:
          SNAPSHOT: "#/components/schemas/WorkflowSnapshotFrame"
          STEP: "#/components/schemas/WorkflowStepFrame"
          FINISHED: "#/components/schemas/WorkflowFinishedFrame"
          PARKED: "#/components/schemas/WorkflowParkedFrame"
          GAP: "#/components/schemas/WorkflowGapFrame"

    WorkflowSnapshotFrame:
      type: object
      description: Always the first frame — the run's state at the moment you attached.
      required: [type, jobId, workflowId, status, isTerminal]
      properties:
        type: { type: string, const: SNAPSHOT }
        jobId: { type: string }
        workflowId: { type: string }
        status: { $ref: "#/components/schemas/RunStatus" }
        isTerminal:
          type: boolean
          description: >-
            When true the run was already finished when you attached; no further frames follow.

    WorkflowStepFrame:
      type: object
      required: [type, sequence, occurredAt]
      properties:
        type: { type: string, const: STEP }
        sequence:
          type: integer
          format: int64
          description: Monotonic within a run. Use it to detect ordering against a reported GAP.
        occurredAt: { type: string, format: date-time }
        stepId: { type: string, nullable: true }
        stepName: { type: string, nullable: true }
        status: { type: string, nullable: true }

    WorkflowFinishedFrame:
      type: object
      description: Terminal frame. The stream closes after it.
      required: [type, sequence, occurredAt]
      properties:
        type: { type: string, const: FINISHED }
        sequence: { type: integer, format: int64 }
        occurredAt: { type: string, format: date-time }
        status: { $ref: "#/components/schemas/RunStatus" }
        detail: { type: string, nullable: true }

    WorkflowParkedFrame:
      type: object
      description: >-
        The run parked on a human gate. The stream closes, but **the run has not ended** — it keeps
        its job id and resumes on it once the gate is answered. Re-attach then.
      required: [type, sequence, occurredAt]
      properties:
        type: { type: string, const: PARKED }
        sequence: { type: integer, format: int64 }
        occurredAt: { type: string, format: date-time }
        detail: { type: string, nullable: true }

    WorkflowGapFrame:
      type: object
      description: >-
        Frames were dropped because this watcher fell behind. Reported rather than hidden, so a slow
        client never mistakes a truncated stream for a complete one.
      required: [type, droppedCount]
      properties:
        type: { type: string, const: GAP }
        droppedCount: { type: integer, format: int64 }
