AgentNava · Build

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 accountWho signs inIn the setup
Agent creator's accountyou or a colleague, once, and every conversation uses itConnection.creatorAccount(app, account)
End user's accounteach person who talks to the agent, once each, remembered for themConnection.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.

the agent uses exactly the account you named

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 withWhen
connection_name_takenthe agent already has a connection with that name
account_app_mismatchthe account is for a different app than the connection
account_not_foundno 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 default

Changing 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');
reconnecting fixes every agent at once

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.

connected means usable right now

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.

an end user is not a member of your workspace

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.

website chat has no end user

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 call

Every 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 hour

Both 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 values

Rotating 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 });
there is no ws.secrets.update

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.

appName
airtableAirtable
apolloApollo
asanaAsana
bamboohrBambooHR
brexBrex
confluenceConfluence
datadogDatadog
githubGitHub
gmailGmail
gongGong
google-adsGoogle Ads
google-analyticsGoogle Analytics
google-calendarGoogle Calendar
google-driveGoogle Drive
google-sheetsGoogle Sheets
greenhouseGreenhouse
hubspotHubSpot
intercomIntercom
jiraJira
leverLever
linearLinear
linkedinLinkedIn
mailchimpMailchimp
meta-adsMeta Ads
onedriveMicrosoft OneDrive
microsoft-outlookMicrosoft Outlook
sharepointMicrosoft SharePoint
microsoft-teamsMicrosoft Teams
notionNotion
pagerdutyPagerDuty
quickbooksQuickBooks
salesforceSalesforce
semrushSemrush
sentrySentry
shopifyShopify
slackSlack
stripeStripe
zendeskZendesk

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.

appName
apaleoApaleo
attioAttio
basecampBasecamp
bitbucketBitbucket
blackbaudBlackbaud
boldsignBoldsign
boxBox
calCal
calendlyCalendly
canvaCanva
capsule_crmCapsule CRM
clickupClickUp
contentfulContentful
crowdinCrowdin
dartDart
daytonaDaytona
dialpadDialpad
discordDiscord
discordbotDiscord Bot
dropboxDropbox
dubDub
dynamics365Dynamics 365
eventbriteEventbrite
excelExcel
existExist
facebookFacebook
fathomFathom
figmaFigma
freeagentFreeagent
freshbooksFreshBooks
gitlabGitLab
googlebigqueryGoogle BigQuery
google_classroomGoogle Classroom
googledocsGoogle Docs
google_mapsGoogle Maps
googlemeetGoogle Meet
googlephotosGoogle Photos
google_search_consoleGoogle Search Console
googleslidesGoogle Slides
googlesuperGoogle Super
googletasksGoogle Tasks
gorgiasGorgias
gumroadGumroad
harvestHarvest
hugging_faceHugging Face
instagramInstagram
kitKit
linkhutLinkhut
miroMiro
mondayMonday
moneybirdMoneybird
muralMural
notebook_lmNotebookLM
omnisendOmnisend
pinterestPinterest
pinterest_adsPinterest Ads
prismaPrisma
productboardProductboard
pushbulletPushbullet
redditReddit
reddit_adsReddit Ads
roamRoam
servicem8Servicem8
shippoShippo
slackbotSlackbot
splitwiseSplitwise
squareSquare
stack_exchangeStack Exchange
supabaseSupabase
ticketmasterTicketmaster
ticktickTicktick
timelyTimely
todoistTodoist
trelloTrello
twitchTwitch
typeformTypeform
wakatimeWakaTime
webexWebex
whatsappWhatsApp
wrikeWrike
yandexYandex
ynabYNAB
youtubeYouTube
zeplinZeplin
zohoZoho
zoho_biginZoho Bigin
zoho_booksZoho Books
zoho_deskZoho Desk
zoho_inventoryZoho Inventory
zoho_invoiceZoho Invoice
zoho_mailZoho Mail
zoomZoom

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.

A conversation is never refused

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.