@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.
- 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.
- Channels —
/v1/channels, connected transports 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.
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 { people } = usePeople({ owner: "hanzo", service: false });
const { channels } = useChannels();
const { messages, rooms, 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);
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.
Channels
Inbound only — there is a read side and no send side because the server
publishes none.
const transports = await ai.channels.list();
const box = await ai.channels.inbox({ limit: 50 });
const next = await ai.channels.inbox({ since: box.cursor });
rooms() groups a page you already hold into conversations. It is arithmetic,
not a request: the server has no rooms route, and a room is just the messages
sharing a (channel, roomId).
import { rooms } from "@hanzo/ai";
for (const room of rooms(box.messages)) {
console.log(room.roomKind, room.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: "" });
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