@hanzo/ai
v0.6.0
Published
TypeScript client for the Hanzo AI backend — agents, machines, chat completions, Anthropic messages, models, embeddings, speech, images, answer-engine search/deep-research, and portable chat/message threads. Headless root; optional React hooks at @hanzo/a
Readme
@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/machines, the org's compute. An agent bound to one is a bot. - 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/speechand/v1/audio/transcriptions, both directions. - Images —
/v1/images/generations. - Portable chat threads — the cross-surface conversation store (
chats+ theirmessages), so a conversation started on one surface resumes on another. - 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/aiUsage
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 }); // baseUrl defaults to https://api.hanzo.aiA 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!,
// or, for rotating tokens:
// getToken: async () => session.accessToken,
});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." }],
});
// Streaming yields SSE events:
const events = await ai.messages.create({ /* ...,*/ stream: true } as any);
for await (const ev of events) {
if (ev.type === "content_block_delta") { /* ev.delta.text */ }
}Models
const models = await ai.models.list(); // Model[]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; // or image.b64_json, per what the upstream answeredn 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; // "ok" — or "error", with run.error saying why
run.output; // "2+2 equals 4."
await ai.agents.list(); // Agent[]
await ai.agents.get("helper"); // AgentDetail — prompt + recent runs
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(); // org-wide feed, newest first
await ai.agents.runs({ limit: 50, status: "error" }); // just the failures
await ai.agents.runs({ agent: "helper", limit: 20 }); // one agent's runs
await ai.agents.metrics({ range: "7D" }); // invocation histogram + resource rollup
await ai.agents.activity(); // the org's activity feedstatus 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); // { active: 7, idle: 0, error: 0, draft: 0 }
agentStats(agents); // …plus total and summed run count
runStats(runs); // { total, ok, failed, successRate, avgLatencyMs, …tokens }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(); // Machine[]React
@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);
// …render with @hanzo/ui
}useModels() and useMachines() supply the choices an authoring form offers
for model and computeRef.
Portable chat threads
A Chat is a durable thread keyed by "owner/name"; its turns are Messages.
This is the store that lets a conversation move between surfaces.
await ai.chats.create({ owner: "hanzo", name: "thread-1", user: "alice", type: "AI" });
await ai.chats.messages.create({
owner: "hanzo",
name: "msg-1",
chat: "thread-1",
author: "alice",
text: "Hello",
});
const thread = await ai.chats.get("hanzo/thread-1");
const turns = await ai.chats.messages.list({ chat: "thread-1" });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";
// Cross-origin to api.hanzo.ai, with a bearer for per-org attribution:
sendFeedback({ requestId: "chatcmpl-abc", signal: "up" }, { token });
// A 1–3 rating — the ONLY signal that carries a value:
sendFeedback({ requestId: "chatcmpl-abc", signal: "rating", rating: 3 }, { token });
// Same-origin BFF proxy (cookie auth) — prefers navigator.sendBeacon:
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 withstatus: "error". Carries.statusand.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:
| option | type | default |
| ---------- | -------------------------------------- | ------------------------ |
| 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
