NylorunDocsBeta
BuildHarnesses

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.

  1. 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, 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.

  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:

    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:

    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.

  6. 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:

MessageAnswer
fixture:textfixture 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:budgetA 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:

FactWhat it says
agentThe Agent the session runs on: { id, kind, sandbox?, selfWake? }. sandbox.workspace is there when the Agent has a sandbox.
routing.sandboxThe session's sandbox spec and the variables every command gets.
The manifest's skillsEach skill's files by hash, whose bytes the harness reads with definition.file.
mcpWhat the session's MCP servers listed, by their own names.
modelThe Tenant's model: what it reads, its context window and output limit.
memoryThe Agent's memory access, instructions and index, when the manifest turns memory on.
limitsThe Tenant's Runtime limits a harness must keep within.
definition, loopThe 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:

OptionWhat it sets
artifactPageBytesThe most one read_artifact page holds (32 KiB).
sandboxToolsThe sandbox tools' limits: bash timeouts, read and grep counts.
skillReadBytesThe most of one skill file the skill tools read into the model's context (10 MiB).
mcpToolsWhen MCP tools are deferred, and tool_search's limits.
toolResultsHow tool results are cut to fit: inline cap, preview, step budget.
outputsThe outputs folder saved at a completed turn's end (/workspace/outputs, 1 GiB).
compactionThe kept tail, the reply's reserve, the estimate.
partialOutputChars, flowValueCharsHow much of a failed subagent's output, and of a value in a flow's failure, is quoted.
maxRuns, transcriptCacheMb, leaseRetryMs, resendDelaysMsRuns held at once, the transcript cache, and retry timing.
onHello, onGrant, onStartCallbacks 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 with limit_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.workspace it serves no workspace, and the session's sandbox routes answer 503. It is sent no workspace requests of another harness.

On this page