Skip to main content

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
});
OptionDefaultDescription
apiKey—Required. zhn_live_* / zhn_test_*.
baseUrlhttps://llm.zihin.aiOverride for a private deployment.
timeoutMsper operationMax total time for the whole call (headers and stream). Defaults: 200 s stream · 260 s dispatch · 35 s short reads.
maxRetries2Retries with exponential backoff.
allowCustomBaseUrlfalseAllow a baseUrl outside the Zihin domain whitelist (SSRF opt-in).
allowHttpfalseAllow http/localhost (development only).
fetchglobalInject 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);
}
Session continuity

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​

Since version 0.6.1

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? } is consumerKey with the name attached (consumer.key and consumerKey are the same field; send one). Same rule as consumerKey: only valid with an admin/owner API key. The name is sent as consumer_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.key per 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 the name does 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 in ignoredFields. The agent owner can turn suggestions off (quick_replies_enabled) and the agent decides when to suggest. See the contract.
  • suggestions (in AgentResult and in the response chunk) are the labels of the quick_replies block: up to 4, short. Tapping one means sending its text as the next message. In a suggestions-only turn, content is empty.
  • Deprecated: options.jitMode, options.temperature and options.maxTokens have no effect on /stream (temperature and output cap are agent configuration). They are still sent and the server lists them in ignoredFields; the client emits a ZihinDeprecationWarning once per process. They are removed in 1.0.

Idempotency: clientMessageId (0.7)​

Since version 0.7.0

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, on invokeAgent and streamAgent) makes the turn idempotent and sends client_message_id (see the contract). With it, the client also retries the /stream POST 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 with COMPLETED/LENGTH → the backend replays the stored answer for 24 h, with idempotentReplay: true and no usage. 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 gives config (IDEMPOTENT_REPLAY_OTHER_SESSION) — this guard exists only in invokeAgent with an explicit sessionId; with sessionFrom, without a session, or in streamAgent, a reused id silently returns the answer (and the session_id) of the original conversation.
  • SERVICE_UNAVAILABLE (replay with no recoverable answer) is not retryable: it can last 24 h and only a new clientMessageId gets out of it.
  • Against a server that does not list client_message_id in accepted_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 as response and records the execution as an error; the client throws blocked with code: '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_BUSY as an event: error. The client classifies it as blocked and retryable (the turn never started).
  • capabilities.blocks accepts 'html_report': your screen renders HTML, and the agent may then deliver an HTML document inside an html fenced code block in content (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​

OperationRequired role
invokeAgent / streamAgentany role
createTrigger / getExecutionadmin or editor

A member key calling a restricted operation gets a ZihinAgentError with kind: 'auth'.

Errors​

Every failure is a ZihinAgentError:

FieldMeaning
kindconfig · auth · trial_expired · no_subscription · quota_exceeded · not_found · rate_limit · timeout · aborted · server · network · protocol · blocked · unknown
codethe backend code or turn outcome (e.g. REFUSED, CONTEXT_LIMIT, SESSION_BUSY, TURN_TIMEOUT)
statusHTTP status, when applicable
retryablewhether a retry could succeed
endpoint"POST host/path" for debugging
requestIdbackend 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; timeoutMs overrides 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: invokeAgent with a clientMessageId also 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 is network with retryable: false: the backend cancels the turn and releases the id, so a resend runs the tools again. Retry-After is honored.
  • Memory safety — stream limits (maxStreamEventBytes 1 MB, maxStreamTotalBytes 64 MB) abort with kind: 'protocol' if exceeded.
  • Transport safety — redirect: 'error' (a cross-host 3xx would leak the key) and cache: '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.

  • invokeAgent throws on REFUSED and CONTEXT_LIMIT (kind: 'blocked', code = the outcome). In 0.6 the backend's neutral sentence came back as the answer.
  • SESSION_BUSY in an event: error moves from server / not retryable to blocked / 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 dispatchAgent throws when outcome is not COMPLETED, LENGTH or PAUSED, even with success: true (an older backend without outcome behaves as before).
  • Type change: classifyStreamErrorKind now returns the full ZihinErrorKind (before: 'timeout' | 'server' | 'config'). Code that assigned it to the narrow type stops compiling, and an exhaustive switch needs a default. errorFromStreamChunk(chunk, ctx?) gained an optional second parameter (compatible).
  • Type change: AgentStreamChunk gains the action_forwarded member — an exhaustive switch on chunk.type needs a default (before, these events arrived as unknown).
  • New optional clientMessageId (UUID); with it, the /stream POST 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.

warning

With allowCustomBaseUrl: true the built-in URL validation is disabled — enable it only for trusted, controlled deployments.