@cerebro-labs/agentnava-sdk
Configure an agent, start a conversation, work with it.
npm install @cerebro-labs/agentnava-sdk
Node 18 or later, or Bun, or any runtime with fetch. Both import and
require work, and TypeScript types ship with it.
import { AgentNava } from '@cerebro-labs/agentnava-sdk';
const ws = AgentNava.getWorkspace(); // reads AGENTNAVA_API_KEY
const agent = await ws.agents.create({
name: 'Refund checker',
instructions: 'Decide whether a late delivery qualifies for a refund.',
});
const conversation = await agent.start({ endUserId: 'u_ananya' });
console.log(await conversation.ask('Order 4471 arrived nine days late.'));
The full reference is at https://docs.agentnava.com. (It used to point here at a file path inside our own repository, which nobody who installs this package has.) This README covers only the things that are easy to get wrong.
Connecting an account, end to end
An account is one saved sign-in to one app. A connection is an entry in
an agent's setup that names one, so the agent uses exactly that account and never
another. ws.accounts.connect() gives you a URL for a person to visit, and
waitUntilConnected() tells you when they are done.
import { App, Connection } from '@cerebro-labs/agentnava-sdk';
const { account, url } = await ws.accounts.connect({ app: App.GMAIL, nickname: 'Support inbox' });
showToUser(url); // popup, redirect, QR code, your choice
await account.waitUntilConnected(); // resolves once they have signed in
await agent.connections.add(Connection.creatorAccount(App.GMAIL, account));
How you show the URL is yours: this SDK authenticates with an API key, so it runs on your server, and the browser half is your application's.
On timeout it throws account_not_connected and the message names the account
and where it stands. It is safe to call again; someone who has not finished
signing in may just need longer. After account.reconnect() it waits for the
new sign-in too: the account keeps working meanwhile, with reconnecting true.
For an app each of YOUR users signs in to themselves, the connection uses the end user's account. Each person signs in once, it is remembered for them, and nothing is attached per conversation:
await agent.connections.add(Connection.endUserAccount(App.GOOGLE_CALENDAR));
const conversation = await agent.start({ endUserId: user.id });
for (const { app, url } of await conversation.accountsNeeded()) {
showToUser(app, url); // the ones this person has not connected yet
}
An end user's account is an ordinary Account: agent.endUser(id).accounts.connect()
returns one, and waitUntilConnected(), ws.account(id) and every other account
call work on it the same way. account.data.owner says whose it is.
The builders make plain objects, { app, account: '<id>' } and
{ app, endUser: true }, and either object written by hand is accepted too.
App.GMAIL is the string 'gmail', so a plain string works anywhere a constant
does, including for an app added after your SDK version.
0.80.0: an agent can remember each end user
Nothing changes until you turn it on, and nothing in 0.79 breaks.
await agent.update({ memorySettings: { on: true } });
const conversation = await agent.start({ endUserId: 'u_ananya' });
After a chat goes quiet, a background agent keeps a short memory about that end user, on that agent only, and later chats use it. Your team can read and change it. It needs a Team plan or higher.
| New in 0.80 | What it does |
|---|---|
memorySettings on agent.update and ws.agents.create | Turn it on, add your own instructions, set how long it is kept |
(await agent.get()).memorySettings.notice | The line to show near your message box, if you draw your own chat |
agent.endUser(id).memory | get, set, delete and stop |
agent.memories() | Who has a memory on this agent, newest change first |
agent.start({ endUserId, memory: false }) | One conversation that uses nothing remembered and adds nothing |
The id you pass decides whose memory it is, so use one stable id per person. The guide, with when you may need consent: https://docs.agentnava.com/memory
0.79.0 is a breaking change: an end user belongs to one agent
Each agent is its own app, with its own end users. The same id on two agents is two different people, with separate accounts, secrets and conversations. A person who uses two of your agents signs in to each.
Until now a person's connected account was found by workspace and id alone, so agent B could use an account that a different person with the same id connected through agent A. That is what this closes.
| Up to 0.78 | From 0.79 |
|---|---|
ws.endUser(id) | agent.endUser(id). ws.endUser is gone |
ws.endUser(id).accounts.connect({ app }) | agent.endUser(id).accounts.connect({ app }), once per person on each agent |
ws.endUser(id).secrets.create({ name, value, agentId? }) | agent.endUser(id).secrets.create({ name, value }) |
ws.secrets.create({ name, value, endUserId }) | agent.endUser(endUserId).secrets.create({ name, value }) |
ws.endUser(id).conversations(), across every agent | agent.endUser(id).conversations(), this agent. Ask each agent |
accounts.disconnectAll() to off-board someone | agent.endUser(id).erase(): their accounts, secrets and conversations on this agent, and it returns how many of each |
What an older SDK gets. ws.endUser(id).accounts.* and ws.endUser(id).secrets.*
are refused with a 410 (end_users_are_per_agent) that says what to call instead.
An empty list would read as "nothing connected", so they never answer one.
agent.start, conversation.ask, and conversation.accountsNeeded() with its
sign-in link keep working on an older SDK: a conversation knows its agent.
Existing accounts were filed under the agent that used them, with no new sign-in. One that several agents used went to none of them, because the same id may be two people, so each agent asks the person to sign in. Workspace members are unchanged: a member's own account belongs to no agent.
0.44.0 is a breaking change: apps and accounts
Two words now mean two things. An account is one saved sign-in to one app.
A connection is an entry in an agent's setup: a name, an app, and whose
account it uses, built with Connection.creatorAccount(app, account) for the
agent creator's account or Connection.endUserAccount(app) for each end user's
own. A turn uses exactly the account the setup names. Nothing is attached to a
conversation any more, and nothing falls back to some other account of the same
app.
Upgrade together with the production release that serves the new routes:
0.44 calls /v1/accounts, /v1/end-users/:id/accounts and the agent connection
routes, and the connection routes 0.43 calls are removed in that same release.
There is no compatibility shim: this package is pre-launch, so the old names
were removed rather than deprecated.
| 0.43 | 0.44 |
|---|---|
ws.connections.create({ provider }) | ws.accounts.connect({ app, nickname? }), returns { account, url } |
ws.connections.create({ provider, agentId }) | ws.accounts.connect({ app }), then agent.connections.add(Connection.creatorAccount(app, account)) |
ws.connections.create({ provider, endUserId }) | ws.endUser(id).accounts.connect({ app }) |
ws.connections.list() | ws.accounts.list({ app? }), each with usedBy |
ws.connections.list({ endUserId }) | ws.endUser(id).accounts.list() |
ws.connection(id) | ws.account(id) |
Connection (class): a signed-in account | Account (class). The name Connection now builds an entry of an agent's setup |
connection.get(), returned ConnectionStatus | account.get(), returns AccountData; a missing account throws account_not_found |
connection.waitUntilConnected() | account.waitUntilConnected(); times out with account_not_connected |
connection.revoke() | account.disconnect(), returns { agents }: the agents that lose it |
connection.provider, .connected, .owner, .endUserId | account.app, .status, .data.owner, .data.endUserId |
connection.url | the url that connect returns |
| none | account.reconnect(), account.rename(nickname), account.check(), account.label, account.nickname, account.usedBy, account.data.reconnecting |
agent.authorize(provider) | ws.accounts.connect({ app }), then agent.connections.add(Connection.creatorAccount(app, account)) |
agent.attach(connection) | agent.connections.add(Connection.creatorAccount(app, account, { name? })) |
| none | agent.connections.add(Connection.endUserAccount(app, { name? })) |
| none | agent.connections.change(name, { account }) or (name, { endUser: true }) |
| none | agent.connections.change(name, { tools }): which of the app's own tools it may use, ids from ws.apps.tools(app); null for the app's default |
agent.disconnect(provider), which deleted the shared account | agent.connections.remove(name), which never touches the account |
agent.connections() | agent.connections.list() |
agent.attach(secret), which was accepted and did nothing | refused with not_a_secret; attach a stored secret to a conversation instead |
agent.start({ attach: connection }) | agent.start({ endUserId }): the setup names the accounts |
conversation.authorize(provider) | conversation.accountsNeeded(): the links an end user still has to sign in at |
conversation.attach(connection) | none: the agent's setup decides |
endUser.connections.create({ provider }) | endUser.accounts.connect({ app }) |
endUser.connections.list() | endUser.accounts.list() |
endUser.revokeConnections() | endUser.accounts.disconnectAll(), returns { disconnected } |
setup connections: [{ provider, scope: 'fixed', connectionId }] | Connection.creatorAccount(app, account, { name?, tools? }), which is { name?, app, account: '<id>', tools? } |
setup connections: [{ provider, scope: 'conversation' }] | Connection.endUserAccount(app, { name?, tools? }), which is { name?, app, endUser: true, tools? } |
type ConnectionRequirement | AgentConnection (as read back) and Connection (as you send it), with CreatorAccountConnection and EndUserAccountConnection |
type ConnectionStatus, keyed by provider | ConnectionStatus, keyed by name: { name, app, account?, endUser?, status, inDraft, removedInDraft } |
type CredentialOwner | removed: AccountData.owner is 'workspace' or 'end-user' |
Bindings.connections | removed: Bindings carries secrets only |
| none | App: every app id as a constant (App.GMAIL), and the type AppId |
New error codes to branch on: account_not_found, connection_name_taken,
account_app_mismatch, connection_not_found (from
agent.connections.change), account_not_connected (from
waitUntilConnected), account_required (from Connection.creatorAccount
with no account), and not_a_secret (an account handed to attach).
Secrets did not move: agent.attach({ secrets }), conversation.attach(secret),
conversation.attach({ secrets }) and agent.start({ attach: secret, secrets })
work as before.
0.2.0 is a breaking change. Upgrade from 0.1.1, do not stay on it.
"session" is gone from everything you touch. It survives only in our storage, which you never see.
| 0.1.1 | 0.2.0 |
|---|---|
GET /v1/sessions | GET /v1/conversations (the old path now 404s) |
ConnectionStatus.sessionId | ConnectionStatus.conversationId |
conversation.authorize() sent sessionId | sends conversationId |
scope: 'session' came back from the API | scope: 'conversation', the value the type always declared |
0.1.1 stops working once these reach production, because the API reads only the new field names. There is no compatibility shim: this package is private and pre-launch, so the old spellings were removed rather than deprecated.
If you are on 0.1.1 the change is mechanical: rename sessionId to
conversationId wherever you read it or pass it. Nothing else about the shape
moved.
Holding an object is free
ws.agent(id), agent.conversation(id), ws.project(id) and ws.account(id)
make no network call. You get something you can call methods on, and the first request happens
when you call one.
Store the id, not the object. A returned object is a handle, not JSON.
Asking: one method, two shapes
const reply = await conversation.ask('...'); // just the answer
for await (const e of conversation.ask('...')) { // as it happens
if (e.type === 'progress') showSteps(e.steps); // the whole list, each time
if (e.type === 'text') append(e.text);
if (e.type === 'file') show(await conversation.readFile(e.path));
if (e.type === 'done') save(e.reply);
}
progress lists the steps the AgentNava app shows while the agent works, in
the same words ("Searching the web: steel tariffs"), each active, done or
error. Replace what you show with each one. file names a file the agent
wrote, once per file.
The message is sent when you call ask, not when you await or iterate it.
Calling ask twice generates two submission keys and asks twice. If you need
to identify an explicit retry after a lost response, save a key before sending:
const idempotencyKey = crypto.randomUUID();
const reply = await conversation.ask('...', { idempotencyKey });
Reuse that key only with the same content in the same conversation. The API
refuses an already accepted turn with turn_already_accepted instead of running
it again. A new intentional question needs a new key. Errors include the
original key and instructions for checking status; a retryable: false error
must not be retried as a fresh submission.
You may await the same turn more than once; it is a promise, and you get the same answer. You may not iterate it twice, or mix the two, because the events are a live stream rather than a stored list and the second consumer would receive nothing at all. That throws instead.
The turn is not your request. Once the server accepts it, a dropped
connection, aborting AskOptions.signal, or leaving a for await loop early
stops receiving the response. Accepted work can continue and normal billing
applies; disconnecting does not cancel server work or automatically refund the
turn. An already-aborted signal passed to ask() prevents submission, so no
turn is accepted or charged for that call.
Use a signal to limit how long your application waits:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10_000);
try {
const reply = await conversation.ask('Summarize the report.', {
signal: controller.signal,
});
save(reply);
} catch (error) {
if (!controller.signal.aborted) throw error;
// We stopped waiting. A turn already accepted by the server can still run.
} finally {
clearTimeout(timer);
}
After an interruption, read await conversation.messages() before another
ask(). The answer may still be in progress, so check history again later
instead of submitting the same question again.
A successful await or done event requires the server's completion marker.
If the stream closes early, the SDK reports a nonretryable interruption even
when reply text already arrived; use the original submission key to check its
status before starting more work.
Changing an agent publishes a version
There is no separate save. agent.update(), setWorkflow, removeWorkflow and
setTriggers each publish a new version.
Send only what changes. Everything you do not send is inherited from the current version. That is what makes changing one field safe, and it is not a convenience: a caller that rebuilds and resends a whole configuration drops whatever it forgot, which has caused four separate data-loss bugs in this codebase.
await agent.update({ instructions: 'Be brief.' }); // keeps everything else
A conversation pins its version
It pins the agent's version when it starts and keeps it. Publishing afterwards does not move a conversation that is already open.
Knowledge is the exception, and deliberately so: it is read by reference, so replacing a knowledge file changes what open conversations see on their next turn. A price list you could not correct without republishing the agent would be useless.
Two drives
| The agent sees | Belongs to | You write | The agent writes | |
|---|---|---|---|---|
| Knowledge | /knowledge | the agent | yes | no |
| Conversation | /workspace | one conversation | yes | yes |
An application stages material by writing it in before asking about it:
await conversation.writeFile('/input/contract.pdf', bytes);
await conversation.ask('What are the payment terms?');
Anywhere under the root works. input/ is a convention here and nothing
enforces it, because the agent and your application share one namespace.
Whose account an agent uses
Each connection in an agent's setup names one account, and a turn uses exactly that one:
| Built with | Whose | Who signs in |
|---|---|---|
Connection.creatorAccount(app, account) | the agent creator's | you or a colleague, once; every conversation uses it |
Connection.endUserAccount(app) | the end user's | each person, once on each agent, remembered for them |
There is no fallback. An account that is not signed in leaves its connection unavailable, and the agent says so rather than reaching for another account of the same app.
Secrets still come in two tiers that do not substitute: fixed is set once on
the agent, conversation is supplied per conversation. Check
conversation.ready and conversation.requires to see what is outstanding.
Drafting tools from an API you already describe
importTools turns an OpenAPI document, a Postman collection or a working curl
command into draft httpTools, so nobody hand-writes one declaration per
endpoint.
import { importTools } from '@cerebro-labs/agentnava-sdk';
const draft = importTools({ openapi }, { include: (c) => !c.url.includes('/admin/') });
await ws.agents.create({ id: 'support', instructions, httpTools: draft.tools });
It runs entirely on your machine. Nothing about the source is sent to
AgentNava, which matters because these files routinely contain a live token:
any credential found is replaced with a {{secrets.NAME}} placeholder and
listed in draft.secrets, with no value carried across. Each drafted tool also
declares the secrets it uses in its own secrets field, which is what lets the
runtime load them for the turn.
They are drafts. draft.warnings names what a human still has to decide, and
the big one is description-missing: none of the three formats contains the
sentence a model reads to pick a tool, and a bad description does not fail, it
makes the agent call the wrong thing.
A rule in an OpenAPI document (a minimum, a length, a pattern, a format, a
oneOf) does not cost you the operation. It is written into the parameter's
description, Rules: at least 1; at most 100., and your API enforces it as it
does for any caller; its 400 reaches the agent as a failed result it can
correct. A document that defines one security scheme and never lists it under
security gets that scheme on every operation, with a security-choice
warning; with several schemes, none is applied and the warning says so.
HTTP tool response limits
Declarative HTTP tools retain at most 64 KiB of response data and stop reading
when overflow is detected. The limit counts UTF-8 bytes, not characters. Oversized responses
return ok: false, truncated: true, and a response_too_large error body
with pagination/filtering guidance; the HTTP status remains the upstream
status. Partial JSON is never presented as a complete result. Use the API's
filters and pagination to retrieve a large dataset in bounded pages.
Errors
Every failure throws AgentNavaError.
catch (err) {
if (err instanceof AgentNavaError) {
err.code; // 'agent_not_found', branch on THIS
err.status; // 404
err.retryable; // 5xx, 429, and network failures
err.retryAfter; // seconds, on a 429
}
}
Branch on code, not on message. Codes are stable; messages are written for a
person reading a log.
retryable is the only thing worth automating on: a 400 will fail identically
forever, and a 404 will not become found.
404 covers both "no such thing" and "not yours", so a caller probing ids cannot tell another customer's agent from one that never existed. There is no 403.
Configuration
AgentNava.getWorkspace(); // AGENTNAVA_API_KEY
AgentNava.getWorkspace({ apiKey, baseURL, timeout });
AgentNava.getWorkspace({ fetch: myFetch }); // a proxy, or a test
Runs anywhere fetch exists: Node, Bun, Deno, Cloudflare Workers, the browser.
loadWorkflows is the one exception; it reads a directory off disk and so needs
Node or Bun.
Pointing at a different deployment
The SDK talks to https://api.agentnava.com unless you say otherwise. Two ways
to change it, and the explicit option wins over the environment variable.
// a dedicated enterprise endpoint
const ws = AgentNava.getWorkspace({ apiKey, baseURL: 'https://acme.agentnava.example' });
# staging, which is where a test key works
AGENTNAVA_BASE_URL=https://api-staging.agentnava.com
A staging key against the production URL fails with invalid api key, which
reads like a bad key rather than the wrong host. If you were given a test key,
set the staging URL above. A trailing slash is fine, and a base with a path
prefix works too, for an API gateway sitting in front of us.
Licence
Apache License 2.0. See LICENSE, and NOTICE for the attribution that
section 4(d) asks you to carry along.
Free to use, modify and redistribute, including commercially, with an explicit patent grant. Section 6 grants no rights to the names: "AgentNava" and "Cerebro Labs" remain trademarks of Cerebro Labs, Inc.
Widgets in replies (0.50)
agent.start({ endUserId, widgets: true }) says your screen can draw cards, tables and charts. Each turn then streams widget events (a whole widget each time, replace it by surfaceId), done carries the final widgets, and messages() returns them on each reply. Continue one with agent.conversation(id, { widgets: true }). Widgets use Google's open A2UI format with AgentNava's own set of parts (Widget, WidgetPart): draw each with your own components, starting from its part with id root, or use the ready-made React views below. placeWidgets(text, widgets) splits a reply into words and widgets in reading order, for any framework.
import { ReplyView } from '@cerebro-labs/agentnava-sdk/react'
import '@cerebro-labs/agentnava-sdk/react/styles.css'
<ReplyView text={reply} widgets={widgets} onAsk={send} />
React is an optional peer: only @cerebro-labs/agentnava-sdk/react imports it. Every kind of part has a default look, themed with --an-w-* CSS variables; parts={{ Stat: MyStat }} swaps any kind for your own component, renderText draws the words, currency and locale format numbers, and allowUrl narrows which links and pictures open (https:// only by default). An agent's own widgets: false turns them off for it. The agent field's old meaning, a list of widget kinds nothing read, is gone, and so is the widgets.json file loadAgentDir used to load.
Where an agent is live (0.41)
agent.get() and ws.agents.list() carry a read-only status: where the agent is live, by the rule the API runs it by, and in needs any required account or secret not set up yet (it still runs; the tools that use them fail until they are).
Enterprise HTTP contracts (0.34)
Requires the corresponding API and runtime update. Parameter types include object and array, nested properties/items, per-property required, nullable, integer and additionalProperties. Use choices for primitive enums. Supply native JSON values to paramsJson; an object body is not a JSON-encoded string.
The OpenAPI importer respects operation security, document security, and security:[] (public). When a document defines exactly one supported scheme and no security array anywhere names one, that scheme applies to every operation that does not say security: [], with a security-choice warning; with several schemes, or one the importer cannot set up (OAuth, cookie), none is applied and the warning says the tools send no credential. Bearer, Basic and header/query API keys are supported. It preserves inherited parameters, resolves acyclic local schema references, and URL-encodes path/query values. Omitted optional query/header values and an optional whole body are not sent; missing required values still fail before HTTP. Secrets in query values use secretsEncoded.
The secretName override can represent only one credential per operation. The importer checks each security alternative together with the operation's credential header parameters and selects the first compatible alternative. If none can use a single credential, the importer skips the operation with a warning instead of reusing the same secret for independent credentials. Omit the override to generate independent secret names, or configure the operation explicitly.
Review import warnings. Schema rules never skip an operation: bounds, lengths, patterns, string formats and item or field counts are written into the parameter's description, and the API being called enforces them. oneOf/anyOf become one parameter that admits every alternative, with the alternatives described (One of: Card (with type, token); BankTransfer (with type, iban).); "X or null" becomes a nullable X, and a map (additionalProperties holding a schema) stays open to any key. When the alternatives cannot share one shape (a list or an object), the first is kept and an unsupported-body warning names the field. allOf members merge, including a rule-only member beside a type. Choosing among security alternatives produces a warning. What still skips an operation, with an unsupported-operation warning: a contradictory allOf, tuple arrays (prefixItems), recursive or external references, a schema with no type, structured query serialization, cookie authentication and OAuth acquisition/refresh. Schemas cannot declare a property named __proto__, which the Workers schema adapter cannot preserve.
Keywords beside a schema $ref apply together with the referenced schema, as OpenAPI 3.1 defines, at every step of a reference chain: a description replaces the referenced one, a rule is described, an enum narrows the choices. Response-only properties marked readOnly: true are still omitted before importing their request schemas.
All three execution paths share one HTTP executor. Results include lower-cased Link, Retry-After, request ID, ETag, Last-Modified, Content-Type and rate-limit headers. Credential-bearing headers are excluded and known secret values in metadata are redacted. HTTP errors and oversized responses have ok:false at the outer tool envelope too. The response byte limit remains 64 KiB: filter or paginate larger records.