Skip to main content

H3 Harness SDK for Python

Python SDK for building H3-compliant agent harnesses.

Install

pip install h3-harness-sdk

Install fallback (source / git)

If a release isn't published to PyPI yet (or you want the latest unreleased changes), install directly from the repository:

# From git
pip install git+https://github.com/get-h3/sdk-python.git

# Editable source install (development)
git clone https://github.com/get-h3/sdk-python.git
cd sdk-python
pip install -e .

From a clean system (no pip preinstalled — e.g. a minimal Ubuntu container): bootstrap a venv first, then install into it:

python3 -m venv .venv
.venv/bin/pip install -e ".[dev]"
# or the one-command equivalent: make install

make install creates .venv (via python3 -m venv), upgrades pip inside it, and installs the package with dev extras — no system pip required.

Quickstart

from datetime import datetime, timezone

from h3_harness import (
    BaseHarness,
    Decision,
    DecisionType,
    End,
    TextResponse,
    create_router,
)
from fastapi import FastAPI


class MyHarness(BaseHarness):
    def __init__(self):
        # Track sessions so cancel/session lookups 404 on unknown ids
        # (battery: test_5_9b cancel_unknown_session, test_5_10 session_not_found).
        self._sessions: dict[str, dict] = {}

    async def on_process(self, req):
        # Echo conversation history from context (battery: history preserved).
        history = list(req.context.history)
        # Streaming: "do not finish" in message -> unfinished text.
        streaming = "do not finish" in req.message.content
        finished = not streaming
        self._sessions[req.session_id] = {
            "started_at": datetime.now(timezone.utc).isoformat(),
            "turn_count": (
                self._sessions.get(req.session_id, {}).get("turn_count", 0) + 1
            ),
        }
        return Decision(
            decision=DecisionType.TEXT,
            text=TextResponse(
                content=f"Echo: {req.message.content}",
                finished=finished,
            ),
            history=history,
        )

    async def on_result(self, req):
        return Decision(decision=DecisionType.END, end=End(reason="task_complete"))

    def get_session_info(self, session_id: str) -> dict | None:
        return self._sessions.get(session_id)

    async def on_session_terminate(self, session_id: str) -> None:
        # DELETE /v1/sessions/{id} -> forget the session so a later GET 404s.
        self._sessions.pop(session_id, None)


app = FastAPI()
app.include_router(create_router(MyHarness()))
# Run with: uvicorn my_harness:app --port 9191

Trying it out: with the server running, send a minimal request. The payload must include identity, context.config, and context.session_state (plus message and session_id) or the router rejects it with a 422:

curl -X POST http://localhost:9191/v1/process \
  -H 'Content-Type: application/json' \
  -d '{
    "session_id": "sess-001",
    "identity": {"chat_id": "chat-1", "platform": "cli"},
    "message": {"content": "Hello, harness!"},
    "context": {"config": {}, "session_state": {}, "history": []}
  }'

→ {"decision":"text","decision_id":"...","history":[],"text":{"content":"Echo: Hello, harness!","finished":true}}

Health is at /v1/health (not /health): curl http://localhost:9191/v1/health.

Session lifecycle

DELETE /v1/sessions/{id} invokes on_session_terminate(session_id) on the harness (unknown ids 404 at the router before the harness is involved). The base implementation is a no-op, so without an override a terminated session stays fully retrievable: the DELETE returns {"terminated": true}, but a later GET /v1/sessions/{id} still returns 200 with the full session. The fix is a 3-line cleanup — drop the session from your tracking dict, exactly as in the quickstart above:

async def on_session_terminate(self, session_id: str) -> None:
    # DELETE /v1/sessions/{id} -> forget the session so a later GET 404s.
    self._sessions.pop(session_id, None)

Keep this override in any harness that tracks sessions — it keeps harness state consistent with what the wire promises (a terminated session 404s on later GET, like any unknown session).

Testbed

import asyncio

from h3_harness.testbed import MockHermes
from h3_harness.examples.echo import EchoHarness


async def main() -> None:
    mock = MockHermes(EchoHarness())
    decision = await mock.send_message("Hello!")
    assert decision.text.content == "Echo: Hello!"


if __name__ == "__main__":
    asyncio.run(main())

Examples

  • echo.py — Echo harness that mirrors user messages
  • minimal.py — Minimal harness with health endpoint, uvicorn runner
  • langchain_agent.py — LangChain integration: LLM call with text response

Each example exposes a module-level app and a python -m runner. Either works:

# uvicorn against the module-level app (any port)
uvicorn h3_harness.examples.echo:app --port 9191
uvicorn h3_harness.examples.minimal:app --port 8000

# or the built-in runner (echo.py accepts an optional port argument)
python -m h3_harness.examples.echo 9191
python -m h3_harness.examples.minimal

langchain_agent.py additionally requires pip install langchain langchain-openai.

Passing the battery (h3-test compliance)

The gate for any H3 harness is the test battery (test_battery.py from get-h3/shim — 45 tests across 6 categories). Run it against any running harness endpoint:

# The shim is not yet published to PyPI — install from source (get-h3/shim)
pip install git+https://github.com/get-h3/shim
h3-test --endpoint http://localhost:9191   # exit 0 = compliant

The Quickstart harness above implements all four conventions and is fully battery-compliant (45/45). If you modify it, keep the conventions intact — a naive harness that drops them scores 42/45. The four conventions the battery checks (beyond "return a Decision") are:

  1. Echo context.history in every Decision returned from on_process. The battery sends a session with prior history and asserts it flows back through the response (test_2_8_process_preserves_history). Pass it through explicitly:
    history = list(req.context.history)
    return Decision(..., history=history)
    
    This applies to decisions returned from on_process — the ProcessRequest carries the context field. on_result receives a ResultRequest, which has no context field (only decision_id/result/session_id); a decision returned from on_result simply omits history. Echoing req.context.history there raises AttributeError.
  2. Never issue llm_call when context.models is empty. The battery sends context.models: [] and FAILS any harness that returns an llm_call decision (test_5_8_no_models_available — "hallucinated model"). Only return LLM_CALL when the request actually lists models.
  3. Return text.finished=false for "do not finish" prompts. The battery sends "Just start a thought, do not finish it yet." and asserts the response has text.finished == False (test_2_4_process_text_finished_false). Detect streaming/unfinished intent and set finished accordingly.
  4. 404 unknown sessions. The battery cancels a nonexistent session (test_5_9b cancel_unknown_session) and GETs one (test_5_10 session_not_found) and asserts a 404. Track sessions in the harness (get_session_info returning None for unknown ids) — the router turns that into the 404.

The canonical battery-ready template is echo.py — it implements all four conventions and scores 45/45. Use it as the starting point for your own harness.

Error handling

Exceptions raised inside your harness's on_process / on_result are caught by the router and masked as a successful HTTP 200 end decision:

{"decision": "end", "end": {"reason": "error", "summary": "<exception text>"}}

This masking is the current contract — the spec (get-h3/h3 → specs/04-SDK-Libraries.md) is silent on handler exceptions, and the behavior is locked in by tests/test_handler_crash.py. From the shim's point of view the session simply ends: it sees a normal end and stops, so the session dies silently. If you need to distinguish a crash from a real completion, validate the decision — the end.reason == "error" marker (and the server-side on_process failed / on_result failed log lines from logger.exception) is the only signal.

Real HTTP 500s are reserved for non-handler failures and are not disturbed by the masking: exceptions from on_cancel / on_session_terminate surface as HTTP 500 ({"detail": ...}), and any exception that escapes the router entirely is caught by the logging middleware's catch-all, which returns an ErrorResponse with ErrorCode.INTERNAL_ERROR. Handler-crash coverage in the shim battery (get-h3/shim → test_battery.py) belongs to that repo — it is out of scope for this SDK.

Result payloads

result["tool_calls"] is a LIST. When a result carries tool calls (OpenAI-style LLM APIs), req.result["tool_calls"] is a list[dict], one entry per call — never a single dict:

tool_calls = req.result.get("tool_calls") or []
if not isinstance(tool_calls, list):
    tool_calls = [tool_calls]  # defensive: accept a lone dict too
for call in tool_calls:
    print(call["name"])  # each entry is a plain dict

Dict-style access on the list (req.result["tool_calls"].get("name")) raises AttributeError: 'list' object has no attribute 'get' — which, under the error contract above, surfaces as a silent HTTP 200 end/error and kills the session with no signal.

req.result itself is a plain dict — read fields with .get(), never attribute style (req.result.type raises AttributeError). The canonical shape is ResultPayload; see docs/api/protocol.md.

GET /v1/sessions/{id} reports session status: "active" (default) or "completed". Harnesses that track lifecycle return a status key from get_session_info ("completed" once the loop reaches end) and the router passes it through.

Development

make install   # create venv + install deps
make build     # build wheel (and sdist) into dist/
make test      # run tests
make lint      # ruff check
make fmt       # ruff format

Running tests: use the project venv — make install then .venv/bin/pytest (147 tests). Bare pytest on an ambient interpreter may fail to import h3_harness; pytest.ini's pythonpath = src covers collection from the source tree without an install, but the project venv is the supported path.

Reference

Metadata

Release files for h3-harness-sdk 0.1.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for h3-harness-sdk 0.1.5
File Size Uploaded
h3_harness_sdk-0.1.5.tar.gz 168.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for h3-harness-sdk 0.1.5
File Interpreter ABI Platform
h3_harness_sdk-0.1.5-py3-none-any.whl Python 3 none any Details

Total release size: 189.0 kB

Release files / h3_harness_sdk-0.1.5.tar.gz

Download URL h3_harness_sdk-0.1.5.tar.gz
Size 168.4 kB
Tags Source
SHA-256 checksum
How to use checksums
1d6c653282c546a2d5df4f2a16544380bb0df0d1e136ac36edaeda2504849467
BLAKE2b-256 checksum
How to use checksums
f585e38440086e92d7b1e528c95dc00626d6d536f936f7711ff1949f2c3fc76c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release files / h3_harness_sdk-0.1.5-py3-none-any.whl

Download URL h3_harness_sdk-0.1.5-py3-none-any.whl
Size 20.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
86ea196ca0f922ae408e00dae42d8930759ac7d630c2f83f6530d41880e5ec6f
BLAKE2b-256 checksum
How to use checksums
24f01e70d23a2adb465263794be15299159235d07203040adef6363d4c65abb0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via uv/0.11.17 {"installer":{"name":"uv","version":"0.11.17","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

Release history Release notifications | RSS feed

0.1.6

2 release files

This release

0.1.5 This release

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page