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 }],
});scope | Who supplies it | Reaches |
|---|---|---|
fixed | you, once, on the agent | every conversation, including ones already open |
conversation | each conversation, on itself | that 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 }]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.
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 onlyBoth 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 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. 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?');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 });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 callEvery conversation of hers stops acting as her from the next turn, and nothing is retained.
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.
provider | 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.
provider | 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 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.
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.