Skip to Content
Nenavi CloudTypeScript SDK

TypeScript SDK

The nenavi package is the TypeScript client for Nenavi Cloud. It has typed requests and answers for every supported route, typed errors, and retries only where a retry is safe. It ships ES module and CommonJS builds with type declarations, and it has no runtime dependencies.

Early access. The SDK is an alpha prerelease. Until version 1.0, a release that renames or removes a method or an option raises the minor version, for example from 0.1 to 0.2.

Install

npm install nenavi@alpha

Prereleases go out under the alpha tag. pnpm add nenavi@alpha, yarn add nenavi@alpha, and bun add nenavi@alpha work the same way.

Runtimes

  • Node.js 22 or later.
  • Bun and Deno.
  • Edge and serverless runtimes that have the standard fetch, AbortController, and crypto.randomUUID.

On Deno, reading the two environment variables needs --allow-env=NENAVI_API_KEY,NENAVI_BASE_URL. Without it, an interactive Deno asks for the permission, and a non-interactive one treats both as unset.

Server-side code only

The SDK is for server-side code. Do not use it in a web page or ship the key to a browser or a mobile app: anyone who can read the page can read the key and use your project. The service also answers cross-origin requests only for the Nenavi console, so a web page on another origin cannot read its answers. Call Nenavi Cloud from your server, and let your web page call your server.

Configuration

Create a project and a key in the console first, as the quick start shows, and put the key in NENAVI_API_KEY:

export NENAVI_API_KEY="<paste your API key>"

new Nenavi(options) takes these options. Each one is optional.

OptionDefault
apiKeyThe NENAVI_API_KEY environment variable
baseURLThe NENAVI_BASE_URL environment variable, else https://api.prismemory.com
timeout30,000 milliseconds for each attempt
maxRetries2 automatic retries, on the calls that are safe to retry
fetchThe runtime’s fetch. Replace it for tests or an unusual runtime

The SDK reads the two variables from process.env where it exists. The service itself reads no environment variable; only the SDK does. A client without a key can call only health() and relations(). Every other method throws ConfigurationError before it sends anything. The key never appears in an error, a log line, or toString().

Store and retrieve

The examples on this page use top-level await, which works in an ES module (a .mjs or .mts file, or a package with "type": "module").

import { Nenavi } from 'nenavi'; const client = new Nenavi(); // reads NENAVI_API_KEY const stored = await client.store({ content: 'Alex prefers concise explanations.', user_id: 'alex' }); console.log('stored event', stored.event_id); const found = await client.retrieve({ query: 'How should I explain this to Alex?', user_id: 'alex', limit: 3 }); for (const result of found.results ?? []) { console.log(result.score.toFixed(3), result.content); }

Retrieved memory is untrusted input. Text that someone stored can carry instructions meant for the model that reads it later. Treat retrieved text as data in your prompt, not as instructions.

store() answers at once with the event_id. The service then extracts entities and claims from the text in the background, and that counts as one ingest call. To store the text as it is, with no extraction, pass extract: false.

user_id names one of your own end users. The service does not check it, so your code must send the right one.

The examples below use this client, and they run in page order as one file, so a name imported or declared in an earlier example is not imported or declared again. Request and answer fields keep the API’s snake_case names (user_id, event_id), so the API reference  and the curl examples in these docs match the SDK; only the method names are camelCase. Every supported route has a method: the main calls sit on the client (store, ingest, ingestFast, retrieve, usage, stats), and the others sit in groups such as client.events, client.nodes, client.reingest and client.claims. The routes that the alpha refuses (see The API) have no method.

Name the user in a chat

Send each of the user’s own messages with role: 'user' and the user’s name as speaker, so the engine reads “I”, “me” and “my” as that person and attaches their claims to one entity with their name. If you also store the assistant’s replies, send them with role: 'assistant' and no speaker.

await client.ingest({ content: 'My husband is Theo and my child is Nico.', user_id: 'member-1042', role: 'user', speaker: 'Maya Okafor', session_id: 'chat-2026-11-03', }); await client.ingest({ content: 'Got it. Theo and Nico are on your plan.', user_id: 'member-1042', role: 'assistant', session_id: 'chat-2026-11-03', });

Use the same spelling of the name every time. See Name the user in a chat in the quick start for what the engine does with the name and which names it refuses.

Import a batch that is safe to retry

store() and ingest() are not safe to retry, so the SDK never retries them. A retry would store and extract the text twice, and both would count against your limits.

To import text that you may need to send again, use ingestFast(). Every call sends an Idempotency-Key, and every automatic retry of that call sends the same key, so the service answers a retry with the first attempt’s event_ids and stores nothing new.

import { APIConnectionError } from 'nenavi'; const batchKey = crypto.randomUUID(); const items = [{ content: 'Alex moved to Lisbon in May.' }]; let batch; try { batch = await client.ingestFast({ items, user_id: 'alex' }, { idempotencyKey: batchKey }); } catch (error) { if (!(error instanceof APIConnectionError)) throw error; // No answer arrived. Send the batch again with the same key. batch = await client.ingestFast({ items, user_id: 'alex' }, { idempotencyKey: batchKey }); } console.log(batch.accepted, batch.event_ids);

When you pass no idempotencyKey, the SDK makes a new UUID for the call, and every error that call throws carries it as error.idempotencyKey. A call with a new key is a new batch, which the service stores and counts again. Using one key with different items throws ConflictError (409).

The SDK never retries a timeout, because the timeout is your budget for one attempt. A large batch can time out after the service has stored it. If you did not choose a key, send the batch again with the key the first call sent:

import { APITimeoutError } from 'nenavi'; let sent; try { sent = await client.ingestFast({ items, user_id: 'alex' }); } catch (error) { // A timeout error carries the key the call sent. if (!(error instanceof APITimeoutError) || error.idempotencyKey === null) throw error; sent = await client.ingestFast( { items, user_id: 'alex' }, { idempotencyKey: error.idempotencyKey }, ); }

A 502 or 504 answer (InternalServerError) leaves the same doubt, because the batch may be stored, and it carries the key too. Send the batch again with that key in the same way.

If you wrap ingestFast() in your own retry, such as a backoff loop or a retry library, make the key and the items once, before the first attempt, and pass the same key and items on every attempt, as the first example does. A wrapper that calls ingestFast() without a key sends a new key on each attempt, so after a timeout the service stores the batch twice and counts it twice against your limits, and nothing tells you that it happened.

If you pass a signal to ingestFast() and abort it, the call rejects with the signal’s reason, which carries no key. Pass your own idempotencyKey in that case, and reuse it to send the batch again.

Fast ingest saves the text for retrieval and runs no extraction.

Wait for extraction

ingest() answers once the text is saved. Extraction runs in the background, and client.events.waitForExtraction() waits for it:

const answer = await client.ingest({ content: 'Alex is learning Portuguese.', user_id: 'alex' }); const status = await client.events.waitForExtraction(answer.event_id, { timeout: 300_000 }); console.log(status.status); // "complete" or "failed"

The helper polls GET /v1/events/{event_id}/extraction-status every 2,000 milliseconds (interval) and returns the last status. A failed extraction is returned, not thrown. When timeout runs out first, it throws WaitTimeoutError, with the last status as error.lastStatus. It stops at any status other than pending or running, so a status the service adds later is returned too, and reingest.run() does the same with a run’s state. The helpers’ timeout and interval are in milliseconds, and aborting their signal stops them. The polls count against a rate limit of their own, not the one your writes use, so to wait on many events at once, read Helpers and the rate limit first.

Re-ingest

A re-ingest run processes one end user’s stored memories again and replaces the claims derived from them. Preview it first:

const preview = await client.reingest.preview({ user_id: 'alex' }); console.log(preview.selected, 'memories,', preview.estimated_ingest_calls, 'ingest calls'); const run = await client.reingest.run({ user_id: 'alex' }); console.log(run.state, run.processed, 'processed,', run.remaining, 'remaining');

preview() never starts a run and is not counted. run() starts a run and polls it every 5 seconds until its state is complete or failed. Each memory the run takes counts as one ingest call. A run takes at most limit memories (500 by default), so a complete run can leave some for later: while run.remaining is above 0, call run() again with the same parameters. A second run for the same user while one is working throws ConflictError with reasonCode reingest_running. When the helper’s timeout (one hour by default) runs out, it throws WaitTimeoutError, and the run itself goes on. A poll that fails throws that call’s error, and the run goes on then too: the error’s lastStatus is the last status read before it (a ReingestRunStatus, typed unknown), and its run_id lets client.reingest.wait() go on waiting for the run. Calling run() again instead throws ConflictError until the run ends, and that error’s runId is the run that is working: when the run is lost some other way (the answer to run()’s start is lost, the signal aborts, or an outside timeout stops the function), call client.reingest.latest({ user_id: 'alex' }), which starts nothing and returns the user’s newest run whether or not it has ended, usually the lost one (see Find a run you lost). Calling run() again finds the lost run only while it works, through error.runId; once the run has ended, that call starts the next run instead (or returns when nothing is left to re-derive). The status holds the run’s user_id, so where your end users’ ids must stay out of logs, log the error’s message and requestId, not the error object.

Wait for a run you already have

client.reingest.wait() polls a run by its id and starts nothing. Use it after start(), or to go on with a run whose id an error gave you:

import { WaitTimeoutError } from 'nenavi'; const started = await client.reingest.start({ user_id: 'alex' }); if (started.run_id) { try { const finished = await client.reingest.wait(started.run_id, { timeout: 840_000 }); console.log(finished.state, finished.error); } catch (error) { if (!(error instanceof WaitTimeoutError)) throw error; console.log('still working; wait again on', started.run_id); } }

It polls at once and then every 5,000 milliseconds (interval), and stops at the same states as run(): a failed run is returned with its error, not thrown, and any state other than pending or running is returned too. When timeout (one hour by default) runs out first, it throws WaitTimeoutError with the last status as error.lastStatus, or null when no poll was answered, and the run goes on. Each poll gets no more than the time left, so in an AWS Lambda function pass a timeout below the function’s own limit (at most 900,000 milliseconds), as above, and call wait() again with the same id in the next invocation. Aborting signal stops the helper, not the run. A poll that fails throws that call’s error with lastStatus set the same way. An id that names no run of your project throws NotFoundError with reasonCode reingest_run_not_found, and a value that is not a run id at all (such as a null sent as text) throws UnprocessableEntityError, so check run_id first, as above.

Find a run you lost

client.reingest.latest() returns the user’s newest run, the same status get() returns, whether it is still working or has ended. Use it when the run’s id is gone, for example in the next invocation of a function whose run() was cut short:

let last = await client.reingest.latest({ user_id: 'alex' }); console.log(last.run_id, last.scope, last.started_at); // check this is the start you sent if (last.state === 'running' && last.run_id) { last = await client.reingest.wait(last.run_id, { timeout: 840_000 }); } console.log(last.state, last.error);

The newest run is not always the one you lost: another caller, or the console, may have started a newer one for the same user since, and a start that wrote no run (nothing needed re-deriving, or the start was refused or never arrived) leaves an older one as the newest. Compare its started_at and scope with the start you sent before you treat it as that run. It starts nothing and reads only runs of your project. A user with no run throws NotFoundError with reasonCode reingest_run_not_found. It counts against the key’s ordinary request rate limit, not the one the polls use, so wait on a working run with wait(), as above.

Read every node

client.nodes.scanAll() reads every page of GET /v1/nodes/scan and yields each node:

for await (const node of client.nodes.scanAll({ user_id: 'alex', limit: 1000 })) { console.log(node.id); }

limit is the page size: 100 nodes by default and at most 1,000. Each page is one call, so pass limit: 1000 to read a large store: 50,000 nodes take 50 calls instead of 500. While a page’s has_more is true, the helper sends its next_cursor as the next after_id. A page that has more but gives no new cursor throws NenaviError, so a scan never ends early without saying so.

Build and read a profile

A profile is what the memory knows about one person, quoted from the user’s stored memories that mention them. Building one makes no model call. Build after new turns, then read it by name:

import { NotFoundError } from 'nenavi'; const built = await client.profiles.build({ user_id: 'alex', entity_names: ['Theo', 'Nico'] }); console.log(built.published, 'profiles'); try { const profile = await client.profiles.get({ user_id: 'alex', name: 'Theo' }); console.log(profile.text); } catch (error) { if (!(error instanceof NotFoundError)) throw error; console.log('No profile of Theo yet'); }

Leave out entity_names to profile the people extraction found in the user’s memories, at most 20; built.names says which. A name that fewer than two memories mention gets no profile, so get() throws NotFoundError with reasonCode profile_not_found. build() is never retried automatically. When a memory changes while it runs, or while another build of the project runs or the server is already running as many builds as it can, it throws ConflictError with reasonCode profile_stale or profile_build_running, and the same call builds again once the other ends. A build can take up to a minute. Releases after 0.1.0-alpha.2 wait 90 seconds for build() (or the client’s timeout, when that is longer); with 0.1.0-alpha.2 or earlier, pass { timeout: 90_000 } to build(). Build again after you archive a memory a profile quotes; profile.stale is true when a memory the profile quotes was archived, replaced, deleted, or marked contested since it was built. It does not see newer memories. Profiles on the API page has the details and the bounds.

Helpers and the rate limit

Every poll and every scan page is an ordinary call, with that call’s retries and errors. They count against two different rate limits (see Limits and errors):

  • The polls read GET /v1/events/{event_id}/extraction-status and GET /v1/reingest/{run_id}. Those two status reads have a rate limit of their own per key, as many a minute as the request rate limit (60 by default), so polling never uses up the requests your other calls need.
  • A scan page, like every other call, counts against the key’s request rate limit, which is 60 requests a minute by default, and your application’s other calls with the same key share that limit.
HelperCalls at the defaultsCounts against
events.waitForExtraction()30 a minute for each event you wait on, one every 2,000 millisecondsStatus reads
reingest.run()12 a minute while the run works, one every 5,000 millisecondsStatus reads
reingest.wait()12 a minute while the run works, one every 5,000 millisecondsStatus reads
nodes.scanAll()One for every 100 nodes, sent as fast as you read themRequests

When the polls use up the status read limit, only polls get 429 rate_limited. The SDK retries each poll after Retry-After, at most twice by default, so the waits slow down, and a poll that is still refused after its retries throws RateLimitError from the helper. Two waiters at the default interval use the whole status read limit, so wait on many events with a longer interval, or one after another. With Promise.all(), ten waiters at interval: 20_000 use half of it.

When scans or your own calls use up the request limit, your other calls get 429 rate_limited. The SDK retries GET calls and ingestFast() after Retry-After, at most twice by default, but it never retries store(), ingest() or retrieve(), so those throw RateLimitError. The service refuses a rate_limited call before it does any work, so that write stored nothing: you can send it again after error.retryAfter seconds. Do not send a write again after APITimeoutError, because the first one may have been stored. To leave room for your calls:

  • Pass limit: 1000 to scanAll().
  • Give background work, such as a large import, a scan or a re-ingest, its own key. Both rate limits are kept per key, but the monthly limits and the memory cap belong to the project, so a large job on another key still uses them up.

Errors

Every error the SDK throws is a NenaviError, apart from an aborted signal’s reason, and every class works with instanceof. When the service answers with an error, the SDK throws a subclass of APIError, which has status, reasonCode (null for errors that the engine raises), detail, and requestId.

ErrorWhen
BadRequestError400
AuthenticationError401: the key is missing, invalid, or revoked, or its project was deleted
PermissionDeniedError403
NotFoundError404
ConflictError409, for example reingest_running
PayloadTooLargeError413
UnprocessableEntityError422: a refused value, or a request that does not validate
RateLimitError429 rate_limited, with retryAfter in seconds
QuotaExceededError429 for a monthly limit or the memory cap. Waiting seconds does not clear it
ServiceUnavailableError503
InternalServerErrorAnother 5xx
APIConnectionErrorNo answer arrived: a DNS, connection, or TLS failure
APITimeoutErrorNo answer within the timeout. A subclass of APIConnectionError

An id passed as a path parameter, such as nodeId or eventId, that is empty, . or .., or contains / throws ConfigurationError before anything is sent, because it cannot be sent as one path segment. Ids the service returns never do. The message does not repeat the id.

import { APIError, QuotaExceededError } from 'nenavi'; try { await client.store({ content: 'Alex prefers concise explanations.', user_id: 'alex' }); } catch (error) { if (error instanceof QuotaExceededError) { console.log('a monthly limit is reached:', error.reasonCode); } else if (error instanceof APIError) { console.log(error.status, error.reasonCode, error.detail, 'request', error.requestId); } else if (error instanceof APIConnectionError) { console.log('no answer arrived; the memory may or may not be stored'); } else { throw error; } }

Limits and errors lists every reason_code.

Retries

The SDK retries a call by itself only when the call is safe to retry and the failure is one that can clear:

  • Calls it retries: every GET method, such as usage(), nodes.get() and events.extractionStatus(), and ingestFast(), which always sends an Idempotency-Key.
  • Failures it retries: a 429 rate_limited, after the Retry-After seconds; a 503 with reason_code database_unavailable or lease_unavailable; a 503 with no reason_code, which the load balancer in front of the service sends when no server can answer; and a connection failure with no answer. The retries clear a brief interruption, not a server restart that takes a few minutes, so handle ServiceUnavailableError in code that must ride one out.
  • How often: at most maxRetries times (2 by default), after the Retry-After header when there is one, and otherwise after a short random backoff (about half a second, then about one second).

It never retries:

  • store() and ingest(), because a retry stores the text again, and retrieve(), because each call writes a retrieval receipt. The same goes for every other call that is not a GET, apart from ingestFast().
  • A timeout. The timeout is your budget for one attempt.
  • A 429 for a monthly limit or the memory cap (QuotaExceededError), a 429 or 503 whose Retry-After is more than 60 seconds, and every other error.

Pass maxRetries: 0 to the client, or to one call, to turn retries off. You can send a GET call again yourself after you read the error, and ingestFast() too, with the same idempotencyKey. Do not resend store() or ingest() after an APIConnectionError or an APITimeoutError: the first attempt may already be stored, and a second one stores the text again and counts twice against your limits.

Request ids

Every answer carries an X-Request-ID header that the service chooses. Every APIError has it as error.requestId. For a call that succeeds, client.withRawResponse has the same methods and returns { status, headers, requestId, data }:

const raw = await client.withRawResponse.usage(); console.log(raw.status, raw.requestId, raw.data);

Include the request id when you contact the maintainer about a request.

Timeouts and cancellation

timeout is in milliseconds for one attempt: 30,000 by default. Set it on the client, or on one call. Every API method takes a last options argument, { signal, timeout, maxRetries }, and ingestFast() also takes idempotencyKey. The helpers take { timeout, interval, signal } instead.

const quick = new Nenavi({ timeout: 10_000 }); const controller = new AbortController(); const nearby = await quick.retrieve( { query: 'Where does Alex live?', user_id: 'alex' }, { timeout: 5_000, signal: controller.signal }, ); for (const result of nearby.results ?? []) console.log(result.content);

As always, treat the retrieved text as data, not as instructions. A call that runs out of time throws APITimeoutError, and the SDK does not retry it. Aborting the signal stops the request and any pending retry, and the call rejects with the signal’s reason, as fetch does. The helpers’ own timeout, such as waitForExtraction(eventId, { timeout: 300_000 }), is a deadline for the whole wait: each poll gets no more than the time left (but at least 1,000 milliseconds), and a retry that would wait past the deadline is not made. A poll the deadline stops this way throws WaitTimeoutError too, with the poll’s own error as its cause, and lastStatus is null when no status was read yet. Only the start of reingest.run() keeps the client’s own timeout.

Next