Harness API
The contract between the Runtime and a harness, section by section.
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 is the guide.
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
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, 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
- A WebSocket to core's harness listener (port 4200 by default), path
/nylorun/harness/v1. - Upgrade headers:
Authorization: Bearer <token>andNylorun-Harness-Api: 1. A wrong path is404, a missing or unknown token401, a missing header426, a closed Tenant503. - 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 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) andlimit_exceeded(what you sent passes a Runtime limit ofturn.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 tonylorun. 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
hello {api: 5, name, version, features}is the first request.nameis the harness id:^[a-z][a-z0-9-]{1,62}$, and the one the credential is for.features.workspacedeclares 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
apiand 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, elsenylorun; a pod Agent's runs, only to its pod's host.GET /v1/sessions/{id}returns it asharness. A session never moves to another harness, since its state is that harness's. RunGrantis{runId, sessionId, turnId, epoch, token, tokenExpiresAt};tokenis the run token for the gates (section 5).- Keep one
leasewaiting for each run you can take.
- Core offers a session's runs only to connections of the harness the session pinned when it
opened: its Agent's manifest's
lease.renew {runId}everyrenewEveryMs. 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
TurnStart (TurnStartSchema) carries:
typeandengine:turn.startorapproval.answer;agentorflow. 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'snameas itsid(or a variant a message named). It is flat: the agent's owninstructions,tools,skillsandmcpServers. A skill or a server that came from an Agent Plugin carriesplugin: {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),sandboxToolsandsandboxInstructions,skillCatalogText,skillFolderNoteandskillTools, andrunSkillTool.- Skills: each skill's
name,descriptionandfiles(path tosha256:<hex>). Read a file's bytes withdefinition.file {runId, sha256, offset?, length?}, at mostlimits.chunkBytesa 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,sandboxSkillsOfanddefinitionFileChunksin@nylorun/harness); the Runtime mounts nothing. - Outputs: core exports none. Our engine saves
/workspace/outputsas the session'soutputsfolder 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).
- Skills: each skill's
definition(optional):{name, version}, the agent manifest version the session runs, for your records.loop(optional): the harness section'sloop, your own settings, which the Runtime passes on without reading. Ours reads none.memory(TurnMemorySchema, optional): present when the version the session runs setsharness.memory.access(read_writeorread_only, the same in every turn),instructions(the agent's own, after yours), andindex: one line per memory, newest first,- <path>with— <description>from its frontmatter, within the Runtime limitsmemoryIndexLinesandmemoryIndexBytes(truncatedwhen 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 amemorytool whose calls areruntimeeffects (section 4); offer onlyviewforread_onlyand to agents used as tools. Ours words it withmemoryToolandmemoryIndexBlock(@nylorun/harness/api), checks each command's parameters before it sends a call (checkMemoryCall,@nylorun/core/contracts; core checks again), and takes a call namedmemory.<command>ormemory/<command>asmemorywith that command. See Agent memory.checkpoint(HarnessCheckpointSchema):sessionId,turnId,segment,manifestHash,inputandstate.inputis 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 owninputtranscript entry), or{kind: "continue"}for the next segment of a yielded turn.stateis 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 withtranscript.read {runId}, which answers{cursor, entries}. Entries areTranscriptEntrySchema(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}.agentIdnames the agent used as a tool that declares it (absent for the root);instructionsare 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?andannotations?, plusenabled(false when the turn's manifest disables it: skip it, since core refuses its calls astool.disabled) anddeferred?(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__toolwith a hash suffix when needed, aremcpToolNameandmcpToolNamesin@nylorun/harness, andmcpToolSetbuilds 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:ownerIdandenvironmentId(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), andenvironment, the variables every command gets. Eachenvironment_secretis set to the placeholdernylorun_managed, never its value.
agent(TurnAgentSchema, optional): the Agent the session runs on,{id, kind, sandbox?: {workspace}, selfWake?}.sandboxis there when the session has a sandbox (kindvirtualorpod), andworkspaceis the directory its commands start in (/workspace). Offer sandbox tools, and write a note on the sandbox, only then.selfWakeis true when the version the session runs setsruntime.selfWake: offerschedule_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
traceparenton your gate calls, and asTRACEPARENTto processes you start. - The Runtime never ingests a harness's spans; send them to your own collector.
- Send it as
limits(HarnessLimitsSchema): the Tenant's Runtime 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 withlimit_exceeded:recordEntryBytes: one entry of the record, as JSON: aneffect.outcomevalue, 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-largeorhttp.too-large), and the tool bodies it reads.harnessStateBytes: your state (section 7), as JSON (default 256 KiB).chunkBytes: onedefinition.filerange,artifact.readanswer orartifact.writechunk (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 nomodel.id, as the segment started:{provider, id, inputs, contextWindow, maxOutputTokens, reasoning?}.inputsis what it reads (textalways, andimage,pdf,office,audio,videoas its catalog and the operator say), and the window and output limit are in tokens. The fixture model isprovider: "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
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 answeredcompletedwith atool.deniedfailure, and a call held for a person is answeredpending. It answers:{status: "execute"}: run it. For a tool call it may carrytraceparent, the call's span context: pass it asTRACEPARENTto 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 withturn.waiting.
effect.outcome {runId, effectId, value}, or{..., error}when you could not learn what happened. It answerscompletedwith the recorded outcome. A tool call whose fate is unknown fails to the model; a model call becomesuncertain. Avaluelarger thanlimits.recordEntryBytesas JSON is refused withlimit_exceeded, and nothing is recorded: core shapes no result, so store the rest withartifact.writeand 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
invokingis a re-send. Core counts it, up to the Runtime limittoolAttempts(3 by default), and answersexecute, orcompletedwith atool.interruptedfailure. - A
sandboxorharnesscall that a crash interrupted getstool.interruptedat once.
What an intent carries:
- The effect id is
<turnId>:<segment>:<kind>:<identity>(effectIdOf).identitymust 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 withinputset to itsModelCall, and send the intent withoutinput. A replay that asks for the same id with another hash iseffect_drift. contextcarries 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) andinvocationId, or notool.completedis written; - a tool effect may name its
stepId.
- a model effect needs
- Kinds:
-
model; -
toolwithtoolName, the name the model called it by (for display only), andtarget, 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 iseffect_drift:targetRuns when Who runs it {kind: "mcp", server, tool}The call's agent declares server, the session discoveredtool(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 toolNameis one ofbash,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, neverexecute.{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 isread_onlyor an agent used as a tool's (memory.forbidden) is a failure with text for the model.Each refusal is answered
completedwith a failure and never runs: an MCP tool the session did not discover istool.unknown, one the turn disabledtool.disabled, and a call with no target, or an HTTP tool or sandbox the session lacks,tool.unavailable. Core runs no tool itself butschedule_wakeandmemory:tool_searchover deferred tools is yours (ours is aharnesscall), 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
serverandtoolare the MCP server and its raw tool name, ortoolan HTTP tool's declared name. The name you gave the model never matches. -
delegationandagentbelong 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 atGET /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 (toolNamestays 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
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}:artifactis{artifactId, kind, name, version, contentType, size, sha256, tenantWide}, forversion(default the latest);base64is the bytes fromoffset(default 0), at mostlength;eofsays no bytes are left past this chunk.
lengthdefaults toturn.start.limits.chunkBytes, and is held to it.length: 0asks for the metadata alone.- An
offsetpast the end isinvalid; one at the end answers no bytes andeof: 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 islimit_exceeded. Abeginwithbase64sends the whole body at once and commits it. - Offsets. Each chunk's
offsetmust bereceived. A repeat of the last chunk, whose answer you lost, is acknowledged and not taken twice; any other offset isinvalid. - Limits.
maxBytesis 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 declaredsizepast it, or bytes past it, arelimit_exceeded, withlimitBytesin the message. The totals are checked again at commit. - Idempotency.
keymakes the write idempotent within the session: abeginwith 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.
effectIdnames a tool effect of the run's turn, journaled first. The artifact'sartifact.created(orartifact.version.created) event then carries that call'scallId, which core reads from its journal. Any other effect isinvalid. - Ends. An upload your run leaves open ends with the run, and nothing of it is stored.
abortends one now, and answersabortedfor one already gone.
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
sha256isinvalid, and the upload ends. - Limits.
maxEntriesislimits.folderEntries,maxFileBytesthe per-file limit andmaxBytesthe most the files may hold together; past one, thefilerequest islimit_exceeded. At commit, the version's new content (after dedupe) must fit the Tenant's and the Agent's totals: past one the answer isrefusedwithreasontenant_totalorenvironment_total(the Agent's), and nothing is stored. - Unchanged. A version whose files are the folder's latest is not added:
writtenwithcreated: falseandunchanged: truenames that version. - Our engine's outputs folder writes one at each completed turn's end, keyed
outputs:<turnId>, and claimsartifact.export.skipped({name, reason, message, limit?, path?}) orartifact.export.failed({name, message}) witheventwhen 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
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>andIdempotency-Key: <effect id>on every call. The gate hands a tool the same key:Idempotency-Keyto an HTTP tool, and_meta["nylorun/idempotency-key"]on an MCPtools/call. A tool that must act once checks it. - A tool call must be journaled first (
effect.intentansweredexecute), or the gate refuses it:400 key_requiredor409 effect_not_journaled. - Gate errors are
{error: {code, message}}withGATE_ERROR_CODES.409 run_stalemeans the run is over.
The calls:
- Model.
POST /nylorun/v1/model-callswithModelCallBodySchema:{effectId, invocationId, call: ModelCall}. AModelCallcarries{prompt, tools, model?, executionId}, whereexecutionIdis the session id.- A media part's
referencenames 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, pastlimits.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}: aModelCandidate(outputblocks,finishReason,usage) or a failure{kind: "failed", code, message, retryable}. - The gate meters every call: one
model_usagerow per effect id, readable with itseffectIdatGET /v1/sessions/{id}/calls/model. POST /nylorun/v1/model-calls/{key}/cancelaborts a call.
- A media part's
- MCP.
mcp/connect,mcp/list,tool-calls(ToolCallBodySchema) andmcp/closename a server the agent declares, never a URL:server: {agentId?, serverName}. Atool-callsbody is{server, effectId, name, arguments}, withnamethe 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}/cancelaborts either. The gate replaces the credential values it sent that an answer echoes with[redacted], and refuses an answer pastlimits.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
A tool's answer can be far larger than one record entry. Fitting it is the harness's job.
- What must fit. Each
effect.outcomevalue, and each transcript entry (section 6), is at mostlimits.recordEntryBytesas JSON. A step's results are onetool-resultsentry, so they share it. - Spill the rest. Store what does not fit as file artifacts with
artifact.write(section 4b), with the call'seffectId, soartifact.creatednames 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,audioor blobresourcepart is base64 in the answer. Store it, and send an image to the model as a file only whenturn.start.modelreads images. - Our shaping is reusable.
@nylorun/harnessexports what our engine does:shapeToolOutcome(value, ctx): one outcome, shaped to a cap;StepBudget: one step's budget,stepBudgetBytes(256 KiB) held underrecordEntryByteslessentryEnvelopeBytes(4 KiB). Each MCP or HTTP result gets at mostresultInlineBytes(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_CONFIGandToolResultConfig.
- 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, withdroppedsaying why. A result that still cannot fit fails astool.result-too-large.
6 Transcript
The session's transcript is shared by every turn. A segment reads it once and reports its edits
as TranscriptUpdates, {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 amessagesinput;candidate: the model's answer, bystepId;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 nomessage.assistantfor it; - mark the edit that replaced the history
kind: "compaction", withcompaction: {trigger, tokensBefore, tokensAfter, summaryEffectIds}(CompactionFactsSchema;transcriptUpdates(before, after, recordEntryBytes, compaction)puts it on the edit's first event).summaryEffectIdsare 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 onecontext.compactedwith your facts and the last call's step and timing.
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.
- A custom harness's state is at most
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 oneapprovalinteraction per call, whose id is the effect id. An MCP call's interaction names itsserverandtool(Approve <server> / <tool>?), andtoolNamestays the model's name. Once a person answers, a new segment replays: an approved call's intent answersexecute, a denied one'scompletedwithtool.denied. A harness needs nothing more for approvals.
The answer is {cursor}: the transcript's new cursor, for your cache.
8 Cancel and abort
- Core sends
cancel {runId, reason, message?}as a message, withreasonone ofcancel,shutdown,deadlineandownership.lost. - On
cancelanddeadline: stop, cancel the gate calls you started (theircancelroutes), and reportturn.failedwith statuscancelled. - On
shutdownorownership.lost: sendlease.release. Another owner replays the segment from its journal. {t: "abort", id}cancels one request in flight.
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:
POST /nylorun/harness/v1/host/joinwith{sandboxId, podUid, joinToken, volumeId?}(HostJoinRequestSchema). KeepvolumeIdon the volume (/harness/volume-id), so a volume recreated empty is told apart.- The answer (
HostJoinAnswerSchema) is{hostToken, egressToken, sandboxId, epoch, expiresAt, caCertificate}. On a new disk, a join also carries the Agent'ssetup: 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: truemeans the disk starts from a disk version, so there is no setup to run. - Connect with the host token as the WebSocket bearer. Say
hellowith the Agent's harness id (its manifest'sharness.id); any other is refused. - Every 5 minutes,
POST .../host/renewwith 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)
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.readcarries a sandbox tool call or a disk operation only: core lists and reads no workspace files of its own.
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
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.
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.