@agent-orc/harness-protocol
v2.0.0
Published
Orca Harness Protocol v1 server library: write the agent, not the wire
Readme
@agent-orc/harness-protocol
A server library for Orca Harness Protocol v1.
A harness is your own agent-worker. Orca's runtime (agent-runtime) boots
it per session and drives it over exactly the contract the platform's built-in
sidecar answers, so it drops into the same socket: same /health, /healthz,
/run, /state, and /models.
This library is that contract. It handles routing, NDJSON framing, event ordering, cancellation, request limits, state transfer, and connecting the session's MCP tools. You write the agent loop.
Terminology, because it is easy to get backwards: the agent is a profile you configure in Orca, and one harness serves many of them. The harness is the worker that runs them. There is only one runtime, and it is Orca's.
It is not a harness framework and not a set of agent-SDK adapters.
There is no piAdapter(). You import your SDK directly and map its events onto
ctx.emit, which is about fifteen lines and leaves you in control of the loop.
Your SDK code + @agent-orc/harness-protocol = a conformant harnessInstall
npm install @agent-orc/harness-protocolThe shape
import { createHarnessServer, type RunContext } from '@agent-orc/harness-protocol'
const harness = createHarnessServer({
// What this worker calls itself, reported as `runtime` in /health, exactly
// as the platform sidecar reports MODE=claude there. Not an agent name:
// agents are Orca profiles, and this one worker serves all of them.
harness: 'ledger-harness',
// Optional. Surfaces at GET /models, which is what fills the model picker
// in the Orca dashboard.
models: ['anthropic:claude-sonnet-4-5', 'openai:gpt-4o'],
async run(ctx: RunContext) {
ctx.emit.progress('reading the ledger')
ctx.emit.assistant('I found three unmatched lines.')
return 'Reconciled 3 of 3.'
},
})
await harness.listen({ port: Number(process.env.PORT ?? 7099) })That is a conformant harness. Everything below is optional detail.
With an agent SDK
Pi, as a worked example. The pattern is the same for any SDK: create the
session, map the generic tools into whatever your SDK calls a tool, forward
text and usage to ctx.emit, and return the answer.
import { createHarnessServer, type RunContext } from '@agent-orc/harness-protocol'
import {
createAgentSession,
ModelRuntime,
SessionManager,
type ToolDefinition,
} from '@earendil-works/pi-coding-agent'
const models = await ModelRuntime.create()
const harness = createHarnessServer({
harness: 'ledger-harness',
async run(ctx: RunContext) {
// Already split, and leniently: an un-namespaced id leaves provider
// undefined, which is how claude and codex profiles are written.
const model = models.getModel(ctx.model.provider ?? 'anthropic', ctx.model.modelId)
if (!model) throw new Error(`unknown model: ${ctx.model.id}`)
// ctx.tools is generic. This is where a Pi harness makes it Pi's. Pi types
// parameters as a TypeBox TSchema (a JSON Schema object at runtime) and
// wants a content array back rather than a bare value.
const customTools: ToolDefinition[] = ctx.tools.map((tool) => ({
name: tool.name,
label: tool.name,
description: tool.description,
parameters: tool.inputSchema as unknown as ToolDefinition['parameters'],
async execute(toolCallId: string, params: unknown) {
const output = await tool.call(params, { toolCallId })
return {
content: [{ type: 'text' as const, text: String(output) }],
details: undefined,
}
},
}))
const { session } = await createAgentSession({
model,
modelRuntime: models,
sessionManager: SessionManager.inMemory(),
tools: customTools.map((t) => t.name),
customTools,
})
ctx.signal.addEventListener('abort', () => void session.abort(), { once: true })
session.subscribe((event) => {
if (event.type === 'message_update' && event.assistantMessageEvent?.type === 'text_delta') {
ctx.emit.assistant(event.assistantMessageEvent.delta)
}
if (event.type === 'message_end' && event.message?.usage) {
ctx.emit.usage({
inputTokens: event.message.usage.input ?? 0,
outputTokens: event.message.usage.output ?? 0,
})
}
})
await session.prompt(ctx.subtask.prompt)
return {
message: finalText(session.agent.state.messages),
state: { messages: session.agent.state.messages },
}
},
})
await harness.listen({ port: Number(process.env.PORT ?? 7099) })orca harness init --sdk pi writes a working version of this.
What you get
ctx
| Field | What it is |
|---|---|
| ctx.subtask.prompt | The work to do. |
| ctx.model | profile.model split into { id, provider?, modelId, supported }. |
| ctx.profile | name, runtime, model, systemPrompt, tools, template. |
| ctx.tools | Platform and user MCP tools, already connected. Always an array. |
| ctx.skills | Resolved skill documents. Use these; never read the host filesystem. |
| ctx.state | Whatever the previous run returned as state, or undefined. |
| ctx.signal | Aborts when the platform cancels. Thread it into model and tool calls. |
| ctx.emit | progress, assistant, usage, session, toolCall, toolResult. |
| ctx.request | The raw envelope, for anything this version does not surface. |
ctx.tools
Every MCP server in the envelope is connected before run is called, and their
catalogs are flattened into one list. Names are prefixed mcp__<server>__ so
two servers exporting search cannot collide.
type HarnessTool = {
name: string
description: string
inputSchema: Record<string, unknown> // JSON Schema
call(input?: unknown, opts?: { toolCallId?: string }): Promise<unknown>
}call emits a tool_call before and a tool_result after, so tool use shows
up in the Orca transcript without you reporting it. It resolves with structured
output when the tool provides it, otherwise the joined text.
Pass tools: false to skip connecting, if your harness brings its own.
Returning
return 'the answer' // stateless
return { message: 'the answer', state: { ... } } // statefulThe state value comes back as ctx.state on the next run for that session,
including after Orca has moved the session to a different replica. It must be
JSON-serializable and under 8 MB.
Models
models is declarative. It answers GET /models in the shape the conductor
decodes, which is how a tenant's models reach the dashboard picker:
{ "runtimes": { "ledger-harness": ["anthropic:claude-sonnet-4-5"] }, "errors": {} }Declare nothing and the route does not exist, which is the sidecar's own
convention: the conductor drops an upstream it cannot reach, where an empty
200 would be taken as authoritative and blank the picker.
The library never refuses a run over it. Orca does not validate the model on a
custom-runtime profile either, so rejecting one here would make your harness
stricter than the worker it replaces and fail runs the profile was legal for.
ctx.model.supported tells you; what to do is yours:
if (!ctx.model.supported) ctx.emit.progress(`${ctx.model.id} is untested here`)What the library guarantees
These are the parts of the spec that are easy to get wrong by hand, and the
reason this exists rather than a copy-pasted server.ts:
- Exactly one terminal event per run, always last. Anything emitted after it is dropped rather than corrupting the stream.
- A client disconnect aborts
ctx.signaland does not take the process down. A run that finishes normally does not abort, which is the bug most hand-written harnesses ship. - A malformed envelope is a
400before the stream opens, never an error event on a200. GET /state/{id}answers404when nothing is stored. That is the correct stateless answer, not a failure.- Request bodies and state bundles are capped rather than buffered without
limit, and
/healthstays under the 64 KB the platform reads. - MCP servers are closed when the run ends, including when connecting one of them failed part-way through.
/healthzanswers as well as/health, and/modelsappears only when you declare models, both matching the platform sidecar.
Security
The platform never sends its own credentials to a harness. The only authority a run carries is the session-scoped MCP endpoint in its envelope. Model provider credentials belong to you, and reach the harness through its own environment.
Tenant-declared MCP server URLs get a scheme and literal-address check before
connect: loopback, RFC1918, link-local, and the cloud metadata address are
refused. This is defense in depth, not the authority, because it runs before
DNS resolution. Set HARNESS_ALLOW_HOSTS=localhost for local development.
Conformance
node index.ts &
./conformance.sh http://localhost:7099The checker lives in the platform repo at examples/brains/conformance.sh. It
exercises the wire behaviors a unit test cannot see. This library passes all 15
checks with no skips.
Versioning
The package version and the wire version are independent. @agent-orc/harness-protocol@2
may still speak orca-harness/v1. Do not infer protocol compatibility from an
npm version.
