Agent Client SDK
@zihin/agent-client is the official Node.js client for invoking Zihin agents. It is the shared
core behind every Zihin connector (n8n, Activepieces, Zapier, Paperclip) and the reference client for your own
backends. Zero platform dependencies. See the current npm release versions.
(npm).
npm install @zihin/agent-client
Requires Node ≥ 20.16 (uses global fetch / ReadableStream).
Create a client
import { createClient } from '@zihin/agent-client';
const zihin = createClient({
apiKey: process.env.ZIHIN_API_KEY, // zhn_live_* — tenant is embedded
});
| Option | Default | Description |
|---|---|---|
apiKey | — | Required. zhn_live_* / zhn_test_*. |
baseUrl | https://llm.zihin.ai | Override for a private deployment. |
timeoutMs | per operation | Max total time for the whole call (headers and stream). Defaults: 200 s stream · 260 s dispatch · 35 s short reads. |
maxRetries | 2 | Retries with exponential backoff. |
allowCustomBaseUrl | false | Allow a baseUrl outside the Zihin domain whitelist (SSRF opt-in). |
allowHttp | false | Allow http/localhost (development only). |
fetch | global | Inject a fetch for runtimes without one (edge/serverless). |
onRetry / onError | — | Observability hooks (no console). |
Direct invocation
Wait for the result. The client buffers the SSE stream and consolidates the response and metrics.
const res = await zihin.invokeAgent({
agentId: 'uuid-of-the-agent',
message: 'What is the sales forecast?',
sessionId: previousSessionId, // optional — continues the conversation
});
res.content; // final text
res.contentSource; // 'response' = the agent's final answer · 'tokens' = raw partial (stream ended early) — review before publishing
res.sessionId; // reuse on the next message for continuity
res.model; // effective model
res.usage; // { inputTokens, outputTokens, totalTokens, costUsd }
res.sources; // tool / retrieval sources, when present
Streaming
Receive deltas as the agent thinks, calls tools, and answers:
for await (const chunk of zihin.streamAgent({ agentId, message: 'Hello' })) {
if (chunk.type === 'token') process.stdout.write(chunk.content);
if (chunk.type === 'tool') console.log('tool:', chunk.tool);
}
Pass the sessionId returned by a previous call to keep context across turns. Leave it empty to start
a fresh conversation.
Contact name and suggested replies
These features need @zihin/agent-client 0.6.1 or later. Against an older backend they simply do not happen; the turn runs normally.
const r = await zihin.invokeAgent({
agentId,
message: 'I want blue pens',
consumer: { key: 'customer-1410', name: 'Fernando Silva' }, // name shows in the console contacts
capabilities: { blocks: ['quick_replies'] }, // my screen renders suggestions
});
r.suggestions; // ['Pen A', 'Pen B'], or undefined when the agent did not suggest
consumer: { key, name? }isconsumerKeywith the name attached (consumer.keyandconsumerKeyare the same field; send one). Same rule asconsumerKey: only valid with anadmin/ownerAPI key. The name is sent asconsumer_display_name(up to 255 characters) and applies from now on: it updates the contact and the conversation in progress, while ended conversations keep the name they had. It is personal data: send only what the operation needs to see.- Use one
consumer.keyper person. The key is the identity (memory, denylist and engagement control belong to it). If several people share a channel, give each one a key; changing only thenamedoes not separate them. capabilities: { blocks: ['quick_replies'] }says your screen renders suggested replies. It is a request, not a requirement: any API key can send it, and a server that does not know it only reports it inignoredFields. The agent owner can turn suggestions off (quick_replies_enabled) and the agent decides when to suggest. See the contract.suggestions(inAgentResultand in theresponsechunk) are the labels of thequick_repliesblock: up to 4, short. Tapping one means sending its text as the nextmessage. In a suggestions-only turn,contentis empty.- Deprecated:
options.jitMode,options.temperatureandoptions.maxTokenshave no effect on/stream(temperature and output cap are agent configuration). They are still sent and the server lists them inignoredFields; the client emits aZihinDeprecationWarningonce per process. They are removed in 1.0.
Idempotency: clientMessageId (0.7)
Needs @zihin/agent-client 0.7.0 and a Zihin backend from October 2026 or later.
const r = await zihin.invokeAgent({
agentId,
message: 'Close order 42',
clientMessageId: crypto.randomUUID(), // a NEW UUID per message; resend the SAME one to retry
});
r.idempotentReplay; // true = the backend replayed the stored answer (the agent did not run again)
clientMessageId(UUID, oninvokeAgentandstreamAgent) makes the turn idempotent and sendsclient_message_id(see the contract). With it, the client also retries the/streamPOST on network errors and 5xx before the stream opens (without it, a POST never retries on a network error, because it could duplicate the turn).- Resending the same id with the same message: turn still in flight →
SESSION_BUSY(blocked, retryable); turn finished withCOMPLETED/LENGTH→ the backend replays the stored answer for 24 h, withidempotentReplay: trueand nousage. Same id with a different message →IDEMPOTENCY_CONFLICT(config). - Best effort, and only for a finished turn. A turn cut mid-stream, cancelled, failed or timed out releases the id, and a resend runs again (tools included). That is why the automatic retry happens only before the stream opens. If the backend's cache is unavailable, there is no dedupe.
- Never use a fixed value. The scope is the API key +
consumerKey(not the session) and the comparison is message + attachments only: a scheduled automation with a fixed id would get the same stored answer for 24 h. Reusing the id in another session givesconfig(IDEMPOTENT_REPLAY_OTHER_SESSION) — this guard exists only ininvokeAgentwith an explicitsessionId; withsessionFrom, without a session, or instreamAgent, a reused id silently returns the answer (and thesession_id) of the original conversation. SERVICE_UNAVAILABLE(replay with no recoverable answer) is not retryable: it can last 24 h and only a newclientMessageIdgets out of it.- Against a server that does not list
client_message_idinaccepted_fields, the client refuses the turn (config): the guarantee you asked for would not exist.
Turn ids
The /stream events carry execution_id in metadata, and execution_id, user_message_id and
assistant_message_id in response and done. Since 0.7, AgentResult exposes executionId (read
from metadata, so it arrives even when the turn dies before metrics), clientMessageId,
userMessageId and assistantMessageId.
Refusal, context limit and one turn per session (0.7)
REFUSED— the model provider refused under its policy. The backend delivers a fixed neutral sentence asresponseand records the execution as an error; the client throwsblockedwithcode: 'REFUSED'.CONTEXT_LIMIT— the conversation gathered more context than the model can process:blocked,code: 'CONTEXT_LIMIT'. Send less, or open a new session.- One turn per session — sending a second message before the first finishes gives
SESSION_BUSYas anevent: error. The client classifies it asblockedand retryable (the turn never started). capabilities.blocksaccepts'html_report': your screen renders HTML, and the agent may then deliver an HTML document inside anhtmlfenced code block incontent(not a new block type).
Asynchronous invocation (triggers)
For long-running or decoupled work, provision a webhook bound to the agent and dispatch to it. The
dispatch returns immediately; the result arrives at your callbackUrl (or via polling).
// Once — provision (or reuse) a webhook. Prefer findOrCreateTrigger to avoid creating
// duplicate triggers.
const { triggerId, webhookUrl } = await zihin.findOrCreateTrigger({
agentId,
name: 'Integration X',
mode: 'async',
callbackUrl: 'https://my-platform.com/webhook/zihin',
});
// N times — dispatch (responds 202; the result is delivered to the callback)
const { executionId } = await zihin.dispatchAgent({
triggerId,
payload: { message: 'Process this order' },
});
// Optional — poll the result instead of using a callback
const exec = await zihin.getExecution({ triggerId, executionId });
mode: 'sync' instead responds 200 + JSON immediately. Since 0.7, a sync dispatch reads the
outcome of the body: REFUSED, CONTEXT_LIMIT, TIMEOUT, ERROR… arrive with success: true
and the platform's sentence in agent_response, and the client throws with the same
classification as /stream (anything other than COMPLETED, LENGTH or PAUSED).
DispatchResult.outcome exposes the outcome of the dispatches that answer. The helper
errorFromOutcome(outcome, ctx) is exported for code that reads outcomes outside SSE. Trigger lifecycle helpers: listTriggers,
getTrigger, updateTrigger (read-merge-write — a partial update won't wipe the stored callback config), deleteTrigger.
List agents
const agents = await zihin.listAgents(); // GET /api/v2/agents
// [{ id, name, commercialName, status, toolsCount, ... }]
Useful to populate an agent picker in a connector UI.
API key permissions
| Operation | Required role |
|---|---|
invokeAgent / streamAgent | any role |
createTrigger / getExecution | admin or editor |
A member key calling a restricted operation gets a ZihinAgentError with kind: 'auth'.
Errors
Every failure is a ZihinAgentError:
| Field | Meaning |
|---|---|
kind | config · auth · trial_expired · no_subscription · quota_exceeded · not_found · rate_limit · timeout · aborted · server · network · protocol · blocked · unknown |
code | the backend code or turn outcome (e.g. REFUSED, CONTEXT_LIMIT, SESSION_BUSY, TURN_TIMEOUT) |
status | HTTP status, when applicable |
retryable | whether a retry could succeed |
endpoint | "POST host/path" for debugging |
requestId | backend handle — share it with support to correlate |
Messages redact only secrets (key / Bearer / stack); request_id and trace_id are preserved.
The three commercial kinds (trial_expired, no_subscription, quota_exceeded) are detected from
the body of a 403/429 — an exhausted quota is not retried as if it were rate limiting.
Network errors carry the root cause (ECONNREFUSED / ENOTFOUND / TLS…). Cancel any call with an
AbortSignal via params.signal.
Robustness
- Timeout covers everything — headers and the entire body/stream. The per-operation defaults
(200 s stream / 260 s dispatch / 35 s reads) sit above the backend's own turn budgets, so the client
receives the server's structured outcome (e.g. an explicit turn timeout) instead of aborting blind;
timeoutMsoverrides them. - Retries never duplicate effects — idempotent reads retry on network/429/5xx; side-effecting
POSTs (
invokeAgent,dispatchAgent,createTrigger) retry only on 429/503 and never on a network error (the request may already have been processed). Exception:invokeAgentwith aclientMessageIdalso retries on network errors and 5xx, only before the SSE headers arrive (the backend sends the headers before reserving the id and before any work of the turn). A stream cut mid-way isnetworkwithretryable: false: the backend cancels the turn and releases the id, so a resend runs the tools again.Retry-Afteris honored. - Memory safety — stream limits (
maxStreamEventBytes1 MB,maxStreamTotalBytes64 MB) abort withkind: 'protocol'if exceeded. - Transport safety —
redirect: 'error'(a cross-host 3xx would leak the key) andcache: 'no-store'on every request.
What changed in 0.7.0
^0.6.x does not pull 0.7 on its own. Minor because behavior changes: turns that used to resolve
now throw.
invokeAgentthrows onREFUSEDandCONTEXT_LIMIT(kind: 'blocked',code= the outcome). In 0.6 the backend's neutral sentence came back as the answer.SESSION_BUSYin anevent: errormoves fromserver/ not retryable toblocked/ retryable.IDEMPOTENCY_CONFLICT→config;CONTEXT_LIMIT/CONFLICT→blocked;NOT_FOUND→not_found; quota/trial/subscription → the commercial kinds. Messages for these codes come from the SDK (English), not from the backend.- A sync
dispatchAgentthrows whenoutcomeis notCOMPLETED,LENGTHorPAUSED, even withsuccess: true(an older backend withoutoutcomebehaves as before). - Type change:
classifyStreamErrorKindnow returns the fullZihinErrorKind(before:'timeout' | 'server' | 'config'). Code that assigned it to the narrow type stops compiling, and an exhaustiveswitchneeds adefault.errorFromStreamChunk(chunk, ctx?)gained an optional second parameter (compatible). - Type change:
AgentStreamChunkgains theaction_forwardedmember — an exhaustiveswitchonchunk.typeneeds adefault(before, these events arrived asunknown). - New optional
clientMessageId(UUID); with it, the/streamPOST retries on network/5xx before the stream opens.
Security
The client validates the base (and callback) URL by hostname against the Zihin whitelist. Set
allowCustomBaseUrl: true only for a trusted private deployment — the API key is sent to that host.
With allowCustomBaseUrl: true the built-in URL validation is disabled — enable it only for trusted,
controlled deployments.