---
title: "Write a harness"
description: "Wrap a framework or implement the harness protocol."
canonical_url: "https://docs.oz0.ai/build/parts/harnesses/custom-harnesses"
---
# Write a harness

> Wrap a framework or implement the harness protocol.

For: plugin authors.

Wrap a framework or implement the harness protocol.

## Frameworks as harnesses

Three `plugin new` templates run an agent written for one of the cloud vendors' agent frameworks as a harness, in Python: each job is one session of the framework's agent, in its own process on the node. The framework keeps its own loop; the platform gives the session what every harness session gets: the live stream and the trace, model calls through the gateway, metered under the job, and every tool call asked first.

<table>
<thead>
  <tr>
    <th>
      Template
    </th>
    
    <th>
      Framework, as pinned
    </th>
    
    <th>
      Model client
    </th>
    
    <th>
      Provider record it needs
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        harness-agent-framework
      </code>
    </td>
    
    <td>
      Microsoft Agent Framework: <code>
        agent-framework-core
      </code>
      
       1.21.0, <code>
        agent-framework-openai
      </code>
      
       1.16.0
    </td>
    
    <td>
      its OpenAI chat completion client (its Responses client, with <code>
        store
      </code>
      
       off, for a record that takes only the Responses API)
    </td>
    
    <td>
      one of <code>
        openai-chat
      </code>
      
       or <code>
        openai-responses
      </code>
      
      : OpenAI, Azure OpenAI, the OpenAI-compatible presets
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        harness-adk
      </code>
    </td>
    
    <td>
      Google ADK: <code>
        google-adk
      </code>
      
       2.11.0, without extras
    </td>
    
    <td>
      ADK's Gemini model on google-genai, Google's <code>
        generateContent
      </code>
      
       API, the key as <code>
        x-goog-api-key
      </code>
    </td>
    
    <td>
      a record of <code>
        google-generate-content
      </code>
      
       with the Gemini API's paths: the <code>
        google
      </code>
      
       preset's
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        harness-strands
      </code>
    </td>
    
    <td>
      AWS Strands: <code>
        strands-agents[anthropic,openai]
      </code>
      
       1.59.0
    </td>
    
    <td>
      Strands' Anthropic model (its OpenAI model for a record of chat completions)
    </td>
    
    <td>
      one of <code>
        anthropic-messages
      </code>
      
       or <code>
        openai-chat
      </code>
    </td>
  </tr>
</tbody>
</table>

```bash [Terminal]
oz0 plugin new acme-desk --template harness-strands   # or harness-agent-framework, harness-adk
cd acme-desk
uv sync && uv run pytest     # the locked dependencies, and the harness over a pipe against a fake model API
oz0 dev                      # then: oz0 run acme-desk "I was charged twice for order 1234"
```

Each template writes a small agent with one tool that reads (`lookup_order`) and one that writes and waits for a person (`refund_order`, under `harness.requires_approval`):

- `src/<module>/agent.py`: the framework's agent and its tools, built in `run(session)`. This is the file you change.
- `src/<module>/adapter.py`: the framework on the protocol: the model client at the session's address on the node's LLM proxy with the session's own key and the `Oz0-Job` and `Oz0-Agent` headers, the framework's own hook (Agent Framework's function middleware, an ADK plugin, a Strands hook) that asks the job before each tool call, the calls and the text in the live stream, and the conversation kept in the session's state directory.
- `src/<module>/harness.py`: the protocol, the same file as the `byo-agent` template's; `tests/test_harness.py` and `tests/fake_model.py`, the same in every harness template.
- `pyproject.toml` with the framework pinned exactly, and `uv.lock` with a hash for every package: nodes install exactly those.

The repository has an example plugin made from each: `examples/agent-framework-desk`, `examples/adk-desk` and `examples/strands-desk`. The [interop matrix](https://docs.oz0.ai/build/apps/interop#agent-frameworks-as-harnesses) says what was run with each framework's pinned release.

Model calls go through the gateway only. The session gets the node's LLM proxy and a key of its own, never a provider's key, so budgets, quotas and kill switches apply to every call. Never give the framework a client of your own with a provider's address or key.

LiteLLM is never a dependency: not ADK's `LiteLlm` model, not Strands' LiteLLM model, not an extra that installs it (`plugin lint` refuses a plugin whose lock or imports name it, as rule OZ-S012). Strands' default model calls Amazon Bedrock's Converse API, which the gateway does not meter and refuses: the template uses Strands' Anthropic and OpenAI models instead.

ADK needs a Gemini API record. ADK's Gemini model speaks only Google's `generateContent` API, at the Gemini API's paths. Add the shipped preset and its key once, and give the agent a `google/<model>`:

```bash [Terminal]
oz0 provider set google --preset google
oz0 secret set GEMINI_API_KEY
```

Formats, not provider names. Each template's harness lists the model APIs its adapter speaks under `formats:` (`[openai-chat, openai-responses]`, `[google-generate-content]`, `[anthropic-messages, openai-chat]`), and the adapter calls in the one the session request names. The agent asks for an alias, as the Strands template does (`model: {use: default, needs: [tools]}`), and the edge resolves it only to a provider record that takes one of the formats; or it names its model with `model.primary`, as the Agent Framework and ADK templates do, since a tenant's `default` often maps to a record of another API (`oz0 dev` and `plugin test` map it to Anthropic's).

The tools are the harness's own. They run in the session's process. Each call is asked as `<harness>.<tool>`, such as `acme-desk.refund_order`, so [hooks](https://docs.oz0.ai/build/parts/hooks), default-deny and [approvals](https://docs.oz0.ai/agents/approvals) decide it as for any harness's tool: list each tool under `harness.tools`, and the ones that change something under `harness.requires_approval`. The tenant's MCP tools are not offered to a harness's agent; for an agent that calls them step by step, use a [Pydantic AI or OpenAI Agents SDK agent](https://docs.oz0.ai/build/parts/agents).

Durable as one step. Each framework's agent is governed and metered, but its session is one durable step of its job, not one step per model call or tool call. A [lost node](https://docs.oz0.ai/build/parts/harnesses/recovery#when-a-node-is-lost) starts the session again from scratch on another node. The platform cannot know which of your tools only read, so `plugin lint` says `restart: ask` for these agents, and each restart waits for a person.

A session whose model call a quota refused is different: it waits for the [budget decision](https://docs.oz0.ai/spend/budgets-and-quotas), and the job starts it again with its session ID, so it goes on from the conversation it kept.

## Write a harness for another tool

To run code you already have (a script, a LangGraph graph, a CrewAI crew), start from the `byo-agent` template: its harness speaks this protocol for you. See [Bring your own agent](https://docs.oz0.ai/build/parts/bring-your-own-agent).

A harness is a command in the plugin's manifest:

```yaml [oz0-plugin.yaml]
harnesses:
  - name: my-harness
    command: ["python", "-m", "my_harness"]
    permissions: true                  # it asks before each tool call
    tools: [read_file, run_tests]      # what a session gets when its agent lists no harness.tools
```

A harness that lists the model APIs it speaks under `formats:` (such as `formats: [anthropic-messages]`) takes agents that ask for an alias: the edge resolves the alias only to a provider record that takes one of them, and the session request names the record's model as `<provider>/<model>` with its format in `formats`. Check the format, never the provider's name: the Claude harness runs a gateway of your own or Bedrock's Messages API as it runs Anthropic's, and refuses a record that takes only chat completions.

A harness with `permissions: true` and no `tools:` refuses every call of an agent that lists no `harness.tools`; `plugin lint` warns about it, and the node logs it when it loads the plugin.

<warning>

Upgrading. Before the node runtime refused undeclared tools, a harness manifest had no `tools:`. If a tenant installed `harness-claude` from before that change, update it (`plugin install` again) or list each agent's tools under `harness.tools`; otherwise every tool call of those sessions is refused and the jobs finish without having done anything.

</warning>

Agents use it with `runtime: harness:my-harness`. The node starts the command once per session and talks to it in JSON lines over stdin and stdout:

1. The runtime writes one session request to stdin: the job and agent, the model, the instructions, the input, the working and state directories, the turn limit and the agent's `harness:` options.
2. The harness writes session events to stdout, one JSON object per line: text, tool calls and tool results, and a result last. Logs go to stderr.
3. The harness sends the request's job and agent as the headers `Oz0-Job` and `Oz0-Agent` on its model calls.
4. A harness with `permissions: true` in the manifest asks before each tool call. When the request has `permissions` set, the harness writes a permission request event (`id`, `tool`, `input`) and waits: the runtime answers on stdin with a permission answer (`id`, `allow`, `input`, `reason`), and the call runs only if it is allowed, with the answer's input when it has one. The runtime closes stdin when the session's result is out. Calls to tools the agent does not declare are refused without asking the job.
5. When the request has `ask_user` set, the agent may ask people questions: the harness sends each as a permission request for the tool `oz.ask_user` with the input `{"question": "...", "choices": [...]}`. An answer that allows it carries `{"answer": "..."}` as its input; a refusal's reason says why there is no answer.
6. Cancelling a job sends SIGTERM to the session's process group, then SIGKILL after ten seconds.

On Linux the three streams are Unix sockets, not pipes, since the tools a session runs can open a pipe again through `/proc`. Any language's standard streams work on them as they do on pipes; a program that opens `/dev/stdin` or `/dev/stdout` by name gets an error, so read and write the descriptors. Keep the channel yours: start the session's tools with standard streams of their own, so that they inherit none of these, and use a socket pair, not a pipe, for anything your harness keeps between its own parts ([what this boundary holds](https://docs.oz0.ai/concepts/threat-model/harness-tools#a-sessions-tools-and-its-harness)).

Under a sandbox a harness starts in the session's work folder, which anything may have written to. So when a command names Python itself, the runtime adds `-P` and `-s`, and neither that folder nor the user site in the session's home is on its import path: install your package into the plugin's environment, and use Python 3.11 or later, which `-P` needs. A script keeps its own folder on the path.

Start a harness through its interpreter or a console script: a command that goes through a shell or a launcher such as `uv run` gets none of this, and a launcher may read what it finds in the work folder.

The messages are defined in `proto/oz0/harness/v1/harness.proto` and written in their proto3 JSON form. A harness can be written in any language and tested with a pipe; `node/runtime/tests/fake_harness.py` in the repository is a complete example in about 50 lines, permission requests included.


## Sitemap

See the full [sitemap](https://docs.oz0.ai/sitemap.md) for all pages.
