@hanzo/ai
The TypeScript SDK for the Hanzo AI Cloud — agents, models,
chat, messages, embeddings, speech, images, portable threads, and router feedback
in one headless client.
Pure client + types. No UI, no DOM dependencies beyond the standard fetch /
ReadableStream web APIs, and no React in the package root. This is the shared
API layer behind hanzo.chat, hanzo.app, the Hanzo desktop app, and the
hanzo-dev CLI. Components live in @hanzo/ui;
the React hooks that connect them to this client are an optional
@hanzo/ai/react entry.
It is the first-party TypeScript surface for the Hanzo AI Cloud (api.hanzo.ai):
- Agents —
/v1/agents, the one agent store every Hanzo surface reads and
writes: create, configure, list, run, and read the run history.
- Machines —
/v1/agents/targets, the org's compute. An agent bound to one is a bot.
- Sandboxes —
/v1/sandbox, computers leased by the second. An agent needs
no machine to run: lease, run, end.
- Chat completions —
/v1/chat/completions, streaming and not, with Enso's
routing bounds and the model that served.
- Kai —
/v1/decisions, typed questions answered with calibrated
probabilities instead of generated text.
- Messages —
/v1/messages, streaming and not.
- Models — the catalog at
/v1/models.
- Embeddings —
/v1/embeddings, a vector for one text or many.
- Speech —
/v1/audio/speech and /v1/audio/transcriptions, both directions.
- Images —
/v1/images/generations.
- Sessions —
/v1/agents/sessions, a run's durable record: list, tree,
steer, and one live SSE feed.
- Tools —
/v1/tools, what an agent can call: skills, plugins, connected
MCP servers, and the catalog of what could be connected.
- Threads —
/v1/agents/chat/conversations, the conversation store every
chat surface reads, so a conversation started on one resumes on another.
- People —
/v1/iam/scim/v2/Users, the org's directory over SCIM.
- Transports —
/v1/transport, the networks an org has connected and what arrived on them.
- Account — the signed-in IAM identity.
Every shape here is checked against the contract the backend publishes:
pnpm check:spec reads the live OpenAPI at api.hanzo.ai/v1/openapi.json and
fails on any drift, so the types have one home and it is the server's.
Request and response shapes are the familiar chat / messages formats, so bringing
existing code across is mechanical.
Install
npm install @hanzo/ai
Usage
Auth is a Hanzo IAM access token (e.g. from @hanzo/iam) sent as
Authorization: Bearer <token>.
In a signed-in app, hand it IAM and nothing else — @hanzo/iam owns every
credential step, and the client takes a fresh token per request (refreshing when
one expires), so token plumbing is never re-implemented per app:
import { createAiClient } from "@hanzo/ai";
import { IAM } from "@hanzo/iam";
const iam = new IAM({ serverUrl: "https://hanzo.id", clientId: "my-app" });
const ai = createAiClient({ auth: iam });
A signed-out session throws AuthError instead of sending an unauthenticated
request. For a server or a test holding a raw bearer (an hk- key, say), pass it
directly — and if both are given, IAM wins while it has a session:
const ai = createAiClient({
token: process.env.HANZO_TOKEN!,
});
Chat completions
const res = await ai.chat.completions.create({
model: "enso",
messages: [{ role: "user", content: "Explain MoE routing in one sentence." }],
});
console.log(res.choices[0].message.content);
Streaming:
const stream = await ai.chat.completions.create({
model: "enso",
messages: [{ role: "user", content: "Write a haiku about Go." }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta.content ?? "");
}
Tool calling works by passing tools / tool_choice.
Messages
const msg = await ai.messages.create({
model: "enso",
max_tokens: 512,
system: "You are concise.",
messages: [{ role: "user", content: "Summarize the CAP theorem." }],
});
const events = await ai.messages.create({ stream: true } as any);
for await (const ev of events) {
if (ev.type === "content_block_delta") { }
}
Models
const models = await ai.models.list();
Model names such as enso are Zen models — the Hanzo family. You address them
directly; the cloud routes each request to the right expert.
Kai
Kai answers a bounded question with a distribution over its answers. Each answer
comes back under the name its question was asked by:
import { createAiClient, routedModel } from "@hanzo/ai";
const ai = createAiClient({ token: process.env.HANZO_API_KEY! });
const { answers } = await ai.decisions.create({
model: "kai",
state: "My order arrived broken and I want my money back.",
questions: {
intent: {
type: "choice",
instructions: "What does the customer want?",
criteria: { refund: "money back", replacement: "a new item", status: "where the order is" },
},
},
});
const { choice = "", probabilities = {} } = answers.intent;
console.log(choice, probabilities[choice]);
noul asks whether a statement holds (answers.<name>.noul is P(true)) and
score picks a level (answers.<name>.score). A refusal — 402, 422, 429 —
throws APIError carrying its status.
Enso
Send model: "auto" and Enso picks the model per request across what your org
can serve; send an enso-* id for one managed name. maxCost (USD per 1,000
tokens, sent as X-Max-Cost) and maxLatencyMs (X-Max-Latency-Ms) bound the
pick. When Enso chooses the model — always for auto, and for an enso-* id
when its ladder moves the request — the X-Routed-Model response header names
it, and onResponse hands you the response to read it from. When nothing was
chosen the header is absent and the id you sent is the one that served:
let served: string | undefined;
const completion = await ai.chat.completions.create(
{ model: "enso-auto", messages: [{ role: "user", content: "Summarize this changelog in one line." }] },
{ maxCost: 0.01, onResponse: (res) => (served = routedModel(res.headers)) },
);
console.log(served ?? completion.model, completion.choices[0]?.message.content);
const reward = await ai.feedback({ requestId: completion.id, signal: "accept" });
console.log(reward?.recorded);
Routing pattern
Kai decides how hard a request is; Enso serves it at that tier:
const prompt = "Prove that the square root of 2 is irrational.";
const { answers: route } = await ai.decisions.create({
model: "kai",
state: prompt,
questions: {
tier: {
type: "choice",
instructions: "Which model tier should answer this request?",
criteria: {
"enso-flash": "a quick lookup, a greeting, or a short rewrite",
"enso-auto": "an ordinary task that needs some reasoning",
"enso-ultra": "hard multi-step reasoning, math, or code",
},
},
},
});
const answer = await ai.chat.completions.create({
model: route.tier.choice ?? "enso-auto",
messages: [{ role: "user", content: prompt }],
});
console.log(route.tier.choice, answer.choices[0]?.message.content);
Embeddings
import { embed, embedMany } from "@hanzo/ai";
const vector = await embed(ai, "the cat sat", { model: "zen-embed" });
const vectors = await embedMany(ai, ["the cat sat", "on the mat"], { model: "zen-embed" });
embedMany answers the vectors in the order the texts were asked, not the order
the provider sent them: each row of the wire carries the input it belongs to, and
pairing by array position is silently wrong whenever an upstream reorders.
Speech
import { speak, transcribe } from "@hanzo/ai";
const { audio, type } = await speak(ai, "hello there", { model: "zen-voice", voice: "alloy" });
const { text } = await transcribe(ai, file, { model: "whisper-1" });
speak answers the bytes and the media type the server labelled them with — a
provider that cannot make the container you asked for substitutes its own, so the
response is what a player must be told, never the request. transcribe takes a
Blob/File or raw bytes, and asking for timestamps ("word" / "segment")
selects the verbose body those timings ride in.
Images
import { generateImages } from "@hanzo/ai";
const [image] = await generateImages(ai, "a red bicycle", { model: "zen3-image" });
image.url;
n is clamped to [1,10] by the server.
Agents
/v1/agents is the one agent store. Every Hanzo surface — chat, app, console,
playground, desktop, the CLI — reads and writes these records; none keeps its
own. An agent is a model, a system prompt and a set of tool names, scoped to
your org by the token.
const agent = await ai.agents.create({
name: "helper",
model: "enso-flash",
instructions: "Answer in exactly one short sentence.",
});
const run = await ai.agents.run("helper", "What is 2+2?");
run.status;
run.output;
await ai.agents.list();
await ai.agents.get("helper");
await ai.agents.update("helper", { instructions: "be terse" });
await ai.agents.delete("helper");
A ref is the agent_… id or the org-unique name — either resolves the same
agent. A run that fails resolves as a run carrying status: "error"; read the
status rather than assuming output.
The dashboard reads
Three reads and a fold. Enough that a surface renders an agents dashboard
without writing a client or a rollup of its own.
await ai.agents.runs();
await ai.agents.runs({ limit: 50, status: "error" });
await ai.agents.runs({ agent: "helper", limit: 20 });
await ai.agents.metrics({ range: "7D" });
await ai.agents.activity();
status and agent cannot be combined, and that is a type error rather than a
runtime one: the org-wide feed filters on status, the per-agent route does not
(measured — it answers unfiltered), so the pair that would silently return the
wrong rows is unrepresentable.
The folds are pure functions over records you already hold — no client, no
network:
import { agentStats, runStats, healthBreakdown, deriveActivity } from "@hanzo/ai";
healthBreakdown(agents);
agentStats(agents);
runStats(runs);
Two rollups, not one, on purpose. An agent row carries a status and a run
count, so agentStats answers what your agents are; success rate, latency and
tokens are recorded on runs, so they come from runStats. Every field is null
when nothing measured it — never a zero, because "unmeasured" and "zero" are
different facts and a dashboard must be able to tell them apart.
deriveActivity(agents) builds a thinner feed from the agents' own timestamps,
for a surface that cannot reach activity().
Machines
An agent bound to a machine by computeRef is what a bot is. The list is every
machine your org has, provisioned or dialed in via hanzo link.
const machines = await ai.machines.list();
Sandboxes
A machine is a box the org keeps; a sandbox is one it borrows. Running an agent
takes no machine at all — lease an exec, run, end, and nothing is provisioned
and nothing survives.
const box = await ai.sandboxes.lease({ class: "exec" });
const ran = await ai.sandboxes.run({ id: box.id, command: "pytest -q" });
await ai.sandboxes.end({ id: box.id });
Pass session to run() and the output is appended to that agent session as it
is produced, so every surface watching the session watches the command work
instead of a blank pause.
A dev class takes a project, which attaches that project's disk and keeps
the workdir across leases — one live sandbox per project, since the disk mounts
one computer at a time. Resume it by id; a caller holding an id who omits it
gets a second computer rather than a second view of the first:
const dev = await ai.sandboxes.lease({ class: "dev", project: "site" });
await ai.sandboxes.end({ id: dev.id });
await ai.sandboxes.lease({ id: dev.id });
runtime (runc, gvisor, kata-clh, kata-fc) is a REQUEST. The fleet
refuses what it cannot honour, so read Leased.runtime for what you got.
React
@hanzo/ai/react is hooks and a provider — no components, no DOM — so the same
code runs in a web app, a desktop shell and a native surface.
const { sessions, reload } = useSessions({ status: "running", limit: 20 });
const { session } = useSession(selectedId);
const { threads } = useThreads();
const { threads: away } = useThreads({ archived: true });
const { people } = usePeople({ owner: "hanzo", service: false });
const { transports } = useTransports();
const { messages, channels, cursor } = useInbox({ limit: 50 });
Every one hands back reload, because these lists change under you — a steered
session, a renamed thread — and a sidebar that cannot re-read one is showing the
past.
Pass params as a plain object; the hooks destructure them into primitive
dependencies, so a fresh { status: "running" } each render does not re-fetch
forever.
@hanzo/ai/react is the agent-authoring surface every platform mounts. It is
hooks and a provider — no components, no DOM — so the same code runs in a web
app, in the desktop webview and in React Native. Bring your own components from
@hanzo/ui. react is an optional
peer dependency; importing the package root pulls in none of it.
import { AiProvider, useAgents, useAgent } from "@hanzo/ai/react";
<AiProvider auth={iam}>
<Agents />
</AiProvider>;
function Agents() {
const { agents, loading, create, remove } = useAgents();
const { agent, runs, run, running } = useAgent(agents[0]?.name ?? null);
}
useModels() and useMachines() supply the choices an authoring form offers
for model and computeRef.
Tools
Four sources answer here and they are not interchangeable — list() is
everything an agent may dispatch, skills() and plugins() and mcp() are
where those came from, and catalog() is what could be connected but is not.
const available = await ai.tools.list({ activated: true });
const connected = await ai.tools.mcp();
const page = await ai.tools.catalog({ q: "github", limit: 20 });
Activation is a WHOLE SET, not a delta — a name absent from enabled is off
afterwards, so read before you write:
const { enabled = [] } = await ai.tools.activation();
await ai.tools.activate([...enabled, "search"]);
Sessions
A session is a run's durable record, and a tree: a run that spawns another names
it as parent, and every descendant carries the same rootSessionId.
const running = await ai.sessions.list({ status: "running", limit: 20 });
const detail = await ai.sessions.get(running[0].id);
const tree = await ai.sessions.tree(running[0].id);
await ai.sessions.update(running[0].id, { title: "renamed" });
limit bounds the page and there is no cursor — the route publishes no
continuation, so asking for more means a bigger limit, not a second page.
Steering goes through a queue rather than a signal, which is why a pause posted
from any surface reaches a run already in flight:
await ai.sessions.steer(id, "pause");
await ai.sessions.steer(id, "message", { message: "try the other branch" });
const { commands, cursor } = await ai.sessions.control(id, { after: seen });
One live feed for the org, narrowed to a tree with root:
for await (const frame of ai.sessions.stream({ root: id })) {
if (frame.kind === "session") redrawRow(frame.session);
else appendTurn(frame.event);
}
Each record arrives inside a one-key envelope on the wire ({"session":{…}})
and is unwrapped for you, so frame.session is the session rather than the
wrapper. The heartbeat is an SSE comment and never becomes a frame.
There is no per-session stream. /v1/agents/sessions/{id}/stream answers 404
and appears nowhere in the contract; watching one run is this feed with root
set to it.
Threads
The conversation store every Hanzo chat surface reads.
const threads = await ai.threads.list();
const detail = await ai.threads.get(threads[0].id);
await ai.threads.update(threads[0].id, { pinned: true });
await ai.threads.remove(threads[0].id);
detail?.messages.forEach((m) => console.log(m.role, m.content));
get() answers null for a thread this caller does not have. That is load
bearing: asked for an id belonging to another tenant the route answers 200
with an empty messages rather than a 404, so a client trusting the status
renders somebody else's id as an empty conversation.
This replaced a client over /v1/ai/chats, which answers {"data":[]} under
every addressing — owner, user and global — because nothing writes that table.
People
const humans = await ai.people.list({ owner: "hanzo", service: false });
const bots = await ai.people.list({ owner: "hanzo", service: true });
Read through SCIM rather than the plain IAM user route, which answers ~200
fields per user where a directory needs six. The photos data URI — measured at
54 KB for one user — is dropped rather than carried into a list.
The filter is asymmetric on purpose: a service account is exactly
userType: "service-account", and a human is anything else, because userType
is ABSENT on some rows and a positive test for "normal-user" drops them.
Transports
A transport is the network a message travels over (Slack, Discord, Teams,
Telegram, WhatsApp, GitHub, Linear); the conversation it lives in is a channel.
The client binds the reads: what is connected, what arrived, who may speak and
who is waiting to pair.
const transports = await ai.transports.list();
const box = await ai.transports.inbox({ limit: 50 });
const next = await ai.transports.inbox({ since: box.cursor });
const allow = await ai.transports.allowlist({ transport: "slack" });
const waiting = await ai.transports.pairing();
channels() groups a page you already hold into conversations. It is
arithmetic, not a request: a channel here is the messages sharing a
(transport, channelId).
import { channels } from "@hanzo/ai";
for (const channel of channels(box.messages)) {
console.log(channel.channelKind, channel.last.text);
}
sender is the platform's own id (U031THJJ7F1 on Slack). The schema declares
a senderUser beside it and every measured message omits it, so render sender
as an opaque handle and treat a resolved name as a bonus.
Account
const me = await ai.account.get();
Feedback (content-free reward signals)
sendFeedback emits a router-training signal keyed ONLY on the response id (the
chatcmpl-… / msg_… the routing ledger keys on). It is content-free by
construction — the payload can only ever be { request_id, signal, rating? };
no prompt, response, tag, filename or code can transit it. It is fire-and-forget:
it never throws, never blocks UX, and is a silent no-op on any failure. This is
the single implementation shared by hanzo.chat, hanzo.app and world.hanzo.ai.
import { sendFeedback } from "@hanzo/ai";
sendFeedback({ requestId: "chatcmpl-abc", signal: "up" }, { token });
sendFeedback({ requestId: "chatcmpl-abc", signal: "rating", rating: 3 }, { token });
sendFeedback({ requestId: id, signal: "regenerate" }, { baseUrl: "" });
On a client, ai.feedback(input) sends it under the client's own base URL,
credential and fetch. Both forms return a promise you may ignore: it resolves
to the gateway's answer { request_id, reward, recorded } when the signal went
over fetch, and to null for a beacon, a no-op or a failure. It never rejects.
Signals: up · down · regenerate · switch · abandon · accept ·
revert · rating · dismiss. The input is a discriminated union, so it is a
type error to attach a rating to any non-rating signal (dismiss
included) and a rating value is required for signal: "rating".
Options: baseUrl (absolute = api.hanzo.ai, "" = same-origin BFF), token
(static or lazy — forces fetch since a beacon can't set headers),
credentials (default "include"), disabled (config opt-out; also honors a
HANZO_FEEDBACK=0 env var), fetch, sendBeacon, dedupe (default true).
Errors
All failures throw a subclass of HanzoAIError:
APIError — non-2xx HTTP, or a CRUD envelope with status: "error". Carries
.status and .body.
AuthError — no token was available for an authenticated request.
import { APIError } from "@hanzo/ai";
try {
await ai.models.list();
} catch (err) {
if (err instanceof APIError) console.error(err.status, err.message);
}
Advanced
createAiClient accepts:
baseUrl | string | https://api.hanzo.ai |
auth | IamAuth (the @hanzo/iam SDK) | — |
token | string | — |
getToken | () => string | Promise<string> | — |
fetch | typeof fetch | globalThis.fetch |
headers | Record<string, string> | {} |
The low-level transport (client.http) and the SSE helpers (parseSSE,
streamChatCompletion, streamMessage) are exported for custom calls.
License
Apache-2.0
Hanzo — the Open AI Cloud
Open source · every language · on-chain settlement. hanzo.ai · docs.hanzo.ai
SDKs in every language — Python (flagship) · TypeScript · Go · Rust · C++ · Swift · Kotlin · umbrella