@niadra/sdk is the TypeScript SDK. It runs on Node 20 and later, Deno, Bun, Cloudflare Workers and the Vercel Edge Runtime (the SDK’s CI runs the package on each), needs only fetch and Web Crypto, and ships ESM and CommonJS builds with full type definitions. It is open source under Apache 2.0. Every method on this page maps to a route of the API reference.
Install
niadra command ships with the package (npx niadra). The integrations are subpaths of the same package, each with its framework as a peer dependency; see Integrations.
The client
enabled is false when the client was built without a usable key; it then sends nothing. Create one client per process and share it: it owns the queue, the cache and the connection pool.
Safe by default, strict on request
By default every method is fail-open. Reads resolve with an empty result,track() returns null for an item it could not accept, and the reason is logged. Logs carry status codes, error codes and request ids, never handles, message text or the key. With strict: true the constructor throws configuration errors, track() throws validation errors, reads throw request errors and flush() throws lost batches.
Time budgets
keepAliveMs after its last answer. A call for which no idle connection is likely left (every open one is busy, or none answered within keepAliveMs) gets connect on top of its budget, for TCP and TLS, which take a few round trips that a 300 ms read cannot hold from another continent: a turn that reads its context, notes and state at once, with only the connection keepWarm holds, opens two more, and both get it. The calls that start while the first one opens a connection get it too, and then none does until an answer comes, so an outage spends it once. A call that finds an idle connection keeps its budget exact, and a voice read never grows past its own budget: on a cold connection it answers empty in time instead of holding the turn. connect: 0 never adds it. context and navigation you do not set are the API’s time: the client measures the round trip to the region once when it starts (voice.probe) and adds it to those two, so an agent far from the region (Sao Paulo is 170 ms from us-east-2) does not lose its read to the network; the measurement also opens the connection the first read uses. A value you set is a ceiling; when the round trip plus 50 ms exceeds it, the client logs one warning. While the measurement is on its way and a connection is open, those two get connect on top of the default, so the first read does not run out for want of it; with no connection open, connect already comes with the new connection, never twice. A measurement that failed, or probe off, leaves the defaults as they are. While a conversation or task is open (created and not ended) and the client was used in the last 10 minutes, it sends GET /healthz every 100 s in which nothing else went out for 90 s (one attempt, 2 s), on a timer that never keeps a Node process alive: the connection stays open as long as your fetch keeps it, and a turn after a long pause does not pay TCP, TLS and often DNS again. These calls are not the client’s use; keepWarm: false never sends them. keepAliveMs (a client option, 4,000 ms by default) is how long your fetch keeps an idle connection: undici’s default over HTTP/1.1. A fetch built on an undici Agent({ keepAliveTimeout: 120_000 }) keeps a conversation’s turns on one connection; say so in keepAliveMs, and connect is spent only when a connection really closed.
In voice, contextVoice (200 ms) is not a round trip to the region: the pinned body comes from the client’s memory, and the budget only covers the wait for the read of the turn’s words that a prefetch() already started; past it, the turn goes out with the body and no slots, and the read goes on in the background, within prefetch. contextVoiceStart bounds the call’s first read, made by begin() while the phone rings: a cold connection costs three round trips (TCP, TLS and the request) plus the server’s first compile. Outside voice, the first read of a conversation, task or object in a client, and its first agentMemory(), get contextFirst instead of context while context is at its default: the API compiles the pack on that read (410 ms measured on the server), and the next reads of it find the pack compiled. The partial must stay the same for voice.settleMs before the client reads the turn with it, and that read’s slots answer the final turn when it starts with the partial’s words and the partial carries at least voice.minCoverage of them; with voice.probe, the client measures the round trip to the region once, when it starts (GET /healthz; niadra.rtt), and logs a warning when a budget you set, or a voice budget after the first voice read, cannot hold it; probe: false measures nothing.
Override them per client with timeouts, or per call with { timeout }. Pass { signal } to cancel a call. Reads are retried only on 421 (the space moved to another cell), at once, up to three attempts. Writes are retried on 408, 421, 429 and 5xx with exponential backoff and full jitter; other 4xx answers are never retried.
The context cache
Inside a conversation or task, packs are cached in memory:- younger than 10 s: returned without a request;
- up to 10 minutes older: returned at once while one background request refreshes it;
- when a request fails: the last good pack, if it is less than 30 minutes old;
- at most 1,000 packs, the least recently used evicted first;
- in the
voiceview, with aconversation_idortask_id: the pinned body is served from memory at once, whatever its age, and revalidated by ETag in the background; each turn’s slots come from the read of its words and are never cached. Ending the conversation drops its packs and what the voice read kept; an answer still on its way is not stored.
not_modified answer instead of the full text, and only one refresh per pack runs at a time. A 401 or 403 is not an outage: the cached packs go (all of them on 401, the one requested on 403), so cutting a vendor’s access also cuts what it had cached. A plain read and a delta read of one conversation share one entry, and each delta is handed out once.
The write queue
track() and action() return at once. Events leave in batches: when 15 are waiting or every second, up to 100 per request, three attempts each; a batch that still fails with an error that may pass (a connection, a 408, 421, 429 or 5xx) goes back to the front of the queue and leaves again after a pause that doubles up to a minute, under the same idempotency keys, so it arrives once when Niadra is back. A message with a conversation_id leaves at once (turnFlushIntervalMs, 0 by default), taking whatever is waiting along: it is the turn the other agents read in live. One batch is in flight, and what is queued while it is answered leaves together in the next one. When 10,000 events are waiting, new ones are dropped and logged. niadra.dropped counts the items that will never reach memory and that no caller heard about: a full queue, a batch the API refused, or one item a 207 refused alone; niadra.droppedByReason splits them by queue_full or the API’s code (unknown_object, say). A write that identify(), verify() or handoff() waits for tells its caller in the WriteResult instead, unless the caller stopped waiting.
Handles
A handle identifies a subject in a channel or system. The builders set the type and scope; the server normalizes the value.
Builders for people take an optional
{ subjectKind } to mark an organization. toObjectRef("invoice:erp:0823") turns the shorthand into an ObjectRef; the id may itself contain colons.
context()
The context pack for a person, an organization or a business object. Maps toPOST /v1/context.
options takes timeout, signal, headers (such as a traceparent) and cache: false to skip the cache for this call.
Resolves with a ContextResult, always usable:
With
format: "json" in the request, response.pack carries the pack as data (ContextPack, in the context-pack.v1 shape): typed sections, preamble, variables and stamp. The schema is in spec/context-pack.v1.json in the repository, and a test keeps the types equal to it.
renderSuffix(response) and renderLive(response) build the same suffix from a raw response.
search()
Searches the whole history of one subject by keyword and meaning. Maps toPOST /v1/history/search.
Every navigation call resolves to a
Result: { data, error }, with exactly one of the two set. data carries items, recurrence, withheld, as_of, tokens_used, degraded, window (the period when was read as) and ignored (the filters the server did not read).
timeline()
One page of the subject’s history, most recent first. Maps toPOST /v1/history/timeline.
subject, about, filters, cursor, limit (1 to 100, default 20), verification and conversation_id. data carries items, next_cursor, withheld and as_of.
open()
Opens one episode or object fromsearch() or timeline(), by the row’s id (a bare UUID). It maps to POST /v1/history/open: the item id, the level and the conversation_id go in the body, never in a URL.
params takes verification, conversation_id and subject: with subject, the server opens only an item of that customer, and the tools() kit sends the bound customer. data also carries versions when the item has them, oldest first.
objectState() and objectTimeline()
Reads of a business object. They map toGET /v1/objects/{object_type}/{namespace}/{external_id} and its timeline.
ObjectRead is the object as a display state read serves it: each field with its logical value, stamps and freshness. The state comes only from what the systems of record reported; an agent’s action counts once a system confirms it. The timeline lists system events and agent actions, newest first, never what anyone said. Object ids are record ids, not personal data, so they go in the URL; an id with a slash cannot be addressed that way. limit goes from 1 to 100, default 20.
track()
Records a message, a system event or an action. Returns at once with the item’s idempotency key, ornull when the item was dropped. Maps to POST /v1/batch.
action()
Records what an agent did in a system of record. Queued, liketrack(); recording actions needs the act scope.
ActionEvent takes the same fields as track() plus operation (required, such as credit), result (kept whole, up to 200,000 characters), purpose, closes (the open item the action fulfils, by item_id or by object and operation) and corrects_action_id. speaker defaults to ai_agent. The action stays declared until the system of record confirms it.
identify(), verify() and handoff()
These do not wait for a batch: they go out at once, with whatever is already queued, and resolve to aWriteResult: { ok: true, idempotency_key, error: null } or { ok: false, idempotency_key, error }. A context() call made after verify() resolves already reflects the new level, and one made after identify() resolves already sees the new handles and, when they belonged to two profiles that already existed, the merged profile.
A
verify() level above the ceiling of the source fails with verification_not_allowed.
feedback()
Corrects what Niadra derived about a subject. Sent at once and recorded as an event, so it is audited like any other. Maps toPOST /v1/feedback.
subject is always required; fact_id and open_item_id take the id as search and the timeline list it: a bare UUID. A prefixed id is refused with 422. A fact or an open item the subject does not hold, in any status, is refused with 404 not_found (one error per item in a batch), never stored to be skipped; with an account or a partner as the subject, an open item one of its linked contacts noted counts, and resolves under that contact. The correction applies in seconds, after the event is stored. value goes up to 2,000 characters and reason up to 500.
feedbackBatch()
Up to 500 corrections in one call, each with the fields offeedback() and its own idempotency_key (one is minted when missing). Maps to POST /v1/feedback/batch and resolves to Result<BatchResponse> (accepted, duplicates and one error per refused item).
contextUse()
How the space’s agents used the context they read, asGET /v1/context-use reports it: per bucket, sessions, sessions without context, deliveries, use, repetition, transfers and recontact, with 95% intervals. group_by takes day, source_id (the default), vendor, channel, view and experiment_group; since, until (dates), source_id, channel, view and experiment_group filter. Resolves with { data: ContextUseReport }: typed buckets, the other blocks as sent. The key of an analyst source with the analytics scope reads every source of the space; a key with admin reads its own.
link() and endLink()
A system of record that knows who works for whom (a CRM, an HR system) links a person to the account or partner they act for, and ends the link. Maps toPOST /v1/identity/links and POST /v1/identity/links/{link_id}/end, with an idempotency key. Needs a key with the identity:link scope (or admin); can_see_contacts: true needs admin. Resolves with { data: Link } (link_id, role, valid_from, valid_to).
ingestStatus()
Whether what was sent for a conversation or task became memory yet. Maps toPOST /v1/ingest/status.
data carries state (open, processing, ready, failed or unknown), last_event_at, closed_at, close_reason and extraction, never content. Call flush() first when the turns went through track().
whoami()
What this key authenticates as:space_id, source_id, source_name, vendor, scopes, audience, verification_ceiling, purposes and agent_memory. Any key may ask; use it to check a key before wiring an agent to it. Maps to GET /v1/sources/me.
niadra.admin
For a key with theadmin scope, the governance reads and writes, resolving { data, error } and failing open like the rest of the client: findProfiles, memory (profile memory), factHistory (fact history), correct and correctBatch (data subject corrections), forget and forgetStatus (erasure) and export (the data subject’s package). The types are in @niadra/sdk (ProfileMemory, FactHistory, ProfileMatch, Erasure…). An agent key lacks that scope: the routes answer 403.
uploadMedia()
Hands a file to Niadra, such as a call recording, and resolves with the reference for its event. Media never travels inside an event. Maps toPOST /v1/media/uploads.
upload_headers the API returned, which are the headers the signature covers, and nothing else: never your key or defaultHeaders. The URL must be HTTPS, except against a local endpoint served over HTTP. Storage checks the body against the declared size and digest. With subject, the file is stored under that person, so erasing them erases it even if no event ever references it.
data carries media_ref, media_sha256, content_type, size_bytes and expires_at.
conversation()
A helper for one customer thread: it reads the pack the server pins, keeps the deltas, captures turns and ends the conversation.
After the first pack, each read also asks for the delta, and the conversation keeps every delta it receives, in order, at the end of
suffix, after the live turns and the slots. The conversation sends the text of the last customer() as query on every read: the pinned pack is cached as a read without query, and the slots never are. When the server pins a new pack, after verify() for instance, the kept deltas are dropped: the new pack already has them. query picks this read’s slots by other words than the turn; the pack is the pinned one all the same. Call markInjected() each time you put the pack in a prompt: it is how Niadra tells a context that arrived after the agent spoke from one the agent had and did not use.
task()
The same for an internal agent (billing, collections, triage). A task centers its pack on its object, keeps deltas and stamps like a conversation, scopes the cache and ends withtask.ended.
TaskParams takes channel (required, the internal agent or system, such as billing-agent), task_id (a UUIDv7 when omitted), subject, object, about, view (default brief; use a task view such as task:billing), verification and target. A task has context(), markInjected(), agent(), track(), action(), verify(), tools() (null without a subject) and end().
tools()
The navigation kit as function-calling tools, with the customer bound in the SDK rather than in the tool arguments. The model chooses what to look for, never whom it is about, so a prompt injection has no argument to switch customers with.binding takes about, verification, conversation_id, task_id and voice (use the voice time budget).
TOOL_DEFINITIONS and TOOL_NAMES are exported. For APIs that expect { name, description, input_schema }, map function.parameters to input_schema. The definitions are also served by GET /v1/history/tools.
subjectToken()
Mints a signed token, valid for 15 minutes, that binds one customer, conversation and verification level. Call it from your backend and pass it to the MCP connection. Maps toPOST /v1/subject-tokens.
params takes subject, about, conversation_id, task_id and verification. data carries token and expires_at. Send the token in the Niadra-Subject-Token header, next to the source key. See MCP with any LLM.
agentMemory(), searchAgentMemory() and remember()
The agent’s own memory: its working notes, never about a customer. They correspond toGET /v1/agent-memory/block, POST /v1/agent-memory/search and POST /v1/agent-memory/notes.
agentMemory() resolves with an AgentMemoryResult, always usable: text (the block, for after your instructions and before the customer’s context), notes (the ids of the notes in it), etag, tokens, enabled (false when the space has agent memory off), source (network, cache, fallback or none) and error. The block is cached for as long as the context and revalidated by ETag. Inside a conversation or task, agentMemory() uses the session’s view.
remember() takes kind (procedure, tool_note, process_note or pitfall), title (up to 120 characters), body (up to 2,000), tags (up to 8, lowercase), evidence, visibility (source, the default, vendor or space) and valid_until, and resolves with the saved note or the proposal_id of the proposal waiting for a person. A note with personal data comes back with error.code equal to personal_data_in_agent_memory: rewrite it without the data. It needs a key with the agent_memory:write scope.
wrap()
Wraps an OpenAI-compatible client so every call gets the context and records the answer.chat.completions.create and chat.completions.parse call through the wrapper, streaming or not, gets the pack after your leading system messages and the suffix at the end. The injection is stamped, and the model’s answer (its first choice) is recorded as the agent’s turn: at once, or when a stream ends or you stop reading it. .withResponse() keeps working and records too; .asResponse() returns the raw HTTP response, so nothing is recorded then. Pass a function instead of a session to pick one per call; when it returns null, the call passes through untouched. Nothing the wrapper does can fail your call: a context it cannot fetch is left out, and a failure to record the answer is logged, without content. injectContext(context, messages) does the placement alone, without wrapping.
The provider’s prompt cache
The agent’s turn also carries the usage the provider reported for the call, asusage (ModelUsage): the provider, the model, every input token (usage.prompt_tokens), the ones it read from its prompt cache (usage.prompt_tokens_details.cached_tokens) and, through gateways that pass Anthropic’s fields along, the ones it wrote to it. A stream reports usage only when you ask for it, with stream_options: { include_usage: true }; the wrapper never changes your request to get it. Niadra sums the usage per agent, vendor and model, and the Console and GET /v1/usage/prompt-cache show the cache hit rate and the estimated savings.
Without wrap(), pass the provider’s response with the agent’s turn. OpenAI responses (chat completions and Responses) and Anthropic ones are read; on Anthropic, usage.input_tokens counts only the uncached rest, and the total is input_tokens + cache_read_input_tokens + cache_creation_input_tokens.
modelUsage(response) reads a response on its own. A response without usage is left out, and the turn is recorded either way.
Integrations
Every framework has a subpath of the package, with the framework as an optional peer dependency:@niadra/sdk/livekit, elevenlabs, vapi, whatsapp, twilio, ai-sdk, mastra, langchain, openai-agents, anthropic, google-genai, bedrock, retell, llamaindex, genkit, voltagent, google-adk, strands and cloudflare-agents, plus the n8n-nodes-niadra and flowise-nodes-niadra packages in the repository. All of them wire the context, the turns, the tools, the verification and the handoff into the framework’s own extension points and take agentMemory. See Integrations.
flush() and shutdown()
flush() sends every queued event. Call it before a serverless function returns. shutdown() flushes, stops the background timer and releases the exit hook; call it from your SIGTERM handler in long-running services, because beforeExit does not fire on signals or process.exit().
Turn records, claims, coordination and working memory
Everything in this section depends on the features the space turned on.await niadra.profile() returns the SDK profile (the features on, the claim contract, the recording mode), read on first need and revalidated by ETag; with a feature off, each call below resolves with an empty value or goes on without it, never rejects.
Turn records
Niadra.build(pins) makes the pins (prompts, corpus_digest, model, assembler, tool_schemas). convo.turn(params?, fn) (and task.turn()) runs fn inside a turn, which follows the AsyncLocalStorage (useAsyncLocalStorage() supplies one where node:async_hooks is missing); convo.openTurn(params) opens one by hand, for adapters, and convo.activeTurn() returns the turn in progress. Niadra.tool(name, fn, { provenance, ui, args, dryRun, callId, frame, binding, maskOutput, onUnknown }) records each call of a tool of yours. With binding, it also measures the constraints block; without one, it uses the binding the profile serves for the tool of that name. maskOutput: true keeps the fields the key may not read out of the output (deny removes, mask masks, by the profile’s field_access); left unset, it follows the served binding’s capabilities.mask_output. onUnknown: "block" withholds the whole output when no profile was ever read; "pass", the default, lets it through. new Niadra({ turns: { contentMode, maxBytes, maxTurns, intervalMs } }) configures the queue, and niadra.turns.store(put) puts the values in your bucket (put(key, data) => pointer, pointer mode). canonicalJson() and jsonDigest() compute a value’s digest as every producer does. The LangChain and LangGraph (new NiadraCallbackHandler(convo, { turns: true })) and Mastra (niadraProcessor({ turns: true })) adapters open and close the turn for you; on the Vercel AI SDK, niadraTurn(convo) keeps a streamText turn open until the stream finishes and recordTools(tools) records each call. OpenAI Agents JS, Google ADK and VoltAgent also record turns with turns: true; the OpenAI Agents JS and VoltAgent hooks cannot stop a tool, so wrap their tools with Niadra.tool() for a replay to answer them from the record. See Turn records.
Claims and resolvers
convo.claims.check(text, { context, immutable, agent }) classifies and counts, without changing the text; convo.claims.guardText(text, options) resolves with the text as the contract’s actions leave it, and convo.claims.guard(stream, options) does the same over a stream. niadra.claimContract(contract) swaps the profile’s contract for your own copy. convo.verifyClaim(ref, field, value, { budgetMs }) (and niadra.verifyClaim(..., { subject, budgetMs })) says whether a value may be claimed now and, when it is not safe, reads it again through the type’s resolver, within 300 ms: niadra.resolvers.register(type, fn, { rate }) registers a function of yours (ref, fields) => Resolved | Record, which returns NOT_FOUND when the source no longer has the object; niadra.resolvers.fetch() says why a read brought no object. niadra.content.register(fetch) puts back the text a pointer mode space keeps in your storage. exposureToken() and parseExposureToken() handle the exposure token; renderConstraints() and honoredConstraints() expose the constraints block rendering; expr is the niadra-expr language. See Claims.
Coordination
convo.check(intent, { purpose, direction, channel, effectKey, effectKind, object, task, gatewayId, destinationHash, failOpen, timeoutMs }) waits at most 200 ms and, with no answer, decides by the purpose’s direction; it never rejects. Every check about an outbound contact keeps the local copy of the suppression list, read again in the background once a minute, and with Niadra down the opt-out holds by it. convo.declare.kind(kind, detail), effect(key, state, attempt?) and contactMade(decision, { purpose, channel, gatewayId, jti }) send in the background. convo.claim({ object, task, kind, leaseS, level, intents, timeoutMs }) resolves with a Claimed (held, claim, error). niadra.mayContact(handle, purpose, { channel, at, failOpen }) applies the local copy of the suppression list. The first call reads the copy, the salt and the first page each within navigation; a list longer than a page, or a read that ran out of time, goes on in the background, and the next call has the copy. An entry with a window (the hours the customer asked for, as “don’t call before 9 am”) says no only inside those local hours, at the moment at (now by default), and with Niadra down a check inside the window waits until it ends (contact_window); windowUntil() applies the window’s rule. With no copy and Niadra out of reach, the purpose decides: transactional and service go, the others wait; failOpen overrides that. niadra.contactGateway(id, { space, key, seen }) returns a ContactGateway with verify(token, { channel, destination | handle }), which rejects with NiadraContactTokenError; verifyContactToken(token, params) checks a token against a fixed key set, and canonicalDestination() and suppressionKey() compute a destination’s key. See Coordination and Gateways.
Working memory
convo.agentState.get() resolves with a WorkingState (body, version); put(body, { mode, ifVersion }) resolves with a StateWrite (stored, version, reason, pending); DELETE marks a field to remove in a merge, and conflicts lists the resent compare-and-swaps that met another version. See Working memory.
Replay, worker and command line
new Replayer(niadra, agentFactory, { build, read }).run(scenarioIds, { runs, mode, vary, paraphrase }) runs the scenarios in your CI and resolves with a ReplayRun (runId, status, verdict, scenarios). Counterfactual runs the tool counterfactual as the Python runner does, sending Niadra only overlaps and positions, and overlapAtK() is the depth-weighted overlap the report uses. new ResolverWorker(niadra, { limit, pollMs, budgetMs, resolvers }) serves the refresh requests with runOnce() or run(signal), with watch revalidation: it serves watch_revalidation requests first, pushes each object it read with the request_id it answers and releases what it cannot answer. The niadra command (Node, npx niadra) has replay, counterfactual, resolver-worker, types derive (with --check) and contract test, with the Python command’s arguments and exit codes; niadra.api has one method per route (niadra.api.check(body), niadra.api.promoteTurns(body), niadra.api.overview({ days }), niadra.api.notifications({ cursor, limit })), in camelCase, which rejects with the API’s error. See Replay in your CI.
Examples
One runnable example per concept, in the SDK repository, each run by the tests:Errors
Every error extendsNiadraError.
The full code catalog is in Errors. Include
requestId when you contact support.
Next steps
Python SDK
The same surface, synchronous and asynchronous.
Quickstart
From a key to the first delivered context.
MCP with any LLM
Eight customer tools with the customer bound by a subject_token.
Errors
The code catalog and what to do with each code.

