AgentNava · Build

Credentials for your users

Each of your users has their own token for an API. Give the agent that person's token, keep it fresh, and find out when it runs out.

Two ways to hand over a token

Your application already signed the person in, so it holds their bearer token. There are two ways to give it to their conversation. Pick by how long the token lives.

Store it and attach itPush it with an expiry
Best forA token that lasts days or months, such as a personal API keyA short-lived OAuth access token that your app refreshes
Where the value livesStored once for the person, encryptedIn that one conversation
How it stays freshReplace the stored value. Every conversation it is attached to uses the new oneSend a new value with any message
How it stopsDelete it. Every conversation stops using it at onceIt expires. One hour by default, one day at most
Where to readStore it per personPush it with an expiry

Either way, the model never sees the token. The agent calls its tool by name, and we fill the token into the request as it goes out. It is never in a prompt, the transcript or a reply.

Say the token is per person

The agent declares the secret with scope: 'conversation'. Its tool names the secret in a header. That is the whole setup, and it is the same for both ways.

const agent = await ws.agents.create({
  name: 'Ticket helper',
  instructions: 'Help the person with their support tickets.',
  secrets: [{ name: 'HELPDESK_TOKEN', scope: 'conversation' }],
  httpTools: [{
    name: 'list_tickets',
    description: 'List the open tickets of the person you are talking to.',
    method: 'GET',
    url: 'https://api.helpdesk.example.com/v2/tickets?status=open',
    secrets: ['HELPDESK_TOKEN'],
    headers: { Authorization: 'Bearer {{secrets.HELPDESK_TOKEN}}' },
  }],
});
never your own token instead

A secret declared per conversation comes only from that conversation. If the conversation has no usable value, the tool gets none, even when the workspace or the agent stores a secret with the same name. So a missing or expired token can never fall back to your own account.

Store it per person and attach it

Store the token for the person once. Then attach it to each of their conversations.

Your app
Stores the person's token
Encrypted on arrival. Nobody can read it back, you included.
↓attach
The conversation
Keeps a pointer, not a copy
The value stays in one place, so there is nothing to go stale.
↓every tool call
The tool call
Reads the current value, checks it, uses it
Checked each time: same workspace, same person, same name, and the secret still exists.
const ananya = ws.endUser(user.id);
const key = await ananya.secrets.create({ name: 'HELPDESK_TOKEN', value: token });

const conversation = await agent.start({ endUserId: ananya.id, attach: key });
// or, on a conversation that is already running:
await conversation.attach(key);

// later, your app gets a new token for Ananya
await key.update(newToken);   // her conversations use it on their next tool call
await key.delete();           // her conversations stop using it at once

What reaches a conversation that is already running

You do thisThe running conversation
Replace the value: key.update(newToken)Uses the new value on its next tool call
Delete it: key.delete()Gets no value from the next call. Its next turn starts with credential_needed, reason missing
Attach it: conversation.attach(key)Uses it from the next tool call

Three rules, checked at attach and at every call

RuleWhy
The agent declares the name with scope: 'conversation'Attaching is not a way around what an agent may read
Store it under the exact name the agent declaresA stored secret attaches only under its own name, so a token stored as STRIPE_KEY can never fill HELPDESK_TOKEN. A mismatch is refused with secret_name_mismatch
It belongs to the person the conversation is forA secret stored for one end user attaches only to a conversation started for that same person, so one wrong id cannot hand somebody else their key

Push it with an expiry

For a short-lived access token, skip storing. Send the value with the conversation, and say how long it lasts. After that it is not used.

// at the start
const conversation = await agent.start({
  endUserId: user.id,
  secrets: { HELPDESK_TOKEN: accessToken },
  secretsExpireIn: 3600,                 // seconds
});

// with any message: in force before the agent reads it
await conversation.ask('Any open tickets?', {
  secrets: { HELPDESK_TOKEN: freshToken },
  secretsExpireIn: 3600,
});

// on its own, between messages
await conversation.attach({ secrets: { HELPDESK_TOKEN: freshToken } }, { expiresIn: 3600 });
Where you send itHow long it lasts
agent.start({ secrets })secretsExpireIn
conversation.ask(text, { secrets })secretsExpireIn
conversation.attach({ secrets }){ expiresIn }, the second argument

All three take seconds: 3600 (one hour) if you leave it out, at least 1, at most 86400 (one day). A number outside that range is refused. A new value replaces the old one under that name and starts its own clock. Set it to the token's own lifetime, so we stop using it when the API would refuse it anyway.

the simplest way to never run out

If your app can get a current token cheaply, send it with every ask. Then a token never expires in the middle of a conversation, and you rarely see credential_needed at all.

When a token is missing or expired

At the start of a turn, the stream tells you about each per-person secret that one of the agent's tools uses and that has no usable value:

{ type: 'credential_needed', name: 'HELPDESK_TOKEN', reason: 'expired' }
reasonWhat happenedWhat to do
expiredThe value you pushed ran outGet a fresh token and send it with ask
missingNo value was given, or the stored secret attached under that name was deleted or no longer belongs to this personSign the person in again in your app, then store or push the token
  • The turn still runs, and the call is not made. For expired, the tool answers that the sign-in expired. For missing, it answers in one plain sentence that it is not available right now, and names no key. Either way the agent tells the person in plain words, and the turn's tool event for that call ends with ok: false.
  • You see it only in the events. Iterate the turn with for await. Awaiting the turn gives you just the reply.
  • It comes once per name, before any text. So you can act on it as soon as it arrives.
  • The conversation shows it too. After conversation.get(), conversation.requires lists the name and conversation.ready is false.

A complete example

An agent that reads each person's helpdesk tickets with their own token. The token is pushed at the start, and refreshed only when the stream says it ran out.

import { AgentNava } from '@cerebro-labs/agentnava-sdk';

const ws = AgentNava.getWorkspace();   // reads AGENTNAVA_API_KEY

// Your own code: a current access token for this person, refreshed with
// your OAuth provider when needed. And a way to ask them to sign in again.
declare function accessTokenFor(userId: string): Promise<string>;
declare function askToSignInAgain(userId: string): Promise<void>;

// 1. Once: an agent whose tool uses each person's own token.
const agent = await ws.agents.create({
  name: 'Ticket helper',
  instructions: 'Help the person with their support tickets. Look them up with list_tickets.',
  secrets: [{ name: 'HELPDESK_TOKEN', scope: 'conversation' }],
  httpTools: [{
    name: 'list_tickets',
    description: 'List the open tickets of the person you are talking to.',
    method: 'GET',
    url: 'https://api.helpdesk.example.com/v2/tickets?status=open',
    secrets: ['HELPDESK_TOKEN'],
    headers: { Authorization: 'Bearer {{secrets.HELPDESK_TOKEN}}' },
  }],
});

// 2. Each person: a conversation that starts with their token.
const userId = 'u_ananya';
const conversation = await agent.start({
  endUserId: userId,
  secrets: { HELPDESK_TOKEN: await accessTokenFor(userId) },
  secretsExpireIn: 3600,   // the access token's own lifetime
});

// 3. Each message: show the reply, and refresh the token when told.
async function send(text: string): Promise<string> {
  let expired = false;
  let reply = '';
  for await (const event of conversation.ask(text)) {
    if (event.type === 'credential_needed') {
      if (event.reason === 'expired') expired = true;
      else await askToSignInAgain(userId);       // 'missing'
    }
    if (event.type === 'text') process.stdout.write(event.text);
    if (event.type === 'done') reply = event.reply;
    if (event.type === 'error') throw new Error(event.message);
  }
  if (!expired) return reply;

  // The token ran out before this turn, so the tool could not sign in and
  // the agent said so. Send a fresh token with the question, once.
  return await conversation.ask(text, {
    secrets: { HELPDESK_TOKEN: await accessTokenFor(userId) },
    secretsExpireIn: 3600,
  });
}

console.log(await send('Do I have any open tickets?'));

The first reply in an expired turn says the sign-in expired, and it stays in the conversation. To avoid that turn altogether, send the current token with every message instead, as in the tip above.

Good to know

CaseWhat happens
Website chatA visitor brings no token, so an agent that needs one per person is not offered there. credential_needed is never sent on a website chat, because it would show a visitor the names of the agent's secrets
Scheduled and webhook runsYour app is not there to push a token. A stored token you keep up to date works; a pushed one that expired stays expired. See what does not work yet
MCP server headersOn some agents, an MCP server's headers are read once, when the conversation starts. A replaced, expired or deleted value then reaches that server only in a new conversation. HTTP tools always read the current value
A token typed into the appA key a member types into a chat's Secrets tab lasts one day
Who may give a tokenAn API key may give one to any conversation in its workspace. A member, in the app, gives one only to their own conversations