Bring your own harness
Write a harness in any language, run it as the host of a pod Agent, and check it with the fixture model.
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 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,
a harness in TypeScript that depends only on ws and @nylorun/core/harness-api.
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.
-
Start from the example. Copy
examples/harness-minimal, written against Harness API 5. It shows each part of the Harness API in one loop: join from the pod, hello, lease, read the transcript, call the Model Gate, runbash, and journal every effect before running it. -
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. -
Save the manifest, make the Agent, open sessions, as above.
-
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:
import { createAdmin } from "@nylorun/admin"; const admin = createAdmin(); await admin.tenant.seed({ fixtureModel: true }); // stays on for this TenantSend scripted messages to a session on the Agent, each after the previous turn ends:
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.completedwithsource: { kind: "harness", id: "minimal" }.session.modelCalls()has one metered row per model answer (providerfixture), andsession.toolCalls()one journaled row for thebashcall. -
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 is409 agent_fixed_fieldfor it. Fork the Agent onto that version instead, and open new sessions on the fork. See Fork. -
Restrict where harnesses run. The Tenant's placement (
admin.settings.sandbox.put({ placement }), which replaces every agent compute setting) 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
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
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
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.
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.
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
- A custom harness's state is at most the Tenant's Runtime limit
harnessStateBytes(256 KiB by default), or the turn fails withlimit_exceeded. - A custom harness cannot pause a turn itself. The Tenant's 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.workspaceit serves no workspace, and the session's sandbox routes answer503. It is sent no workspace requests of another harness.