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 it | Push it with an expiry | |
|---|---|---|
| Best for | A token that lasts days or months, such as a personal API key | A short-lived OAuth access token that your app refreshes |
| Where the value lives | Stored once for the person, encrypted | In that one conversation |
| How it stays fresh | Replace the stored value. Every conversation it is attached to uses the new one | Send a new value with any message |
| How it stops | Delete it. Every conversation stops using it at once | It expires. One hour by default, one day at most |
| Where to read | Store it per person | Push 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}}' },
}],
});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.
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 onceWhat reaches a conversation that is already running
| You do this | The 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
| Rule | Why |
|---|---|
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 declares | A 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 for | A 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 it | How 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.
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' }reason | What happened | What to do |
|---|---|---|
expired | The value you pushed ran out | Get a fresh token and send it with ask |
missing | No value was given, or the stored secret attached under that name was deleted or no longer belongs to this person | Sign 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. Formissing, 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'stoolevent for that call ends withok: 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.requireslists the name andconversation.readyis 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
| Case | What happens |
|---|---|
| Website chat | A 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 runs | Your 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 headers | On 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 app | A key a member types into a chat's Secrets tab lasts one day |
| Who may give a token | An API key may give one to any conversation in its workspace. A member, in the app, gives one only to their own conversations |