# Bring your own harness (/docs/build/harness)



A harness runs an agent's loop: it calls the model, runs tools and reports what happened. The
Runtime runs ours (`nylorun`) by default; any program that speaks the
[Harness API](/docs/build/harness-api) can run instead. A custom harness runs as the host of a pod
Agent: the agent manifest names it in `harness.id`, with a runtime of kind `pod` whose `image` is
the harness. Start from the [minimal example](https://github.com/nylorun/agents/tree/main/examples/harness-minimal),
a harness in TypeScript that depends only on `ws` and `@nylorun/core/harness-api`.

```ts
import { createClient } from "@nylorun/agents";

const client = await createClient();
await client.agentManifests.save({
  manifestSchemaVersion: 8,
  name: "minimal-box",
  harness: { id: "minimal", instructions: ["Answer briefly."] },
  runtime: { kind: "pod", image: "registry.example.com/harness-minimal:1" },
});
await client.agents.put("teams/minimal", { manifest: "minimal-box" });

const session = await client.createSession({
  agent: "teams/minimal", // this session's turns run on the minimal harness
  ownerUserId: "user-123",
});
```

Saving a `harness.id` other than `nylorun` without `runtime.kind: "pod"` and an `image` is
refused with `422 manifest_inconsistent`. The Agent's pod runs the image's own entrypoint, with
nothing of ours copied in, and the harness joins from the pod with the join token mounted in it,
with no harness token. Sessions on
other Agents keep running ours. A harness container connected with an operator's token takes none
of these runs.

1. **Start from the example.** Copy
   [`examples/harness-minimal`](https://github.com/nylorun/agents/tree/main/examples/harness-minimal),
   written against Harness API 5. It shows each part of the [Harness API](/docs/build/harness-api)
   in one loop: join from the pod, hello, lease, read the transcript, call the Model Gate, run
   `bash`, and journal every effect before running it.
2. **Build the image.** Build the harness into the image your agents' tools need (uid 1000,
   read-only root, `HOME=/harness/home`), with the harness as its entrypoint, and push it where the
   cluster pulls from. Pod Agents need sandbox pods: on a local Tenant,
   `npx nylorun local sandbox enable --context <ctx>`; on a server, see
   [Kubernetes](/docs/deploy/kubernetes#custom-harnesses).
3. **Save the manifest, make the Agent, open sessions**, as above.
4. **Check it** with the fixture model. On a Tenant for testing, turn it on with a management key;
   the Model Gate then answers every model call itself and meters it like any model:

   ```ts
   import { createAdmin } from "@nylorun/admin";

   const admin = createAdmin();
   await admin.tenant.seed({ fixtureModel: true }); // stays on for this Tenant
   ```

   Send scripted messages to a session on the Agent, each after the previous turn ends:

   ```ts
   await session.input("fixture:text", { idempotencyKey: crypto.randomUUID() });
   // after that turn ends:
   await session.input('fixture:tool bash {"command":"echo hi"}', {
     idempotencyKey: crypto.randomUUID(),
   });
   ```

   Each turn must end `turn.completed` with `source: { kind: "harness", id: "minimal" }`.
   `session.modelCalls()` has one metered row per model answer (provider `fixture`), and
   `session.toolCalls()` one journaled row for the `bash` call.
5. **Ship a new version.** A new image is a new version of the manifest: save it, and each Agent
   that tracks the manifest moves to it, its pod switching at its next start.
   `client.agents.restart(id)` applies it now: a running pod stops, and the next turn starts it on
   the new version. An Agent's harness is fixed for its life:
   a version that names another harness is `409 agent_fixed_field` for it. Fork the Agent onto that
   version instead, and open new sessions on the fork. See [Fork](/docs/run/agents#fork).
6. **Restrict where harnesses run.** The Tenant's placement
   (`admin.settings.sandbox.put({ placement })`, which replaces every
   [agent compute setting](/reference/management/settings/set-the-tenants-agent-compute-settings))
   maps harness ids to hosts. `"*": { hosts: [] }` refuses any harness it does not name.

The fixture model answers these messages:

| Message                           | Answer                                                                                                           |
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `fixture:text`                    | `fixture ok`.                                                                                                    |
| `fixture:tool <name> <json>`      | One call of the offered tool `<name>` with `<json>` as its arguments, then a text answer that quotes the result. |
| `fixture:unoffered <name> <json>` | The same call of a tool the turn does not offer. Check that nothing runs.                                        |
| `fixture:budget`                  | A model call that fails with `budget_exhausted`.                                                                 |
| `fixture:slow <s>`                | A text answer after `<s>` seconds (at most 60), for cancel checks.                                               |

## Harness ids [#harness-ids]

A harness id matches `^[a-z][a-z0-9-]{1,62}$`: lowercase letters, digits and dashes, starting with
a letter. The id is what the harness says in `hello`, an agent manifest's `harness.id`, and the key
of the Tenant's placement. Ours is `nylorun`.

## Pinned sessions [#pinned-sessions]

A session runs the harness of the Agent it runs on for its whole life, and `GET /v1/sessions/{id}`
returns it as `harness`. Since an Agent's harness never changes, neither does a session's. A flow
always runs ours.

## What a harness reads [#what-a-harness-reads]

The turn's manifest is 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`. It carries the agent's own
`instructions`, `tools`, `skills` and `mcpServers`, and nothing of the Runtime's: what the model
reads and which tools it is offered are the harness's to write. It writes them from the facts in
`turn.start`:

| Fact                    | What it says                                                                                                                   |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `agent`                 | The Agent the session runs on: `{ id, kind, sandbox?, selfWake? }`. `sandbox.workspace` is there when the Agent has a sandbox. |
| `routing.sandbox`       | The session's sandbox spec and the variables every command gets.                                                               |
| The manifest's `skills` | Each skill's files by hash, whose bytes the harness reads with `definition.file`.                                              |
| `mcp`                   | What the session's MCP servers listed, by their own names.                                                                     |
| `model`                 | The Tenant's model: what it reads, its context window and output limit.                                                        |
| `memory`                | The Agent's memory access, instructions and index, when the manifest turns memory on.                                          |
| `limits`                | The Tenant's Runtime limits a harness must keep within.                                                                        |
| `definition`, `loop`    | The agent manifest's name and version, and the harness section's `loop` settings.                                              |

A harness in TypeScript can reuse our engine's helpers from `@nylorun/harness`: `engineToolsFor`
(everything our engine adds, in our order), `sandboxTools` and `sandboxInstructions`,
`skillCatalogText`, `skillFolderNote`, `skillTools` and `runSkillTool`, `mcpToolSet`,
`shapeToolOutcome`, and `memoryTool`. Shapes are in the
[Harness API](/docs/build/harness-api#3-input-turnstart).

## Tools of your own [#tools-of-your-own]

A harness declares no tools. Each tool call it journals names its `target`, what runs it: an MCP
tool by `server` and its own name, an HTTP tool by its declared name, a sandbox tool, a Runtime
operation (`schedule_wake`, `memory`), or `{ kind: "harness" }` for a tool the harness runs in its
own process. The Runtime routes each call by its target, never by its name, so no name is
reserved; a name two of the model's tools share is the harness's to refuse. A `harness` call runs
in the harness's pod, so the tool policy does not apply to it; the egress gate is its only control.
Its calls are `kind: "harness"` in `tool.completed` and `session.toolCalls()`. Shapes are in the
[Harness API](/docs/build/harness-api#4-record-intent-outcome-hash-effect-ids).

`hello` says `api: 5`; core refuses any other. In `@nylorun/harness/api`, our engine's
`createHarness()` takes `features.workspace` alone as its features, and settings for what our
engine adds:

| Option                                                           | What it sets                                                                           |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `artifactPageBytes`                                              | The most one `read_artifact` page holds (32 KiB).                                      |
| `sandboxTools`                                                   | The sandbox tools' limits: `bash` timeouts, `read` and `grep` counts.                  |
| `skillReadBytes`                                                 | The most of one skill file the skill tools read into the model's context (10 MiB).     |
| `mcpTools`                                                       | When MCP tools are deferred, and `tool_search`'s limits.                               |
| `toolResults`                                                    | How tool results are cut to fit: inline cap, preview, step budget.                     |
| `outputs`                                                        | The outputs folder saved at a completed turn's end (`/workspace/outputs`, 1 GiB).      |
| `compaction`                                                     | The kept tail, the reply's reserve, the estimate.                                      |
| `partialOutputChars`, `flowValueChars`                           | How much of a failed subagent's output, and of a value in a flow's failure, is quoted. |
| `maxRuns`, `transcriptCacheMb`, `leaseRetryMs`, `resendDelaysMs` | Runs held at once, the transcript cache, and retry timing.                             |
| `onHello`, `onGrant`, `onStart`                                  | Callbacks that see the `hello` answer, each grant, and each run's `turn.start`.        |

## Limits [#limits]

* A custom harness's state is at most the Tenant's Runtime limit `harnessStateBytes` (256 KiB by
  default), or the turn fails with `limit_exceeded`.
* A custom harness cannot pause a turn itself. The Tenant's [tool policy](/docs/run/tool-policy)
  still holds the MCP and HTTP calls it makes and pauses the turn for approval without it.
* It cannot run flows: a flow always runs our harness.
* It gets no outputs folder and no skill folders unless it writes them: those are our harness's.
* Without `features.workspace` it serves no workspace, and the session's sandbox routes answer
  `503`. It is sent no workspace requests of another harness.
