# Harness API (/docs/build/harness-api)



A harness runs an agent's loop: it calls the model, runs tools, and reports what happened. The
Runtime keeps everything else: the session's record, its lease and ownership, the gates that
hold every credential, and the settle that ends a turn. The Harness API is the contract between
the two. Our harness (`@nylorun/harness`, harness id `nylorun`) is one implementation; you can
write another, in any language, from this page alone.

This page is the reference, section by section; [Bring your own harness](/docs/build/harness) is the guide.
[`examples/harness-minimal`](https://github.com/nylorun/agents/tree/main/examples/harness-minimal) is a complete harness
in TypeScript that depends only on `ws` and `@nylorun/core/harness-api`.

The Harness API is southbound: from the Runtime down to a loop. It is not a northbound API for
calling vendor agents, and it is not the Runtime API your app uses.

## 0 Where the contract lives [#0-where-the-contract-lives]

Every message, body and document below has a zod schema in `@nylorun/core/harness-api`, and the
Runtime validates with the same schemas. The package also ships them as one JSON Schema document,
`@nylorun/core/harness-api/schema.json`, with `$defs` for each schema by name and for each
request's `<method>.params` and `<method>.result`. This site serves it at
[`/schemas/harness-api/v5.json`](/schemas/harness-api/v5.json), its `$id`. A harness in Python or
Go validates against that file.

The package also exports the harness id rule:

| Export               | Value                                                                        |
| -------------------- | ---------------------------------------------------------------------------- |
| `HARNESS_ID_PATTERN` | `/^[a-z][a-z0-9-]{1,62}$/`                                                   |
| `isHarnessId(value)` | Whether `value` is a string that matches the pattern.                        |
| `DEFAULT_HARNESS`    | `"nylorun"`, the harness of a session whose Agent's manifest names no other. |

## 1 Transport [#1-transport]

* A WebSocket to core's harness listener (port 4200 by default), path `/nylorun/harness/v1`.
* Upgrade headers: `Authorization: Bearer <token>` and `Nylorun-Harness-Api: 1`. A wrong path
  is `404`, a missing or unknown token `401`, a missing header `426`, a closed Tenant `503`.
* One JSON text message per frame, at most 64 MiB (`HARNESS_MAX_PAYLOAD`). Frames
  (`FrameSchema`): `{t: "req", id, m, p}`, `{t: "res", id, ok: true, r}`,
  `{t: "res", id, ok: false, e: {code, message}}`, `{t: "msg", m, p}`, `{t: "abort", id}` and
  `{t: "ping"}`. Either end may send requests; ids are per sender.
* Core pings every 30 s and drops a harness it has not heard from for 90 s (the operator's
  [Host tuning](/docs/deploy/vm#host-tuning) sets both). Its runs then end as `connection.lost`,
  and the next advance offers them again.
* Error codes (`HARNESS_ERROR_CODES`): `run_not_held`, `turn_cancelled`, `ownership_lost`,
  `effect_drift`, `invalid`, `unavailable`, `internal`, `not_found` (an artifact that is not
  there for your run, section 4b) and `limit_exceeded` (what you sent passes a Runtime limit of
  `turn.start.limits`, or an artifact limit; the message names the limit and its value).

Which token:

* A custom harness runs as the host of a pod Agent and holds no harness token: it joins from its
  pod (section 9). An agent manifest names a custom harness only with a pod runtime and its
  `image`.
* Our harness uses `NYLORUN_HARNESS_TOKEN`, bound to `nylorun`. A container that connects with a
  token the operator made for a custom harness id (`nylorun-operate harness token put <id>`) takes
  none of that harness's runs: they go to the hosts of its Agents' pods.

## 2 Work: hello, lease, renew, release [#2-work-hello-lease-renew-release]

* `hello {api: 5, name, version, features}` is the first request.
  * `name` is the harness id: `^[a-z][a-z0-9-]{1,62}$`, and the one the credential is for.
  * `features.workspace` declares that it serves workspaces (section 10, optional). A harness
    declares no tools: each tool call names what runs it (section 4).
  * The answer is `{api: 5, tenantId, sandbox: {backend}, renewEveryMs}`.
  * Core refuses another `api` and a name that is not the credential's harness.
* `lease {}` waits until core offers a run, then answers `{run: RunGrant, input: TurnStart}`.
  * Core offers a session's runs only to connections of the harness the session pinned when it
    opened: its Agent's manifest's `harness.id`, else `nylorun`; a pod Agent's runs, only to its
    pod's host. `GET /v1/sessions/{id}` returns it as `harness`. A session never moves to another
    harness, since its state is that harness's.
  * `RunGrant` is `{runId, sessionId, turnId, epoch, token, tokenExpiresAt}`; `token` is the
    run token for the gates (section 5).
  * Keep one `lease` waiting for each run you can take.
* `lease.renew {runId}` every `renewEveryMs`. It answers `{ok: true, token?, tokenExpiresAt?}`
  (switch to a new token when one comes) or `{ok: false}` (the run is over: stop it).
* `lease.release {runId, reason}` gives a run back without an output. Reasons: `shutdown`,
  `ownership.lost`, `connection.lost`.

## 3 Input: TurnStart [#3-input-turnstart]

`TurnStart` (`TurnStartSchema`) carries:

* `type` and `engine`: `turn.start` or `approval.answer`; `agent` or `flow`. A custom harness
  gets agent turns only: flows run in our harness.
* `manifest`: the turn's loop manifest: the harness section of the agent manifest version the
  session runs, in agent manifest v7's shape, with the manifest's `name` as its `id` (or a
  variant a message named). It is flat: the agent's own `instructions`, `tools`, `skills` and
  `mcpServers`. A skill or a server that came from an Agent Plugin carries
  `plugin: {name, version?}`. It carries nothing of the Runtime's: what the model reads and which
  tools it is offered are yours to write, from the facts below. Our engine's helpers are in
  `@nylorun/harness`: `engineToolsFor` (all of it, in our order), `sandboxTools` and
  `sandboxInstructions`, `skillCatalogText`, `skillFolderNote` and `skillTools`, and
  `runSkillTool`.
  * Skills: each skill's `name`, `description` and `files` (path to `sha256:<hex>`). Read a
    file's bytes with `definition.file {runId, sha256, offset?, length?}`, at most
    `limits.chunkBytes` a range. Putting a skill's files in the sandbox is yours too: our engine
    mounts each skill version of the tree read-only at `/skills/<name>@<hash>/` (`skillFolderOf`)
    before each sandbox call (`mountSkills`, `sandboxSkillsOf` and `definitionFileChunks` in
    `@nylorun/harness`); the Runtime mounts nothing.
  * Outputs: core exports none. Our engine saves `/workspace/outputs` as the session's
    `outputs` folder at a completed turn's end (section 4b); a harness of your own saves one only
    if it writes it.
  * Artifacts: reading and saving them is yours to offer, over the artifact requests (section 4b).
* `definition` (optional): `{name, version}`, the agent manifest version the session runs, for
  your records.
* `loop` (optional): the harness section's `loop`, your own settings, which the Runtime passes on
  without reading. Ours reads none.
* `memory` (`TurnMemorySchema`, optional): present when the version the session runs sets
  `harness.memory`. `access` (`read_write` or `read_only`, the same in every turn),
  `instructions` (the agent's own, after yours), and `index`: one line per memory, newest first,
  `- <path>` with ` — <description>` from its frontmatter, within the Runtime limits
  `memoryIndexLines` and `memoryIndexBytes` (`truncated` when some were left out). Pin the index
  where the prompt prefix stays cacheable (ours: a user-role entry at the head of the session,
  fresh after a compaction), and offer a `memory` tool whose calls are `runtime` effects
  (section 4); offer only `view` for `read_only` and to agents used as tools. Ours words it with
  `memoryTool` and `memoryIndexBlock` (`@nylorun/harness/api`), checks each command's parameters
  before it sends a call (`checkMemoryCall`, `@nylorun/core/contracts`; core checks again), and
  takes a call named `memory.<command>` or `memory/<command>` as `memory` with that command. See
  [Agent memory](/docs/run/memory).
* `checkpoint` (`HarnessCheckpointSchema`): `sessionId`, `turnId`, `segment`, `manifestHash`,
  `input` and `state`.
  * `input` is the turn's message: a string, `{text, metadata?}`, `{content, metadata?}`,
    `{kind: "messages", messages}` for messages that waited in the session's queue (each one of
    the shapes before, in order; write each as its own `input` transcript entry), or
    `{kind: "continue"}` for the next segment of a yielded turn.
  * `state` is yours, as your last output left it (section 7).
* `outcomes`: the completed outcomes of this segment, with their request hashes. Replay them
  without asking core again.
* `transcript: {cursor}`: read the transcript with `transcript.read {runId}`, which answers
  `{cursor, entries}`. Entries are `TranscriptEntrySchema` (section 6).
* `mcp` (`McpFactsSchema`, optional): the session's MCP discovery as this turn sees it, once a
  run recorded it. It is facts, not tools: naming them for the model, and deciding which to
  defer, is yours.
  * `servers`: each server of the agent tree that connected, `{agentId?, name, instructions?,
    tools}`. `agentId` names the agent used as a tool that declares it (absent for the root);
    `instructions` are the server's own, scrubbed and uncut.
  * Each tool is as the server listed it: `name` (its raw name on the server, which you call it
    by), `description?`, `inputSchema`, `outputSchema?` and `annotations?`, plus `enabled` (false
    when the turn's manifest disables it: skip it, since core refuses its calls as
    `tool.disabled`) and `deferred?` (the manifest's setting, a hint; absent, you decide).
  * Name each tool within 64 characters, the ceiling every provider accepts. Our engine's names,
    `server__tool` with a hash suffix when needed, are `mcpToolName` and `mcpToolNames` in
    `@nylorun/harness`, and `mcpToolSet` builds our whole MCP tool list.
  * Absent until the session's MCP servers are discovered: connect them through the Tool Gate
    (section 5) and record what they listed with `session.mcp {runId, snapshot?, diagnostics}`
    (`McpSnapshotSchema`, `snapshotSchemaVersion: 2`). The first snapshot recorded wins; the
    answer is `{mcp?}`, the facts this turn runs with.
* `routing`: where tool calls go.
  * `rootManifest`: the session's pinned manifest.
  * `sandbox`: the session's sandbox: `ownerId` and `environmentId` (the id of the Agent whose
    sandbox it is), which key its workspace, `spec`, the sandbox spec the session pinned when it
    opened (absent without a sandbox), and `environment`, the variables every command gets. Each
    `environment_secret` is set to the placeholder `nylorun_managed`, never its value.
* `agent` (`TurnAgentSchema`, optional): the Agent the session runs on, `{id, kind, sandbox?: {workspace}, selfWake?}`. `sandbox` is there when the session has a sandbox (kind `virtual` or
  `pod`), and `workspace` is the directory its commands start in (`/workspace`). Offer sandbox
  tools, and write a note on the sandbox, only then. `selfWake` is true when the version the
  session runs sets `runtime.selfWake`: offer `schedule_wake` (section 4). The Agent's runtime
  section never reaches you. It is absent for a flow.
* `options.fixtureModel`: informational. With the Tenant's fixture model on, the Model Gate
  answers model calls itself, so call it as usual.
* `trace`: optional W3C trace context (`{traceparent, tracestate?}`).
  * Send it as `traceparent` on your gate calls, and as `TRACEPARENT` to processes you start.
  * The Runtime never ingests a harness's spans; send them to your own collector.
* `limits` (`HarnessLimitsSchema`): the Tenant's [Runtime limits](/docs/run/limits) a harness
  must respect, as the segment started (a limit a harness does not know is dropped when it parses
  them). Keep within them, or core refuses what passes one with `limit_exceeded`:
  * `recordEntryBytes`: one entry of the record, as JSON: an `effect.outcome` value, a
    transcript update (section 6), a claimed event. Default 960 KiB, its ceiling.
  * `modelCallBytes`: a Model Gate call's body (default 32 MiB; past it, `400`).
  * `modelFileBytes`: one file the Model Gate sends a model, and the largest a harness should
    show it (default 8 MiB).
  * `toolAnswerBytes`: an MCP or HTTP tool's answer the Tool Gate keeps (default 8 MiB; past it,
    `mcp.too-large` or `http.too-large`), and the tool bodies it reads.
  * `harnessStateBytes`: your state (section 7), as JSON (default 256 KiB).
  * `chunkBytes`: one `definition.file` range, `artifact.read` answer or `artifact.write` chunk
    (section 4b): ask for and send at most this many bytes (default 16 MiB).
  * `folderEntries`: the most files one folder version you write may hold (section 4b; a count,
    not bytes; default 10,000).
* `model` (`ModelDescriptorSchema`, optional): the model the Model Gate uses for a call that
  names no `model.id`, as the segment started: `{provider, id, inputs, contextWindow,
  maxOutputTokens, reasoning?}`. `inputs` is what it reads (`text` always, and `image`, `pdf`,
  `office`, `audio`, `video` as its catalog and the operator say), and the window and output
  limit are in tokens. The fixture model is `provider: "fixture"` and reads every input. It is
  absent for a flow and when the Tenant has no model. Decide from it how each file of the turn
  reaches the model (section 5): the Runtime accepts any file a message names, within the
  artifact size limits.

## 4 Record: intent, outcome, hash, effect ids [#4-record-intent-outcome-hash-effect-ids]

Every effect is journaled before it runs.

* **`effect.intent {runId, effect, requestHash}`** (`EffectIntentSchema`). Core decides the
  Tenant's tool policy here, for MCP and HTTP tools: a denied call is answered `completed` with a
  `tool.denied` failure, and a call held for a person is answered `pending`. It answers:
  * `{status: "execute"}`: run it. For a tool call it may carry `traceparent`, the call's span
    context: pass it as `TRACEPARENT` to the command you run for the call. The Tool Gate sends it
    on to MCP and HTTP tools itself.
  * `{status: "execute", rejoin: true}`: a previous owner left this call running at a gate.
    Send it again with the same key and body to join it or collect its outcome.
  * `{status: "completed", outcome}`: it already ran. Use the outcome.
  * `{status: "pending" | "uncertain"}`: stop the segment with `turn.waiting`.
* **`effect.outcome {runId, effectId, value}`**, or `{..., error}` when you could not learn
  what happened. It answers `completed` with the recorded outcome. A tool call whose fate is
  unknown fails to the model; a model call becomes `uncertain`. A `value` larger than
  `limits.recordEntryBytes` as JSON is refused with `limit_exceeded`, and nothing is recorded:
  core shapes no result, so store the rest with `artifact.write` and report a preview
  (section 5b).

Re-sends:

* Tool calls are delivered at least once. When a gate hop is lost, ask the same intent again.
* An intent for a row still `invoking` is a re-send. Core counts it, up to the Runtime limit
  `toolAttempts` (3 by default), and answers `execute`, or `completed` with a `tool.interrupted`
  failure.
* A `sandbox` or `harness` call that a crash interrupted gets `tool.interrupted` at once.

What an intent carries:

* **The effect id** is `<turnId>:<segment>:<kind>:<identity>` (`effectIdOf`). `identity` must
  name the effect stably within the segment, never by call order: a run that starts over must
  ask for the same ids. Core replays to a run only outcomes whose id has its segment's prefix.
* **The request hash** is `effectRequestHash(intent)` over the whole request. For a model
  effect, hash the intent with `input` set to its `ModelCall`, and send the intent without
  `input`. A replay that asks for the same id with another hash is `effect_drift`.
* **`context`** carries the ids the record derives its events from:
  * a model effect needs `invocationId` (the step);
  * a tool effect needs `callId` (the model's tool call id) and `invocationId`, or no
    `tool.completed` is written;
  * a tool effect may name its `stepId`.
* **Kinds:**
  * `model`;
  * `tool` with `toolName`, the name the model called it by (for display only), and `target`,
    what runs it, by identity. Core routes a call by its target, never by its name, and no name
    is reserved. The target is in the request hash, so a replay that names another tool is
    `effect_drift`:

    | `target`                                        | Runs when                                                                                                                                                                                            | Who runs it                                                                                                                                                                                                                       |
    | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
    | `{kind: "mcp", server, tool}`                   | The call's agent declares `server`, the session discovered `tool` (the server's raw name) on it, and the turn's manifest leaves it enabled.                                                          | You, through the Tool Gate.                                                                                                                                                                                                       |
    | `{kind: "http", tool}`                          | The call's agent declares an HTTP tool named `tool`.                                                                                                                                                 | You, through the Tool Gate.                                                                                                                                                                                                       |
    | `{kind: "sandbox"}`                             | The session has a sandbox and `toolName` is one of `bash`, `read`, `write`, `edit`, `grep`, `glob`.                                                                                                  | You, in the session's sandbox.                                                                                                                                                                                                    |
    | `{kind: "harness"}`                             | Always: your own process, whose egress gate is its only control.                                                                                                                                     | You.                                                                                                                                                                                                                              |
    | `{kind: "runtime", operation: "schedule_wake"}` | The session's version sets `runtime.selfWake` (`turn.start.agent.selfWake`); input `{in \| at, note}` or `{cancel: true}`.                                                                           | Core, in the intent's own transaction: answered `completed`, never `execute`.                                                                                                                                                     |
    | `{kind: "runtime", operation: "memory"}`        | The session's version sets `harness.memory` (`turn.start.memory`); input is Anthropic's memory tool's: `command` (`view`, `create`, `str_replace`, `insert`, `delete`, `rename`) and its parameters. | Core, likewise: a view, or a write versioned to the turn. A missing parameter, a path outside `/memories`, or a write that is `read_only` or an agent used as a tool's (`memory.forbidden`) is a failure with text for the model. |

    Each refusal is answered `completed` with a failure and never runs: an MCP tool the session
    did not discover is `tool.unknown`, one the turn disabled `tool.disabled`, and a call with no
    target, or an HTTP tool or sandbox the session lacks, `tool.unavailable`. Core runs no tool
    itself but `schedule_wake` and `memory`: `tool_search` over deferred tools is yours (ours is a
    `harness` call), and the deferred tool's call names its own MCP target. A tool of yours may
    take any name; a name two of the model's tools share is yours to refuse. `skill` (optional)
    names the skill the call ran.
  * The tool policy matches the identity too: a rule's `server` and `tool` are the MCP server and
    its raw tool name, or `tool` an HTTP tool's declared name. The name you gave the model never
    matches.
  * `delegation` and `agent` belong to our engine's subagents and flows.
* **What the log records of a tool call.** Each one ends in one `tool.completed`, and a row at
  `GET /v1/sessions/{id}/calls/tool`:
  * `kind`: the target's kind;
  * `server`: the MCP server, or the HTTP tool's URL host;
  * `tool`: the target's identity, an MCP tool's raw name or an HTTP tool's declared name
    (`toolName` stays the model's name);
  * `skill`: the skill the intent named, when the agent has it. Our engine names it for a skill
    tool's call and for a sandbox call whose input names its folder, `/skills/<name>@<hash>/`;
  * `plugin`: `{name, version?}`, the Agent Plugin that brought its MCP server or skill.

## 4b Artifacts [#4b-artifacts]

A harness reads and writes the artifacts of the run it holds over two requests. The client
helpers `readArtifact`, `openArtifactWrite` and `writeArtifactBytes` in
`@nylorun/core/harness-api` do the chunking.

**Scope.** Everything is the run's session's, never one you name. Agents used as tools share it.

* You may read the session's own artifacts, and the Tenant-wide ones its messages named.
  Another session's, a Tenant-wide one never named, an Agent's disk versions and a missing one
  are all the same `not_found`, so nothing tells you which exist.
* You may write a new file artifact of the session, or a new version of one of its own file
  artifacts.

**`artifact.read {runId, artifactId, version?, offset?, length?}`** (`ArtifactMetaSchema`):

* It answers `{artifact, offset, base64, eof}`:
  * `artifact` is `{artifactId, kind, name, version, contentType, size, sha256, tenantWide}`, for
    `version` (default the latest);
  * `base64` is the bytes from `offset` (default 0), at most `length`;
  * `eof` says no bytes are left past this chunk.
* `length` defaults to `turn.start.limits.chunkBytes`, and is held to it. `length: 0` asks for
  the metadata alone.
* An `offset` past the end is `invalid`; one at the end answers no bytes and `eof: true`.
* A folder answers its metadata, with no bytes.
* Ask for later chunks with the version the first answer named, since a new one may land.

**`artifact.write`** is an upload in steps, each a request with `op`:

| `op`     | Params                                                                                                                                             | Answer                                                            |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| `begin`  | `key`, then `name` (a new artifact) or `artifactId` (a new version); optional `contentType` (default: from the name), `size`, `effectId`, `base64` | `{status: "open", uploadId, maxBytes, received: 0}`, or `written` |
| `chunk`  | `uploadId`, `offset`, `base64`                                                                                                                     | `{status: "open", uploadId, maxBytes, received}`                  |
| `commit` | `uploadId`                                                                                                                                         | `{status: "written", artifact, created}`                          |
| `abort`  | `uploadId`                                                                                                                                         | `{status: "aborted"}`                                             |

* **One chunk** carries at most `turn.start.limits.chunkBytes`; a larger one is
  `limit_exceeded`. A `begin` with `base64` sends the whole body at once and commits it.
* **Offsets.** Each chunk's `offset` must be `received`. A repeat of the last chunk, whose answer
  you lost, is acknowledged and not taken twice; any other offset is `invalid`.
* **Limits.** `maxBytes` is what the upload may hold when it opens: the file limit, or what is
  left of the Tenant's or the Agent's total. A declared `size` past it, or bytes past it, are
  `limit_exceeded`, with `limitBytes` in the message. The totals are checked again at commit.
* **Idempotency.** `key` makes the write idempotent within the session: a `begin` with a key
  already written answers `{status: "written", created: false}` with that version, and sends
  nothing more. Our harness uses a tool call's effect id as its key.
* **Provenance.** `effectId` names a tool effect of the run's turn, journaled first. The
  artifact's `artifact.created` (or `artifact.version.created`) event then carries that call's
  `callId`, which core reads from its journal. Any other effect is `invalid`.
* **Ends.** An upload your run leaves open ends with the run, and nothing of it is stored.
  `abort` ends one now, and answers `aborted` for one already gone.

```ts
import { readArtifact, writeArtifactBytes } from "@nylorun/core/harness-api";

const { chunkBytes } = run.start.limits;
const saved = await writeArtifactBytes(
  channel,
  { runId: run.runId, key: effect.effectId, effectId: effect.effectId, name: "report.md", chunkBytes },
  new TextEncoder().encode("# Report\n"),
);

const opened = await readArtifact(channel, { runId: run.runId, artifactId: saved.artifact.artifactId, chunkBytes });
for await (const chunk of opened.chunks()) process.stdout.write(chunk);
```

**Folders.** A `begin` with `kind: "folder"` writes a folder version of the session instead, file
by file:

| `op`     | Params                                                                                                                                                                                | Answer                                                                                                 |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `begin`  | `kind: "folder"`, `key`, `name`; optional `artifactId` (a new version of that folder of the session) or `sameName: true` (of its newest folder named `name`, made when there is none) | `{status: "open", uploadId, maxBytes, maxFileBytes, maxEntries, received: 0}`, or `written`            |
| `file`   | `uploadId`, `path` (relative, `/`-separated, no `.` or `..`), `size`, `sha256`; optional `contentType`, `base64` (the whole file, when it fits one chunk)                             | `{status: "open", …, received}`: the file's bytes received                                             |
| `chunk`  | `uploadId`, `sha256` (the file in progress), `offset` (within the file), `base64`                                                                                                     | As `file`.                                                                                             |
| `commit` | `uploadId`                                                                                                                                                                            | `{status: "written", artifact, created, unchanged?}`, or `{status: "refused", reason, message, limit}` |

* **Every byte.** Core takes every file's bytes, whether the store holds that content already
  or not, so a hash alone never names content. A file whose bytes do not match its `sha256` is
  `invalid`, and the upload ends.
* **Limits.** `maxEntries` is `limits.folderEntries`, `maxFileBytes` the per-file limit and
  `maxBytes` the most the files may hold together; past one, the `file` request is
  `limit_exceeded`. At commit, the version's new content (after dedupe) must fit the Tenant's and
  the Agent's totals: past one the answer is `refused` with `reason` `tenant_total` or
  `environment_total` (the Agent's), and nothing is stored.
* **Unchanged.** A version whose files are the folder's latest is not added: `written` with
  `created: false` and `unchanged: true` names that version.
* Our engine's outputs folder writes one at each completed turn's end, keyed `outputs:<turnId>`,
  and claims `artifact.export.skipped` (`{name, reason, message, limit?, path?}`) or
  `artifact.export.failed` (`{name, message}`) with `event` when it stores nothing.

Errors:

| Code                                               | When                                                                                                                                                                                   |
| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `not_found`                                        | The artifact is not there for the run, or `artifactId` is not one of the session's file artifacts (or, for a folder, folders).                                                         |
| `limit_exceeded`                                   | A chunk past `chunkBytes`; a file or total artifact limit.                                                                                                                             |
| `invalid`                                          | A bad name, path or media type, no name, an offset other than `received`, an unknown `uploadId`, an `effectId` not a tool call of the turn, a file's bytes that do not match its hash. |
| `run_not_held`, `turn_cancelled`, `ownership_lost` | As for every run request.                                                                                                                                                              |

## 5 Gates [#5-gates]

The gates run on the gateway, at `NYLORUN_GATES_URL`, and hold every model and tool credential.
A harness never sees one.

* Send `Authorization: Bearer <run token>` and `Idempotency-Key: <effect id>` on every call.
  The gate hands a tool the same key: `Idempotency-Key` to an HTTP tool, and
  `_meta["nylorun/idempotency-key"]` on an MCP `tools/call`. A tool that must act once checks it.
* A tool call must be journaled first (`effect.intent` answered `execute`), or the gate refuses
  it: `400 key_required` or `409 effect_not_journaled`.
* Gate errors are `{error: {code, message}}` with `GATE_ERROR_CODES`. `409 run_stale` means the
  run is over.

The calls:

* **Model.** `POST /nylorun/v1/model-calls` with `ModelCallBodySchema`:
  `{effectId, invocationId, call: ModelCall}`. A `ModelCall` carries
  `{prompt, tools, model?, executionId}`, where `executionId` is the session id.
  * A media part's `reference` names an artifact version (`{artifactId, version}`); the gate
    fetches it under the session's read rule (section 4b) and sends it in the provider's own
    format: an image as image input, a PDF, Office document, audio or video as the API's native
    block.
  * The gate decides nothing about files and writes no text of its own. It refuses, before
    anything is sent and never retried: a text file as a media part, or a kind the model or its
    API does not read (`unsupported_input`; inline text yourself, and tell the model about a file
    it cannot read); a reference it cannot fetch (not the session's, missing, a folder, past
    `limits.modelFileBytes`: `invalid_request`).
  * It adds no output-schema instruction and repairs no JSON: for a structured answer, write the
    instruction yourself and parse the model's text.
  * It answers `200 {outcome}`: a `ModelCandidate` (`output` blocks, `finishReason`, `usage`) or
    a failure `{kind: "failed", code, message, retryable}`.
  * The gate meters every call: one `model_usage` row per effect id, readable with its
    `effectId` at `GET /v1/sessions/{id}/calls/model`.
  * `POST /nylorun/v1/model-calls/{key}/cancel` aborts a call.
* **MCP.** `mcp/connect`, `mcp/list`, `tool-calls` (`ToolCallBodySchema`) and `mcp/close` name
  a server the agent declares, never a URL: `server: {agentId?, serverName}`. A `tool-calls`
  body is `{server, effectId, name, arguments}`, with `name` the tool's raw name on the server,
  `target.tool`. The gate runs it only when the journaled row's target names the same agent,
  server and tool.
* **HTTP tools.** `http-calls` (`HttpCallBodySchema`) names a manifest tool.
* **Answers.** MCP and HTTP calls answer `200 {ok: true, result}` or `{ok: false, error}`; a
  tool's failure is an answer, not a gate error. `tool-calls/{key}/cancel` aborts either. The
  gate replaces the credential values it sent that an answer echoes with `[redacted]`, and
  refuses an answer past `limits.toolAnswerBytes` (`mcp.too-large`, `http.too-large`). It shapes
  nothing else: the whole result is yours to fit (section 5b). A re-send under the same key
  returns the same bytes.

## 5b Tool results [#5b-tool-results]

A tool's answer can be far larger than one record entry. Fitting it is the harness's job.

* **What must fit.** Each `effect.outcome` value, and each transcript entry (section 6), is at
  most `limits.recordEntryBytes` as JSON. A step's results are one `tool-results` entry, so they
  share it.
* **Spill the rest.** Store what does not fit as file artifacts with `artifact.write`
  (section 4b), with the call's `effectId`, so `artifact.created` names the call. Report a
  preview the model can act on, and let it read the rest (ours: `read_artifact`).
* **Key each store by the call.** A crash can run a call again, and the gate answers it with
  the same bytes. A key made from the effect id and the bytes' hash writes nothing new then.
* **Binary parts.** An MCP `image`, `audio` or blob `resource` part is base64 in the answer.
  Store it, and send an image to the model as a file only when `turn.start.model` reads images.
* **Our shaping is reusable.** `@nylorun/harness` exports what our engine does:
  * `shapeToolOutcome(value, ctx)`: one outcome, shaped to a cap;
  * `StepBudget`: one step's budget, `stepBudgetBytes` (256 KiB) held under
    `recordEntryBytes` less `entryEnvelopeBytes` (4 KiB). Each MCP or HTTP result gets at most
    `resultInlineBytes` (32 KiB), and a result once the budget is spent a 512-byte stub naming its
    artifact;
  * `spillKey(effectId, index | "whole", bytes)`: the store's key;
  * `DEFAULT_TOOL_RESULT_CONFIG` and `ToolResultConfig`.
* **What our engine sends the model.** Text or JSON past the cap becomes `{truncated: true,
  artifactId, size, preview}`, the first 4 KiB and the last 1 KiB. A store refusal keeps the
  preview, with `dropped` saying why. A result that still cannot fit fails as
  `tool.result-too-large`.

## 6 Transcript [#6-transcript]

The session's transcript is shared by every turn. A segment reads it once and reports its edits
as `TranscriptUpdate`s, `{keep, entries, length}`, made with
`transcriptUpdates(before, after, limits.recordEntryBytes)`: an edit fills each event up to the
record entry limit, and an entry larger than it is an event of its own, which core refuses.
Entries are one of five kinds (`TranscriptEntrySchema`):

* `input`: the turn's input event, one for each message of a `messages` input;
* `candidate`: the model's answer, by `stepId`;
* `tool-results`: the step's tool results;
* `final`: the turn's output;
* `compaction`: a summary that replaced the older part. It is always first.

History, `transcript.read` and the next turn's cursor fold these edits, so write these kinds
only.

**Compaction is an edit you mark.** Core never parses your entries to find one:

* send each model call that writes a summary with `purpose: "summary"` on its intent; core
  records no `message.assistant` for it;
* mark the edit that replaced the history `kind: "compaction"`, with `compaction: {trigger,
  tokensBefore, tokensAfter, summaryEffectIds}` (`CompactionFactsSchema`;
  `transcriptUpdates(before, after, recordEntryBytes, compaction)` puts it on the edit's first
  event). `summaryEffectIds` are the completed summary calls of this turn that wrote it, in
  order;
* core checks those calls (a call that is not one is `invalid`, and the turn fails) and writes
  one `context.compacted` with your facts and the last call's step and timing.

## 7 Output [#7-output]

End each segment with one output request (`TurnOutputSchema`):

| Status      | Method           | Meaning                                                           |
| ----------- | ---------------- | ----------------------------------------------------------------- |
| `completed` | `turn.completed` | The turn is done; `output` is its result.                         |
| `failed`    | `turn.failed`    | With `error: {code, message}`, or `thrown` for a loop that threw. |
| `cancelled` | `turn.failed`    | Report this after a cancel.                                       |
| `waiting`   | `turn.waiting`   | An effect is `pending`: core waits for it.                        |
| `uncertain` | `turn.waiting`   | An effect is `uncertain`.                                         |
| `yielded`   | `checkpoint`     | The turn goes on in a new segment: same turn, next segment.       |
| `paused`    | `turn.paused`    | Our engine only, in v5.                                           |

The output also carries:

* `state`: what you want back next segment. It is stored as you send it and never read.
  * A custom harness's state is at most `limits.harnessStateBytes` (256 KiB by default), or the
    turn fails (`limit_exceeded`).
  * Return some state on every segment.
* `transcript`: your edits (section 6).
* `effectIds`: the effects a waiting segment waits on. When they are calls the tool policy holds,
  core pauses the turn with one `approval` interaction per call, whose id is the effect id. An
  MCP call's interaction names its `server` and `tool` (`Approve <server> / <tool>?`), and
  `toolName` stays the model's name. Once a person answers, a new segment replays: an approved
  call's intent answers `execute`, a denied one's `completed` with `tool.denied`. A harness needs
  nothing more for approvals.

The answer is `{cursor}`: the transcript's new cursor, for your cache.

## 8 Cancel and abort [#8-cancel-and-abort]

* Core sends `cancel {runId, reason, message?}` as a message, with `reason` one of `cancel`,
  `shutdown`, `deadline` and `ownership.lost`.
* On `cancel` and `deadline`: stop, cancel the gate calls you started (their `cancel` routes),
  and report `turn.failed` with status `cancelled`.
* On `shutdown` or `ownership.lost`: send `lease.release`. Another owner replays the segment
  from its journal.
* `{t: "abort", id}` cancels one request in flight.

## 9 Pod host [#9-pod-host]

A harness in a pod sandbox is the pod: a pod Agent whose manifest's `harness.id` is not ours runs
its `runtime.image`'s own entrypoint, with nothing of ours copied in.

The pod's environment:

| Variable                    | What it is                                          |
| --------------------------- | --------------------------------------------------- |
| `NYLORUN_SANDBOX_ID`        | The sandbox to join as                              |
| `NYLORUN_POD_UID`           | The pod's UID                                       |
| `NYLORUN_SANDBOX_JOIN_FILE` | The mounted join token; read it afresh at each join |
| `NYLORUN_HARNESS_URL`       | `http://host:port`, without the path                |
| `NYLORUN_GATES_URL`         | The gates                                           |
| `NYLORUN_EGRESS_PROXY`      | The egress proxy every command must use             |
| `HOME`                      | `/harness/home`                                     |

How it joins and stays joined:

1. `POST /nylorun/harness/v1/host/join` with `{sandboxId, podUid, joinToken, volumeId?}`
   (`HostJoinRequestSchema`). Keep `volumeId` on the volume (`/harness/volume-id`), so a volume
   recreated empty is told apart.
2. The answer (`HostJoinAnswerSchema`) is `{hostToken, egressToken, sandboxId, epoch,
   expiresAt, caCertificate}`. On a new disk, a join also carries the Agent's `setup`: its input
   files and setup commands. Our engine runs them before it takes turns, and a custom harness
   runs them itself or ignores them. `seeded: true` means the disk starts from a disk version, so
   there is no setup to run.
3. Connect with the host token as the WebSocket bearer. Say `hello` with the Agent's harness id
   (its manifest's `harness.id`); any other is refused.
4. Every 5 minutes, `POST .../host/renew` with the host token as bearer. Join again when a
   renewal is refused.

Every command the harness starts uses the egress proxy, with the egress token as its password
and the CA certificate added to its trust bundle. The workload runs as uid 1000, on a read-only
root, with `/workspace`, `/harness`, `/tmp` and `/skills` writable.

## 10 Workspace (optional) [#10-workspace-optional]

A harness that declares `features.workspace` serves sandbox workspaces: core sends it
`workspace.read`, `workspace.write`, `workspace.exec`, `workspace.report`, `workspace.sweep` and
`workspace.remove`.

* A pod's host serves its own pod.
* A session's workspace off a pod is served by its own harness, if that harness declared the
  feature: our harness's workspaces are never a custom harness's files. Sweeps and removes go
  to ours.
* Without the feature, core answers the session sandbox routes `503`, and turns still run.
* `workspace.read` carries a sandbox tool call or a disk operation only: core lists and reads no
  workspace files of its own.

## What the log records [#what-the-log-records]

Every event says who wrote it (`source`) and how far it can be trusted (`evidence`):

| Writer                         | `source`                                                                                                | `evidence`                                                            |
| ------------------------------ | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| A harness                      | `{kind: "harness", id: <harness id>}`, with the event's `runId`                                         | `claimed`: an assistant message, a tool result it ran, its turn's end |
| A command over the Runtime API | `{kind: "client", …}`; the event also names the sender, from the request's key or token, never its body | `observed`                                                            |
| Core itself                    | `{kind: "core", …}`: settle, sweeps, the tools core runs                                                | `observed`                                                            |

The Model Gate's usage row, keyed by the same effect id, is the measured record of each model
call a harness claims.

Core replaces every Nylorun-signed token in what a harness reports with
`[nylorun token redacted]` before recording it: a command that prints its proxy variables, or a
model that repeats a token, puts nothing usable in the log. Other values pass through unchanged.

## 11 Versioning [#11-versioning]

There are three numbers:

* the transport (path and header): 1;
* the messages: `HARNESS_API_VERSION`, 5;
* the Runtime protocol, which only the Runtime and Management APIs carry.

v5's schemas are strict, and v5 is frozen: a change to a message is a new version. Core speaks
one version: a harness that says `api: 4` is refused. What earlier versions changed is on
[Compatibility](/docs/compatibility).

What a custom harness cannot do in v5:

* pause the turn itself (the tool policy's approvals work without it, section 7);
* run flows;
* receive another harness's workspace requests.
