Unified Harness Protocol¶
The Unified Harness Protocol (UHP) is an HTTP contract for handing a task to a complete agent harness and getting finished work back. A UHP server advertises the harnesses it runs, accepts a task, streams progress, and returns text and files.
SuperQode speaks UHP as a client. Once connected, a harness on a UHP server is registered as the uhp route and runs through the same session, event, and evidence model as a local harness.
SuperQode targets UHP version 2026-08-11 and sends that version on every request.
Where UHP sits¶
UHP is a transport, in the same position as ACP. It is not a replacement for the SuperQode Harness Protocol, which stays the internal control plane.
| Layer | What it does |
|---|---|
| Harness Protocol v1 | SuperQode's session, event, and evidence contract |
| UHP adapter | Translates one UHP server into that contract |
| UHP | The HTTP wire format between SuperQode and the server |
| Harness | Codex, Claude Code, or whatever else the server runs |
The difference from every other connection method is that a UHP server is a remote catalog. An ACP agent is a local process SuperQode starts, and a BYOK provider is an entry in a static list. With UHP the address comes first, the harness list arrives over the network, and only then is there something to select.
Connect¶
superqode connect uhp --base-url https://your-server
SuperQode reads the server's discovery document, lists the harnesses it advertises, and selects one when the server offers only one.
Connected: https://your-server
Protocol: UHP 2026-08-11 (full)
Auth: bearer key
Harnesses (2):
* chrn_codex Codex Codex CLI ยท gpt-5
chrn_claude Claude Code Claude Code ยท server default
Pick a specific harness when the server runs several:
superqode connect uhp --harness chrn_claude
An unknown harness id is an error, not a silent success: the command exits non-zero and saves nothing.
From the TUI, :connect uhp opens a screen that takes the server address, lists what it advertises, and switches to the harness you pick. It prefills HarnessRouter Community Edition's Docker address, so a local container needs no typing. The same screen is on :connect protocols as :connect protocol-uhp.
| Option | Description |
|---|---|
--base-url | Server root, with or without a trailing /v1 |
--api-key | Bearer credential for the server |
--harness | Harness id to select |
--max-output-tokens | Cap the token budget for one task |
--save / --no-save | Remember the connection (default: save) |
--json | Emit the catalog, capabilities, and selection as JSON |
Run a task¶
A saved connection with a selected harness makes uhp a selectable harness everywhere SuperQode lists them:
superqode harness run uhp --prompt "review this repository" --stream
superqode harness protocol list # uhp appears once configured
sq hub show uhp
In the TUI it is a normal switcher entry:
:harness switch uhp
Without a selected harness the route reports itself as unavailable and says what to run, rather than appearing ready and failing later.
:connect uhp discovers and saves the server. :harness switch uhp puts that harness on the current session so the next prompt runs there:
:connect uhp http://127.0.0.1:3000/api/harness
:harness switch uhp
Conversation threading works on both routes. The protocol controller persists the response id with the session, and the harness backend writes it to .superqode/uhp/sessions/<session>.json, so a later process continues the same conversation instead of starting a new one.
The server picks the model¶
A harness on a UHP server already has a model. SuperQode does not send one, so that choice stands. Pass --model when you want a different model for a run, and only that explicit flag overrides the server:
superqode harness run uhp --prompt "..." --model "openai/gpt-5"
The model id has to be one the server can route. A server that cannot serve it rejects the task rather than falling back, so the id must match what the server's integrations provide. SUPERQODE_MODEL deliberately does not override a remote harness: it is a local default for SuperQode's own harnesses and knows nothing about what the server can serve.
Capping the token budget¶
Some providers check affordability against the budget a task reserves, not what it uses. A harness that asks for a large budget is then refused outright, even for a one-word prompt, and the error names a number nobody chose:
You requested up to 65536 tokens, but can only afford 1195.
Cap it, and the server asks for less:
superqode connect uhp --max-output-tokens 1000
The cap is saved with the connection and sent on every task. SUPERQODE_UHP_MAX_OUTPUT_TOKENS sets it per shell. Without one, SuperQode sends no budget and the server's own default applies.
The connect screen has the same field beside the address, so a task refused for its budget can be fixed without leaving the terminal interface. Clearing the field removes the cap.
The workspace is the server's¶
The harness runs in its own workspace inside the server, not in your repository. SuperQode does not upload local files, so a prompt like "review this repository" describes whatever the server holds, not your code.
Include the material in the prompt when the harness needs to see your code:
superqode harness run uhp --prompt "Review this module:
$(cat src/example.py)"
UHP defines a file input endpoint. SuperQode does not use it yet, so uploading a workspace is not available on this route.
HarnessRouter Community Edition¶
Community Edition is the reference implementation most people will run, and two details differ from the bare spec root:
- The protocol is served under a prefix, so the base URL is
http://127.0.0.1:3000/api/harness, nothttp://127.0.0.1:3000. - A default Docker install gates the API with the console login cookie rather than a bearer token. Starting the container with
HR_AUTH_DISABLED=1removes the gate for local testing.
superqode connect uhp --base-url http://127.0.0.1:3000/api/harness
Cookie authentication is not stored in the saved connection. Pass a cookie header through UHPClient(headers=...) when driving CE from Python, or run it with the auth gate disabled.
Credentials¶
| Variable | Purpose |
|---|---|
SUPERQODE_UHP_BASE_URL | Server root |
SUPERQODE_UHP_API_KEY | Bearer credential |
SUPERQODE_UHP_HARNESS | Harness id to select |
Settings resolve from command arguments first, then the environment, then the saved connection at ~/.superqode/uhp.json. That file is created with owner-only permissions.
A key supplied through SUPERQODE_UHP_API_KEY is deliberately not copied into the saved connection, so a credential exported per shell stays in that shell. A key passed with --api-key is saved, because there is nowhere else for it to live. Stripping an environment key never discards a different key that was saved earlier.
Use it in Python¶
The client is usable on its own, without the rest of SuperQode:
from superqode.harness import UHPClient
async with UHPClient("https://your-server", api_key=key) as client:
discovery = await client.discover()
print(discovery.default_version, discovery.conformance_class)
print(discovery.supports("cancellation"))
for harness in await client.list_harnesses():
print(harness.id, harness.base, harness.default_model)
response = await client.create_response(
"Summarize this repository",
harness_id="chrn_codex",
)
print(response.output_text)
for citation in response.file_citations:
print(citation.filename, citation.download_url)
Stream instead of waiting:
async for event in client.stream_response("Fix the failing test", harness_id="chrn_codex"):
if event.type == "response.output_text.delta":
print(event.data["delta"], end="")
Continue the same conversation by passing the previous response id:
first = await client.create_response("Read the config", harness_id="chrn_codex")
second = await client.create_response(
"Now change the timeout",
harness_id="chrn_codex",
previous_response_id=first.id,
)
Retry safety¶
Every task submission carries an Idempotency-Key. Retrying a task without one starts a second agent in the same workspace, so the client generates a key per call and accepts an explicit idempotency_key= when you need a retry to reuse the original.
Use it as a harness¶
UHPHarnessProtocolAdapter puts a UHP server behind the standard lifecycle:
from pathlib import Path
from superqode.harness import (
FileHarnessStore,
HarnessCreateRequest,
HarnessProtocolController,
UHPHarnessProtocolAdapter,
)
adapter = UHPHarnessProtocolAdapter(
"https://your-server",
harness_id="chrn_codex",
api_key=key,
)
controller = HarnessProtocolController(
[adapter],
store=FileHarnessStore(".superqode/harness-protocol"),
)
session = await controller.create(
HarnessCreateRequest(harness_id="uhp", model="gpt-5", working_directory=Path.cwd())
)
async for event in controller.send(session, "Review the current diff"):
print(event.type, event.data)
Event mapping¶
| UHP stream event | Canonical event |
|---|---|
response.output_text.delta | message.delta |
response.reasoning_summary_text.delta | model.thinking |
response.output_item.added (function call) | tool.requested |
response.function_call_arguments.done | tool.requested |
response.output_item.done (call output) | tool.completed |
error | validation.completed with status error |
container_file_citation annotation | artifact.created |
response.completed | model.completed, then message.created |
run.started, run.completed, and run.failed come from the controller, as they do for every adapter.
Capabilities¶
| Capability | Supported | Reason |
|---|---|---|
| Streaming | Yes | Server-Sent Events |
| Resume | Yes | previous_response_id, persisted with the session |
| Cancel | Yes | Response cancel, falling back to session cancel |
| Tools | Yes | Function calls appear in the output |
| Usage | Yes | Reported when the server sends it |
| Steer | No | UHP has no mid-turn steering operation |
| Checkpoint | No | UHP has no checkpoint operation |
| Native export | No | Server-private state is not exportable |
Unsupported operations raise HarnessCapabilityError rather than silently doing nothing.
usage is optional in the spec. A server that reports none produces a model.completed event with no usage key, rather than a fabricated zero.
Resume across a restart¶
UHP threads a conversation with previous_response_id instead of a long-lived connection. The adapter hands the response id and the server's session id back to the controller after each turn, which persists them with the session. A new process therefore continues the same conversation rather than starting a fresh one. When only the server session id survives, the adapter recovers the latest response id from the server's session turns.
Dropped streams¶
A dropped connection does not stop the task; the work continues on the server. When a stream ends without a terminal event, the adapter re-reads the response with GET /v1/responses/{id}, which is the source of truth after a disconnect. If the task is still running and SuperQode is giving up, the adapter cancels it explicitly rather than leaving a harness editing files unattended.
Errors¶
Failures raise a typed exception carrying the server's own error code:
| Exception | UHP error.type |
|---|---|
UHPInvalidRequestError | invalid_request_error |
UHPAuthenticationError | authentication_error |
UHPPermissionError | permission_error |
UHPRateLimitError | rate_limit_error |
UHPHarnessError | harness_error |
UHPServerError | server_error |
Two details are worth knowing when reading stream failures.
An error stream event does not end the task. The spec requires it to be followed by a terminal event, so the client yields it and lets the terminal response decide the outcome. The error is raised only when a stream ends without ever reaching a terminal event, which is a malformed stream.
The error event also reuses type for the event name, so it carries code, message, and param but no error class. SuperQode preserves the code and message on the base UHPError in that case, and resolves the specific class only when the payload nests a full error object.
Limits¶
- The client targets one protocol version.
connect uhpwarns when a server does not list2026-08-11among its versions. - File artifacts are reported as citations with a download URL. SuperQode does not copy them into the workspace automatically; use
UHPClient.download_filefor that. - Harness configuration lives on the server. A HarnessSpec does not drive a remote UHP harness, so tool policy, sandbox, approvals, the model, and the workspace are whatever the server was configured with. The adapter reports
policy_owner: serverin its descriptor metadata so this is visible rather than assumed. - Local files do not reach the harness. SuperQode does not implement UHP file upload, so the harness sees the server's workspace only.
superqode connect uhpverifies the catalog. It does not verify that the credential is sufficient to run a task, because listing and running can be authorized separately.- Cookie-gated servers are supported only by passing the cookie header explicitly from Python.
See also¶
- Harness Protocol for the lifecycle a UHP session is normalized into
- ACP for the other transport that connects an externally owned agent loop
- Connection Methods for how transports relate to the
:connectquestion