Connections and secrets
The apps an agent uses, whose account each one signs in with, and the keys it needs.
Add a connection
A connection is one app in an agent's setup: a name, the app, and whose account it uses. An account is one saved sign-in to that app, such as Gmail signed in as support@example.com. Each connection uses one of two accounts:
| Whose account | Who signs in | In the setup |
|---|---|---|
| Agent creator's account | you or a colleague, once, and every conversation uses it | Connection.creatorAccount(app, account) |
| End user's account | each person who talks to the agent, once each, remembered for them | Connection.endUserAccount(app) |
import { AgentNava, App, Connection } from '@cerebro-labs/agentnava-sdk';
const ws = AgentNava.getWorkspace();
const { account: support, url } = await ws.accounts.connect({
app: App.GMAIL,
nickname: 'Support inbox',
});
// send someone to `url`; they sign in on Google's own page
const agent = await ws.agents.create({
name: 'Support assistant',
instructions: '...',
connections: [
Connection.creatorAccount(App.GMAIL, support), // the agent creator's account
Connection.endUserAccount(App.GOOGLE_CALENDAR), // each end user's own
],
});The two builders ask the question the portal asks, whose account, and make plain objects:
Connection.creatorAccount(App.GMAIL, support) is
{ app: 'gmail', account: support.id }, and
Connection.endUserAccount(App.GMAIL) is { app: 'gmail', endUser: true }.
You can write either object yourself. One with both account and
endUser, or with neither, does not compile.
App holds every app the platform can connect, so your editor completes the name
for you. A plain string works too: App.GMAIL is the string 'gmail',
and a plain id is how you name an app added after your SDK version.
Never another one. If that account is not signed in, the connection is unavailable and the agent says so. It does not fall back to some other account of the same app, so reading the setup tells you whose mailbox an agent reads.
Two accounts of one app
Give each connection its own name. The name is what tells them apart, for you and for the agent, which is told each one by name and says on every call which one it is using.
const work = await ws.accounts.connect({ app: App.GMAIL, nickname: 'Work' });
const personal = await ws.accounts.connect({ app: App.GMAIL, nickname: 'Personal' });
const agent = await ws.agents.create({
name: 'Inbox helper',
instructions: 'Answer from both mailboxes, and say which one each answer came from.',
connections: [
Connection.creatorAccount(App.GMAIL, work.account, { name: 'work-gmail' }),
Connection.creatorAccount(App.GMAIL, personal.account, { name: 'personal-gmail' }),
],
});
const conversation = await agent.start({ endUserId: 'u_ananya' });
await conversation.ask('Did Acme write to either of my inboxes this week?');A name is lowercase letters, digits and dashes, up to 64 characters, and unique on the
agent. Leave it out and it is the app's id, such as gmail.
Change one, remove one, see where they stand
await agent.connections.add(
Connection.creatorAccount(App.GMAIL, billing, { name: 'billing-gmail' }),
);
await agent.connections.change('work-gmail', { account: otherWorkAccount });
await agent.connections.change('billing-gmail', { endUser: true }); // each end user's own now
await agent.connections.remove('billing-gmail'); // the account stays connected
await agent.connections.list();
// [{ name: 'work-gmail', app: 'gmail', status: 'connected',
// account: { label: 'ananya@example.com', nickname: 'Work', ... },
// inDraft: false, removedInDraft: false }, ...]Each of these publishes a new version of the agent, like any other change to its setup. A conversation already open keeps the connections of the version it started on. Removing a connection never touches the account: it stays signed in for every other agent that uses it.
| Refused with | When |
|---|---|
connection_name_taken | the agent already has a connection with that name |
account_app_mismatch | the account is for a different app than the connection |
account_not_found | no account in your workspace has that id |
Which of the app's own tools the agent may use
A connection hands the agent its app's ready-made tools: all of them for most apps, none for
Shopify. Choose fewer with tools, by the tool ids, or [] for none, so
the connection only signs in the agent's own HTTP tools for that app. A tool left out cannot be
run. Every connection of one app on an agent shares one list.
Connection.creatorAccount(App.GMAIL, work, { tools: ['GMAIL_FETCH_EMAILS'] });
Connection.creatorAccount(App.SHOPIFY, store, { tools: [] }); // sign-in only
await agent.connections.change('work-gmail', { tools: ['GMAIL_FETCH_EMAILS', 'GMAIL_SEND_EMAIL'] });
await agent.connections.change('work-gmail', { tools: null }); // back to the app's defaultChanging only the account keeps the tools. A tool id that is not the app's is refused with
invalid_connection_tools, and two connections of one app with different lists with
connection_tools_differ.
The ids come from the app itself: ws.apps.tools(app) (HTTP:
GET /v1/connections/apps/:app/tools) lists its current tools, the common ones
first, each with a plain name and summary, and deletes: true on the ones that
remove something.
const tools = await ws.apps.tools(App.SLACK);
const send = tools.find((t) => t.name === 'Send message');
await agent.connections.change('slack', { tools: [send!.id] });In the app, each connection on an agent's Connections tab says which tools it gives, and Choose tools changes it: the app's default, none, or the ones you pick. Like every connection change it goes into the draft and is live when you publish. The Agent Builder can make the same change in chat ("only let it send messages").
Agent creator's account
For an app the agent uses as you: a team mailbox, the company CRM. Someone signs in once, in their own browser, and you never see a token. Then any agent can name the account.
// 1. a link to sign in at, and the account it will fill in
const { account, url } = await ws.accounts.connect({
app: App.HUBSPOT,
nickname: 'Sales CRM',
});
// 2. show it however suits you: a popup, a redirect, an email to whoever holds the login
showToUser(url);
// 3. wait for them to finish
await account.waitUntilConnected();
// 4. name it on an agent
await agent.connections.add(Connection.creatorAccount(App.HUBSPOT, account));Keeping it working
// every Gmail account, each with usedBy: the agents that use it
const accounts = await ws.accounts.list({ app: App.GMAIL });
const account = ws.account(id);
await account.get(); // { label, nickname, status, usedBy, ... }
await account.check(); // ask the app whether the sign-in still works
const { url } = await account.reconnect(); // a new link when it needs signing in again
await account.rename('Support inbox');The account is shared, so one sign-in brings back every agent that names it. The old
sign-in keeps working until the new one succeeds: meanwhile the account's
reconnecting is true, and account.waitUntilConnected()
waits for it to clear. If the person signs in as somebody else,
that becomes a new account and the old one is left as it was, so no agent switches mailbox
without you choosing it: change the connections that should use the new one.
Disconnecting an account
const { agents } = await account.disconnect();
// [{ id: 'inbox-helper', agentName: 'Inbox helper', connectionName: 'work-gmail',
// published: true, inDraft: false }]Always allowed. It signs the account out at the app and removes it here, and it tells you which agents used it. From their next turn those connections say the account was disconnected; nothing switches to another account in its place.
Not "was set up once". A sign-in the app has revoked turns the status to
needs_sign_in. That is the field to show an operator, because an account can go
bad while nobody is looking.
End user's account
For apps each of your users signs in to themselves: their own calendar, their own notes.
The setup says so with Connection.endUserAccount(app), and each person signs in
once. After that the account is remembered for them, so every conversation of theirs has it
and nothing is attached per conversation.
// once, in the agent's setup
await agent.connections.add(Connection.endUserAccount(App.GMAIL));
// once per person, remembered for them
const { url } = await ws.endUser(user.id).accounts.connect({ app: App.GMAIL });
showToUser(url);
// every conversation: nothing to attach
const conversation = await agent.start({ endUserId: user.id });
const needed = await conversation.accountsNeeded();
// [{ name: 'gmail', app: 'gmail', url }] until they have signed in, then []The turn uses the account of the person the conversation is for, found by its
endUserId, and nobody else's. If they have not signed in yet, the connection is
unavailable and the agent says so, and accountsNeeded() hands you the link to show
them.
What connect hands back is an ordinary Account. Every account call
works on an end user's account the same way as on the agent creator's:
account.waitUntilConnected() waits for them to finish signing in,
ws.account(id) reaches it later, and owner says whose it is.
These are two different populations and it is the sharpest naming trap in this API. A workspace member is your colleague: they sign in here, we issued their id, and we authenticate it. An end user is the person your application serves: they never sign in here, the id is one from your database, and we store it verbatim without ever checking it.
That last part is why the mistake is worth naming. Passing a ws.members() id to
agent.start({ endUserId }) cannot fail, because there is nothing for an end-user id
to be checked against. It returns 200 and labels the conversation with a colleague's internal
id.
An anonymous visitor on your website chat is nobody's end user, so a connection to the end user's account is unavailable there. The agent does not borrow the agent creator's account instead.
When somebody leaves
const ananya = ws.endUser(row.userId);
await ananya.accounts.list(); // what she has connected, for showing her
await ananya.conversations(); // everything she has said, across every agent
await ananya.accounts.disconnectAll(); // { disconnected: 2 }: cut her off, in one callEvery conversation of hers stops reaching those apps from the next turn, and nothing is retained.
Supplying a secret
A secret is a value you already hold, so there is no round trip and no browser. You write it and it is gone: nothing reads it back.
await agent.attach({ secrets: { APOLLO_API_KEY: '...' } }); // every conversation
await conversation.attach({ secrets: { APOLLO_API_KEY: theirKey } }); // this thread only, for one hourBoth merge, so supplying one key leaves the others in place. Only secrets are attached this
way: an account handed to attach is refused, because the agent's setup names its
accounts.
A value given to one conversation expires: after one hour unless you say otherwise, and after one day at most. To keep a person's token fresh, or to learn when it ran out, see Credentials for your users.
Storing a secret you can rotate later
The two calls above supply a value for one agent or one conversation. To keep a secret in the workspace, so it survives and can be replaced, store it:
const key = await ws.secrets.create({ name: 'APOLLO_API_KEY', value, agentId });
await ws.secrets.list(agentId); // what exists, never the valuesRotating one
This is what you do when a key leaks, and it lives on the secret itself rather than on the collection:
await ws.secret(id).update('rotated-value'); // by id
await ws.secret(id).delete();
// or from a listing, because list() returns objects rather than records:
const mine = await ws.secrets.list(agentId);
const existing = mine.find((s) => s.name === 'APOLLO_API_KEY');
if (existing) await existing.update(value);
else await ws.secrets.create({ name: 'APOLLO_API_KEY', value, agentId });Rotation is a method on the secret, not on the collection, so there is one way to do it
rather than two. If you looked at ws.secrets.*, found only create and
list, and concluded a stored value could not be replaced, that is the gap this
section exists to close: creating a name that already exists at the same scope is a 409 rather
than a silent overwrite, so without update() there would genuinely be no way
through.
The new value reaches the next tool call, including in conversations it is attached to. There is no cache to wait out. A deleted secret stops being used at once, in running conversations too.
A secret that belongs to one of your end users
Store it against them, then attach it to their conversation:
const ananya = ws.endUser(row.userId);
const key = await ananya.secrets.create({ name: 'ZENDESK_TOKEN', value: token });
const conversation = await agent.start({ endUserId: ananya.id, attach: key });You attach it by id, not by value, because you never hold the value: it is
encrypted when you create it and is never handed back. The conversation keeps a pointer to the
stored secret, never a copy. Each tool call reads the current value, so
key.update(newToken) reaches the conversation on its next call and
key.delete() stops it at once.
Three rules the platform enforces and you cannot switch off, checked when you attach and again at every call. The agent must declare that secret name at conversation scope, so attaching by id is not a way around what an agent is allowed to read. The secret is attached under its own name, so store it under the name the agent declares. And a secret belonging to one end user can only be attached to a conversation started for that same person, so one mistyped id cannot hand somebody else their key.
A secret the agent declares at conversation scope comes only from the conversation. It never falls back to a workspace or agent secret of the same name. More in Credentials for your users.
Which apps you can connect
130 apps connect today. Use the app id exactly as
written, or its constant: every id is also on App, in capitals with dashes as
underscores, so google-calendar is App.GOOGLE_CALENDAR.
Available now (38)
Your user approves the account through a hosted sign-in page. You never register an OAuth application, and neither we nor you ever see their password.
app | Name |
|---|---|
airtable | Airtable |
apollo | Apollo |
asana | Asana |
bamboohr | BambooHR |
brex | Brex |
confluence | Confluence |
datadog | Datadog |
github | GitHub |
gmail | Gmail |
gong | Gong |
google-ads | Google Ads |
google-analytics | Google Analytics |
google-calendar | Google Calendar |
google-drive | Google Drive |
google-sheets | Google Sheets |
greenhouse | Greenhouse |
hubspot | HubSpot |
intercom | Intercom |
jira | Jira |
lever | Lever |
linear | Linear |
linkedin | |
mailchimp | Mailchimp |
meta-ads | Meta Ads |
onedrive | Microsoft OneDrive |
microsoft-outlook | Microsoft Outlook |
sharepoint | Microsoft SharePoint |
microsoft-teams | Microsoft Teams |
notion | Notion |
pagerduty | PagerDuty |
quickbooks | QuickBooks |
salesforce | Salesforce |
semrush | Semrush |
sentry | Sentry |
shopify | Shopify |
slack | Slack |
stripe | Stripe |
zendesk | Zendesk |
Also available (92)
These come from our integration partner's managed catalog and connect the same way, with the same hosted sign-in. They are not in the list above only because we have not written our own description for each one.
app | Name |
|---|---|
apaleo | Apaleo |
attio | Attio |
basecamp | Basecamp |
bitbucket | Bitbucket |
blackbaud | Blackbaud |
boldsign | Boldsign |
box | Box |
cal | Cal |
calendly | Calendly |
canva | Canva |
capsule_crm | Capsule CRM |
clickup | ClickUp |
contentful | Contentful |
crowdin | Crowdin |
dart | Dart |
daytona | Daytona |
dialpad | Dialpad |
discord | Discord |
discordbot | Discord Bot |
dropbox | Dropbox |
dub | Dub |
dynamics365 | Dynamics 365 |
eventbrite | Eventbrite |
excel | Excel |
exist | Exist |
facebook | |
fathom | Fathom |
figma | Figma |
freeagent | Freeagent |
freshbooks | FreshBooks |
gitlab | GitLab |
googlebigquery | Google BigQuery |
google_classroom | Google Classroom |
googledocs | Google Docs |
google_maps | Google Maps |
googlemeet | Google Meet |
googlephotos | Google Photos |
google_search_console | Google Search Console |
googleslides | Google Slides |
googlesuper | Google Super |
googletasks | Google Tasks |
gorgias | Gorgias |
gumroad | Gumroad |
harvest | Harvest |
hugging_face | Hugging Face |
instagram | |
kit | Kit |
linkhut | Linkhut |
miro | Miro |
monday | Monday |
moneybird | Moneybird |
mural | Mural |
notebook_lm | NotebookLM |
omnisend | Omnisend |
pinterest | |
pinterest_ads | Pinterest Ads |
prisma | Prisma |
productboard | Productboard |
pushbullet | Pushbullet |
reddit | |
reddit_ads | Reddit Ads |
roam | Roam |
servicem8 | Servicem8 |
shippo | Shippo |
slackbot | Slackbot |
splitwise | Splitwise |
square | Square |
stack_exchange | Stack Exchange |
supabase | Supabase |
ticketmaster | Ticketmaster |
ticktick | Ticktick |
timely | Timely |
todoist | Todoist |
trello | Trello |
twitch | Twitch |
typeform | Typeform |
wakatime | WakaTime |
webex | Webex |
whatsapp | |
wrike | Wrike |
yandex | Yandex |
ynab | YNAB |
youtube | YouTube |
zeplin | Zeplin |
zoho | Zoho |
zoho_bigin | Zoho Bigin |
zoho_books | Zoho Books |
zoho_desk | Zoho Desk |
zoho_inventory | Zoho Inventory |
zoho_invoice | Zoho Invoice |
zoho_mail | Zoho Mail |
zoom | Zoom |
Anything else
The app id is not checked against this list when you add a
connection. An app outside it is accepted, and whether it can then connect depends on our
integration partner offering a hosted sign-in for it. So a typo in an app id
will not be reported when you add the connection: it surfaces later, when someone tries to
connect. Check the id against this table, or use the App constants, which
cannot be misspelled.
The tables above, and the App constants, are generated from the
catalog the API reads, so they cannot drift from it. A test fails if they do.
What a conversation still needs
conversation.ready; // true when nothing REQUIRED is outstanding
conversation.requires; // [{ kind: 'secret', name: 'ZENDESK_TOKEN', required: true }]
await conversation.accountsNeeded(); // [{ name: 'gmail', app: 'gmail', url }]ready is the answer most callers want. requires is the detail behind
it, and the two are not the same question: a conversation can be ready and still
list a requirement, because one declared required: false is something the agent
works without. accountsNeeded() lists the end user's accounts that person has not
connected yet, each with the link to send them to.
A secret is listed in requires when nothing supplied it, when the value you pushed
has expired, or when the stored secret attached under that name was deleted.
It starts even with nothing supplied, reports what is outstanding, and the agent's first reply can be the thing that asks for it. If an account breaks mid-conversation you find out on the next turn, which is the only moment it matters.