Thirteen places where this SDK and the other two answered the same question
differently. Every one is now what the served handler reads, or what HIP-0044
says where the handler is silent.
Where this SDK was already right, the other two moved: search normalizes the
knowledge kind in both directions, `Page.total` stays the server's own count,
and `put` picks its method from the name. Where it was not:
- kb.Import carries what the handler answers. `/v1/knowledge/import` writes
{format, imported, pages} (knowledge/import.go); this modelled `imported`
alone, so a caller could not see which pages landed.
- Fact carries the server's three. `GET /v1/graph` serves twelve members
(graph/typed.go, wireFact) and no other route hands back an assertion's
content address, so projecting the nine an asserter states made a read lossy
with nothing to recover it from. `id`, `by` and `knowable` arrive read-only
and `write()` never sends them.
- graph.extract and graph.ingest take a Source. Both routes bind the same
{source, subject, text, at} (graph/semantic.go, graphSourceIn), so it is one
value — and the first positional argument is now the same thing here as it is
in the other two.
- import_(format, data, project) — the order the contract writes and the other
two take. It was (export, *, format).
- A body carries what somebody set. `send` dropped None from the query and left
it in the body, so `search.find` posted {"index": null, "offset": null,
"project": null} where the other two omit the key: three requests for one
call, and cloud asked to read a value where there is none. Audit's pageSize
and p go the same way — the route's own defaults are the same 100 and 1, so
an unset term is one the route decides identically, and `all` pins the size it
walks at because it needs to know the page length.
- A write body is field data and nothing else. `url` belongs to a source, and an
empty title is left out so the doctype names the required field that is
missing rather than being handed a blank one.
- kb.list takes order and kb.links takes a project. `/v1/framework/{doctype}`
serves order_by and `/v1/knowledge/graph` serves ?project=.
- policy_denied and entitlement_required do not exist. Neither string appears
anywhere in hanzoai/cloud, so REFUSED is the two codes it does emit. A 403
read as denied on an invented code would hand an unauthenticated caller a cure
for a refusal nobody made.
- One refusal reader, by named fallback: code from `code` else `error`, reason
from `detail` else `message`, cures from `cure`. The gate's own `reason` —
"unpaid", "unresolved" — names the admit leg that failed rather than
explaining anything, so it is dropped, and `title` is not a refusal member.
- Money counts minor units, not cents: hundredths for USD, whole yen for JPY,
thousandths for KWD.
- Balance.reserved, not held. The wire says `holds`; `held` is an arm.
- Vocabulary.rules, because it is a list. The wire member stays `rule`.
- The fourth outcome is Fault, not the generated client's ApiException. It is
the one shared concept whose name a reader could not carry between languages,
and an exception from the generated layer is not this layer's vocabulary.
- A client with no credential builds. It raised at construction, which is a
process that cannot start where the other two are a process whose first call
fails — and the first call is where the credential is actually needed.
HIP-0044 is amended where the code was right and the document was not.
ruff check, ruff format --check and mypy clean on every changed file; 109
passed. The two test_smoke failures are pre-existing (ToolsApi is absent from
the generated tree) and reproduce on a stashed working copy.
Co-authored-by: Hanzo Dev <[email protected]>
Hanzo Python SDK
hanzoai is the Python client for the Hanzo API, generated from the API's own
OpenAPI document. Every /v1 route is in it, and the names it exposes are the
document's operation ids. .spec-lock names the commit and sha256
of the document this tree was cut from.
Install
pip install hanzoai
Check the install without a key — GET /v1/models is public:
python -m examples.models
https://api.hanzo.ai serves 112 models, no credential required
all-mini-lm-l6-v2 · do-ai · $0.02/Mtok in
anthropic-claude-opus-5 · do-ai · $1/Mtok in
…
Quickstart
from hanzoai import Client
c = Client() # HANZO_CLIENT_ID / HANZO_CLIENT_SECRET
print(c.budget.left().left, "free calls left today")
print(c.policy.check("usr_7", "write", "graph:acme").allow)
a = c.search.find("q3 incident postmortems")
if a.denied:
for cure in a.denied.cures:
print(cure.kind, cure.url)
else:
for hit in a.value.items:
print(hit.score, hit.title)
budget, policy, audit, search, kb and graph hang off the client and
are the surface most callers want. They are the same six words, the same method
names and the same answer type in the
Go and
TypeScript SDKs.
Everything else is the generated client — one *Api class per tag, one method
per operation, typed models in and out. Client is the generated ApiClient, so
it goes anywhere an ApiClient goes and carries the same credential there:
from hanzoai.cloud import KeysApi
with c as api:
for key in KeysApi(api).get_keys().keys or []:
print(key.prefix, key.type, key.created_at)
Answers
A budget that says no and a policy that says no are answers, not exceptions. Every capability call that can be refused returns one type with three arms:
from hanzoai import Ok, Denied, Held
match c.graph.assert_(facts):
case Ok(value=wrote): print(wrote.recorded, "recorded")
case Denied() as denied: print(denied.code, denied.reason, denied.cures)
case Held() as held: print("a person was asked:", held.id, held.clause)
Ok holds what ran, Denied names the code and the cures that would clear it,
Held names the approval a person still has to give. Reading .value off a
refusal raises it, so there is no member you can read off the wrong arm. Every
arm carries request — the x-request-id the server stamped — which is the same
word audit.Event uses, so the trail can be read back for a call you just made.
Reads no gate refuses answer their value directly: budget.left,
budget.balance, budget.plan, audit.list, graph.read, graph.resolve,
kb.get. policy.check answers a Decision carrying a boolean, because asking
whether you may is a question with an answer — being stopped mid-call is what
produces a refusal.
Auth
IAM is the only authority. The client holds its own application's clientId and
clientSecret, exchanges them for a short-lived access token —
POST https://hanzo.id/v1/iam/oauth/token, client_credentials,
client_secret_basic, RFC 8707 resource-scoped to the endpoint it will call —
holds it until shortly before expiry, and re-mints once on a 401.
It takes no bearer. A credential handed to an SDK is a credential nobody rotates, and it says nothing about who is calling, which is the one question every gate in the estate exists to answer.
Five options, each falling back to an environment variable, so the zero-argument constructor is the normal case:
| option | environment | default |
|---|---|---|
id |
HANZO_CLIENT_ID |
— |
secret |
HANZO_CLIENT_SECRET |
— |
base |
HANZO_BASE_URL |
https://api.hanzo.ai |
issuer |
HANZO_ISSUER_URL |
https://hanzo.id |
resource |
HANZO_RESOURCE |
= base |
Four operations need no credential at all — GET /v1/models,
GET /v1/models/providers, GET /v1/commands, GET /v1/openapi.json — which is
why examples/models runs before you have one.
Tenants
If you are building on top of Hanzo, you hold one key and your customers hold
none. as_ binds a client to one of them:
from hanzoai import Client
hanzo = Client()
acme = hanzo.as_("user_42") # a subject id, or your own externalId
acme.kb.put(doc) # written as that customer
IAM mints a short-lived token bound to that subject — POST https://hanzo.id/v1/iam/tokens/issue?id=user_42, on IAM's own host, reading the
act grant off your own token — and the scoped client sends it on every call, the
six capabilities and the generated operations alike, keeps it until it nears
expiry, and re-mints once on a 401. Your own credential leaves with the scope:
two credentials on one request are two answers to who is calling. No method
takes a user id, so there is none to pass wrongly and none to forget.
Client(issuer=...) points the mint at a private estate.
The spelling is as_ rather than as only because as is a keyword; the
trailing underscore is what PEP 8 prescribes, and it keeps the platform's one
word for this from growing a synonym in Python.
Held calls
A generated operation your policy holds for a human decision answers 202 with
the approval — {"status": "held", "id", "clause", "reason"}. A 202 is a 2xx, so
a client that only checks for a raise reads a queued call as a completed one.
This one raises the same Held the six answer as an arm:
from hanzoai import Held
try:
fact = MemoryApi(acme).post_memory_remember(body)
except Held as held:
print(held.id, held.clause, held.reason) # queued — nothing ran
The dozen long-running operations whose 202 means "accepted, working on it"
carry their own schema and pass straight through — the body is the
discriminator, not the status code.
Examples
examples/ carries one directory per flow. Each is a whole path through one part
of the API. CI imports all six and resolves every method name they call against
the client.
| flow | what it does | routes | credential |
|---|---|---|---|
models |
the model catalog | GET /v1/models |
none |
six |
budget, policy, search, kb, graph and audit in one flow | /v1/allowance, /v1/billing/balance, /v1/entitlement, /v1/authz/check, /v1/search, /v1/framework/kb.page, /v1/graph, /v1/audit |
IAM |
hello |
prove the credential works | GET /v1/keys |
IAM |
money |
balance + usage | GET /v1/billing/balance, GET /v1/billing/usage |
IAM |
store |
KV round-trip | POST /v1/kv, GET/DELETE /v1/kv/{name} |
IAM |
agent |
create, run, read the runs | POST /v1/agents, POST /v1/agents/{ref}/run, GET /v1/agents/{ref}/runs |
IAM |
tools |
the tool catalog | GET /v1/tools |
IAM |
One command each, from the repo root:
python -m examples.models # no credential
export HANZO_CLIENT_ID=... HANZO_CLIENT_SECRET=...
python -m examples.six
six is the one that shows the capabilities composing: it checks what it may
spend, asks whether it may write, searches the corpus, files a page, records a
fact, and then reads the audit trail back for the request ids the earlier calls
carried.
There is no chat flow: POST /v1/chat/completions is declared with no
requestBody and no responses, so the method takes no arguments and returns
None. It comes back the day the document describes the body.
money reads its two payloads through the generated
*_without_preload_content variant, for the same reason — an operation that
declares no responses, or a 2xx carrying no content, models no body to
deserialize.
Reference for the routes themselves: api.hanzo.ai/docs, served from the same document — api.hanzo.ai/v1/openapi.json.
The rest of the repo
This is a uv workspace. pkg/hanzoai is the client above; the other packages
are hand-written, ship separately, and mostly carry their own README:
| Package | Install | Purpose |
|---|---|---|
pkg/hanzoai |
hanzoai |
the client above |
pkg/hanzo-mcp |
hanzo-mcp |
Model Context Protocol server |
pkg/hanzo-agent |
hanzo-agent |
agent framework (import path agents) |
pkg/hanzo-agents |
hanzo-agents |
agent networks and swarms |
pkg/hanzo-memory |
hanzo-memory |
persistent memory + RAG over SQLite |
pkg/hanzo-network |
hanzo-network |
distributed compute nodes |
pkg/hanzo-tools-* |
one each | single-concern tool packages, each registering a TOOLS list under the hanzo.tools entry point, which is how hanzo-mcp finds them |
The hanzo command is a native binary, not a Python package:
curl -fsSL https://hanzo.sh | sh. pip install hanzo ships the older Python CLI
under the name hanzo-py, so the two never fight over one name on a PATH.
Development
git clone https://github.com/hanzoai/python-sdk && cd python-sdk
uv sync --all-packages
uv run pytest tests/ -v
pkg/hanzoai/cloud/ is generated and is never edited by hand — a regeneration
does rmtree then copytree, so an edit there is gone on the next run. It comes
from hanzoai/openapi:
cd ../openapi && uv run --with pyyaml python3 generate.py python \
--repo ../python-sdk --spec ../cloud/openapi.yaml
A defect in generated code is fixed in the document.
License
Apache 2.0 — see LICENSE. Report vulnerabilities to [email protected] (SECURITY.md).
Hanzo — the Open AI Cloud
hanzo.ai · docs.hanzo.ai · same client in other languages: TypeScript · Go · Java · Kotlin · umbrella