Write a harness
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.
| Template | Framework, as pinned | Model client | Provider record it needs |
|---|---|---|---|
harness-agent-framework | Microsoft Agent Framework: agent-framework-core 1.21.0, agent-framework-openai 1.16.0 | its OpenAI chat completion client (its Responses client, with store off, for a record that takes only the Responses API) | one of openai-chat or openai-responses: OpenAI, Azure OpenAI, the OpenAI-compatible presets |
harness-adk | Google ADK: google-adk 2.11.0, without extras | ADK's Gemini model on google-genai, Google's generateContent API, the key as x-goog-api-key | a record of google-generate-content with the Gemini API's paths: the google preset's |
harness-strands | AWS Strands: strands-agents[anthropic,openai] 1.59.0 | Strands' Anthropic model (its OpenAI model for a record of chat completions) | one of anthropic-messages or openai-chat |
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 inrun(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 theOz0-JobandOz0-Agentheaders, 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 thebyo-agenttemplate's;tests/test_harness.pyandtests/fake_model.py, the same in every harness template.pyproject.tomlwith the framework pinned exactly, anduv.lockwith 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 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>:
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, default-deny and 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.
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 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, 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.
A harness is a command in the plugin's manifest:
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.
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.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:
- 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. - 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.
- The harness sends the request's job and agent as the headers
Oz0-JobandOz0-Agenton its model calls. - A harness with
permissions: truein the manifest asks before each tool call. When the request haspermissionsset, 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. - When the request has
ask_userset, the agent may ask people questions: the harness sends each as a permission request for the tooloz.ask_userwith the input{"question": "...", "choices": [...]}. An answer that allows it carries{"answer": "..."}as its input; a refusal's reason says why there is no answer. - 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).
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.