N AgentNava
AgentNava · Build

Connections and secrets

What the agent needs, who supplies it, and what happens when it is missing.

Declare what the agent needs

An agent says what it cannot work without, and says who supplies it. Declaring is part of the configuration; supplying is not.

await ws.agents.create({
  name: 'Outreach assistant',
  instructions: '...',
  connections: [{ provider: 'gmail',      scope: 'conversation', required: true }],
  secrets:     [{ name: 'APOLLO_API_KEY', scope: 'fixed',   required: true }],
});
scopeWho supplies itReaches
fixedyou, once, on the agentevery conversation, including ones already open
conversationeach conversation, on itselfthat conversation only

The two do not substitute for each other. Connecting an account on the agent does not satisfy a declaration that said conversation, and a conversation cannot supply a fixed one on its own.

Connecting an account

A connection is an account somebody signs in to, so it takes a round trip through their browser. You never see a token.

One account for everyone

const { url } = await agent.authorize('gmail');
// send them to `url`; the account attaches when they finish

await agent.connections();
// [{ provider: 'gmail', declared: true, connected: true, accountLabel: 'ops@…' }]

Each of your users connects their own

Three steps: ask for a link, put the person in front of it, wait for them to finish. The waiting is the part worth getting right, because it is the only one this SDK cannot do for you silently.

const conversation = await agent.start();

// 1. a link for THIS person, bound to THIS conversation
const { url } = await conversation.authorize('gmail');

// 2. show it however suits your app: a popup, a redirect, even a QR code.
//    This SDK runs on your server, so the browser half is yours.
showToUser(url);

// 3. wait for them to finish. Resolves once nothing required is outstanding.
await conversation.waitUntilReady();

// now it is safe to start work
await conversation.ask('Summarise my unread mail.');

Their account belongs to their conversation. A second person starting their own conversation with the same agent connects their own Gmail, and neither can see the other's.

If you would rather not wait

waitUntilReady() is a convenience over two fields you can read yourself. Use them directly when the waiting belongs somewhere else, such as a webhook or a page the person returns to.

const { ready, requires } = await conversation.get();
// ready    -> true when nothing REQUIRED is outstanding
// requires -> [{ kind: 'connection', name: 'gmail', required: true }]
the wait tells you what it is waiting for

On timeout waitUntilReady() throws conversation_not_ready and the message names what is still missing, for example still waiting on: connection gmail. It is safe to call again: somebody who has not finished signing in may just need longer. Pass { timeoutMs, intervalMs, signal } to change the defaults of two minutes and two seconds.

connected means usable right now

Not "was set up once". A revoked token flips connected to false and puts the reason in error. That is the field to show an operator, because a connection can go bad while nobody is looking.

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

Both merge, so supplying one key leaves the others in place.

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. There is no cache to wait out.

Building for many end users

If your application serves its own users, each of them has their own accounts. Ananya has her Gmail, and so do the other ten thousand. An end user connects once, and the account stays theirs across every conversation, so you never send them through a sign-in again per thread.

const ananya = ws.endUser(row.userId);            // no request

const gmail = await ananya.connections.create({ provider: 'gmail' });
showToUser(gmail.url);                            // they sign in, once
await gmail.waitUntilConnected();

const conversation = await agent.start({ endUserId: ananya.id, attach: gmail });
await conversation.ask('what did I miss this morning?');
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.

Owning an account grants nothing

Naming someone on a conversation is a label. It lets you list their conversations back to them and revoke them when they leave. It does not hand the agent their accounts.

The grant is a separate line, and you can read it:

await agent.attach(gmail);           // every conversation with this agent
await conversation.attach(gmail);    // this one thread only

// or at the moment you start, which is usually what you want:
await agent.start({ endUserId: ananya.id, attach: gmail });
why it is not automatic

An earlier version resolved a person's accounts from the id on the conversation. It was wrong even when it picked correctly: reading the code told you nothing about what an agent could reach, and one mistyped id silently swapped whose mailbox a turn read, with no error and a plausible answer coming back. Attaching costs one line and makes the grant legible.

Attaching at start closes a gap

Between start() and a later conversation.attach(), the conversation exists and can be asked something, and it would answer without the account. Passing attach to start means the first turn already has it.

When somebody leaves

await ananya.connections.list();     // what she has connected, for showing her
await ananya.conversations();        // everything she has said, across every agent
await ananya.revokeConnections();    // cut her off, in one call

Every conversation of hers stops acting as her from the next turn, and nothing is retained.

a secret can belong to one of your end users

Store one with ws.endUser(id).secrets.create({ name, value }), then attach it the same way you attach an account:

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 });
await conversation.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 launch resolves the id and the agent receives the credential, with nothing in between reading it.

Two rules the platform enforces and you cannot switch off. 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. 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.

This used to say per-end-user secrets did not exist, and that was true: a secret could only be bound by value, so a stored one had no way to reach a turn. It was left out rather than shipped as a store that accepted writes and was read by nothing.

Which connections you can declare

130 providers connect today. Use the provider id exactly as written.

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.

providerName
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.

providerName
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 provider id is not checked against this list when you create an agent. A provider 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 a provider id will not be reported when you create the agent: it surfaces later, when someone tries to connect. Check the id against this table.

The two tables above are generated from the provider 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: 'connection', name: 'gmail', required: true }]

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.

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 a credential breaks mid-conversation you find out on the next turn, which is the only moment it matters.