Third-Party Integrations
Let your users connect their own SaaS accounts — GitHub, Slack, Google Calendar, Notion, Strava, and roughly two thousand others — and then let the model use those accounts as tools inside a conversation. "Check my calendar and draft a reply" becomes something the assistant can actually do, against the user's real account, without you building an integration per app.
Connections are handled by Pipedream Connect.
What you don't have to build
You never see, store, or refresh the user's third-party credentials. The user authorizes the app on a hosted page; the tokens live at Pipedream; the platform addresses them by an identifier it derives for each of your users. There is no endpoint that returns those credentials, and no request parameter that asks for them.
That also means you don't implement OAuth per app, don't run a redirect handler per provider, and don't hold refresh tokens. What you do build is a picker, a link, and a "connected apps" screen.
The shape of it
Connecting is its own flow, run before a conversation needs the app. Your app drives it — picker, link, confirmation — and only wires the tools once an account exists:
This diagram is option 1 of two, and it's the one we recommend — which is why it's the one drawn here and documented in full below. Connecting stays a step you control, so the conversation only ever sees apps that already work. The alternative moves authorization into the conversation itself and skips the whole upfront half; it's option 2, and the two compose.
Everything below is scoped to the calling user. No request body carries a user id: the user is derived server-side from your authentication headers, so a caller can only see and change their own connections. There is no way to address anyone else's.
Connecting an account
1. Show the user what they can connect
curl -X POST https://api.travila.ai/api/v1/integrations/pipedream/list-apps \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{
"q": "cal",
"sort_key": "APP_SORT_KEY_FEATURED_WEIGHT",
"sort_direction": "SORT_DIRECTION_DESC",
"limit": 20
}'
{
"data": [
{
"id": "app_1Q5hjR",
"nameSlug": "google_calendar",
"name": "Google Calendar",
"imgSrc": "https://assets.pipedream.net/s.v0/app_1Q5hjR/logo/orig",
"authType": "AUTH_TYPE_OAUTH",
"categories": ["Productivity"],
"featuredWeight": 12
}
],
"pageInfo": { "count": 1, "totalCount": 34, "endCursor": "Y3Vyc29yOjE=" }
}
nameSlug is the identifier you carry through the rest of this guide. Page with
pageInfo.endCursor → after, and stop when a page comes back with no data.
For a category-first picker, call
list-app-categories
and pass the ids you get back as category_ids. For a detail page,
retrieve-app fetches one app by
slug without paging the catalog.
2. Create a Connect link
curl -X POST https://api.travila.ai/api/v1/integrations/pipedream/create-connect-token \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{
"app_slug": "google_calendar",
"success_redirect_url": "https://app.example.com/integrations?ok=1",
"error_redirect_url": "https://app.example.com/integrations?ok=0",
"state": "picker-session-8f3a"
}'
{
"token": "ctok_5xyz...",
"connectLinkUrl": "https://pipedream.com/_static/connect.html?token=ctok_5xyz...&connectLink=true&app=google_calendar",
"expiresAt": "2026-08-10T10:04:11Z"
}
Four things to get right here:
- Redirect the user to
connectLinkUrl. The response also carries the rawtoken, but the link is the one to build on: it already targetsapp_slugand works as given. See the note below the iframe example if you want your own frontend instead. - Always send
app_slug. Without it the hosted page has nothing to connect and answers "Please include the app in the Connect URL". - The link is single-use and expires — about four hours out today, but read
expiresAtrather than assuming a window. Mint one at the moment the user taps Connect. Don't cache them, don't mint them in advance. statecomes back untouched on your redirect, which makes it the natural place to put whatever you need to resume the flow. It is opaque to the platform, so treat it as untrusted when it returns and never put a secret in it.
To embed the page in an iframe rather than redirecting, add allowed_origins with the exact
origins that will frame it:
{
"app_slug": "google_calendar",
"allowed_origins": ["https://app.example.com"]
}
Omit it for a full-page redirect. Get it wrong and the browser blocks the frame.
Building your own connect UI instead? The hosted page covers redirects, iframes, mobile
web views, and links sent by email, which is most cases. If you want the dialog rendered by
your own web frontend, token is what
Pipedream's browser SDK takes —
follow their docs for it. The token this endpoint returns is the server-created one they ask
you to mint, and you still never handle the external user id: the platform derives it from
the authenticated caller.
3. Confirm the connection landed
The redirect tells you the user finished; list-accounts tells you what actually exists.
Treat this as the source of truth:
curl -X POST https://api.travila.ai/api/v1/integrations/pipedream/list-accounts \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{ "app": "google_calendar" }'
{
"data": [
{
"id": "apn_kAHeAr9",
"name": "user@example.com",
"app": "google_calendar",
"healthy": true,
"createdAt": "2026-08-10T09:14:52Z"
}
],
"pageInfo": { "count": 1, "totalCount": 1 }
}
Keep the id — it's what disconnects this specific connection later.
Request bodies accept either snake_case or camelCase. Responses are always camelCase
(nameSlug, connectLinkUrl, pageInfo), and fields that are empty, zero, or false are
omitted entirely rather than returned as null. Read healthy as "false unless
present", not with a presence check.
Giving the model a connected app's tools
A connected app becomes an MCP server named pipedream:{appSlug}. Put it in the thread's
conversation_settings.mcp_servers and its tools are discovered on every turn:
curl -X POST https://api.travila.ai/api/v1/llm/gateway/create-thread \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{
"conversation_settings": {
"mcp_servers": [
{ "server_id": "pipedream:google_calendar", "enabled": true }
]
}
}'
From then on, every send-message on that thread discovers the app's tools and offers them
to the model, which calls them as that user. Nothing else to wire — the tool call is
executed against their account and the result comes back in the same turn. Use
update-settings to change the list on an existing
thread.
Tool discovery happens per turn, so a connection made after the thread was created is picked up on the next message. There's no need to recreate the thread.
If the thread has an agent profile
active, that profile's mcp_servers replace the conversation's — including when the
profile's list is empty. A pipedream: server wired into conversation_settings is silently
dropped for those turns. Put it on the profile instead when you use profiles.
Narrowing what the model can reach
A whole app is often more than you want to hand over. MCPServerReference takes glob
patterns, and they're enforced server-side at call time — not just when advertising the
tool list, so a model that invents a tool name still can't reach a blocked one:
{
"server_id": "pipedream:github",
"enabled": true,
"allowlist_tool_patterns": ["github-get-*", "github-list-*"],
"blocklist_tool_patterns": ["*-delete-*"]
}
An empty allowlist allows everything, and the blocklist wins over the allowlist. See MCP Tools for how this interacts with the platform's own MCP servers and with client-side tools.
Choosing when the user connects
Everything above assumes the user connected the app before a conversation needed it. That's one of two workable arrangements, and which one fits depends on how much of the connect flow you want to own. Start with what actually happens when the model reaches for an app the user hasn't connected, because that's what decides it.
What an unconnected app does
Not what you'd expect. The tools are still there. Pipedream lists an app's tools whether
or not the calling user has connected an account, so discovery succeeds and the model is
offered the full set. The model calls one, and the call comes back 200, no error,
carrying a connect link as its result:
{
"id": "call_a1b2c3",
"name": "github-search-issues-and-pull-requests",
"serverId": "pipedream:github",
"status": "TOOL_EXECUTION_STATUS_SUCCESS",
"resultJson": {
"items": [
{
"type": "text",
"text": "The user MUST be shown the following URL so they can click on it to connect their account and you MUST NOT modify the URL or it will break: https://pipedream.com/_static/connect.html?token=ctok_6214...&connectLink=true&app=github"
}
]
}
}
Three things follow from that, and they shape both options below:
- The turn isn't blocked. The run continues and the model reads that text as an ordinary tool result. It usually passes the URL on, but it is free to paraphrase — "you'll need to connect GitHub first" — in which case the link never reaches the user and the token expires unused.
- It is recorded as a success.
statusisTOOL_EXECUTION_STATUS_SUCCESS. Nothing in the run says authorization was the reason the turn went nowhere, so this doesn't show up in error rates. - The link is in the tool result either way. Read
toolCalls[].resultJson.items[].textoff the assistant message — fromsend-message-sync'smessagesorconversation-state'smessageHistory— and take thehttps://pipedream.com/_static/connect.html?...URL out of it. That copy is there regardless of what the model chose to say.
The token is bound to that link exactly as issued. Appending, re-encoding, or dropping
connectLink=true breaks it. Pass it through verbatim.
Option 1 — Connect first, then wire the tools
The flow documented above: your app runs the picker and the Connect link, confirms with
list-accounts, and only then puts pipedream:{appSlug} in mcp_servers. The conversation
only ever sees apps the user has already authorized.
Best when there's a small, known set of apps and connecting is part of onboarding or a settings screen. Consent gets a real UI, with your branding, at a moment the user is thinking about integrations.
The catch is that it's a wall in front of the long tail — the user has to predict which apps
they'll want — and it doesn't survive a connection going stale. Revocations and expiry
(the healthy case below) land you in exactly the unconnected state described above,
mid-conversation, with the tools already wired.
Option 2 — Hand the link back with a client tool
Wire pipedream:{appSlug} in regardless of connection state, and declare one
client tool whose only job is to carry that connect
URL out of the tool result and into your UI. The platform doesn't execute client tools — it
hands the call to you — so the URL arrives as an argument you can render, rather than as
prose the model may or may not repeat. You show it, the user authorizes, you answer with
submit-client-tool-results, and the run
picks up where it left off and calls the app for real.
Nothing happens before the conversation. There's no picker, no create-connect-token, no
list-accounts — you create the thread with the app already wired and let the first tool
call that needs authorization ask for it:
Declare it once, as the thread default or per turn via override_generation_config:
{
"default_generation_config": {
"client_tools": [
{
"name": "pipedream_connect",
"server_id": "client",
"description": "Show the user a link to connect a third-party account. Call this IMMEDIATELY whenever a pipedream:* tool returns text containing a connect URL (https://pipedream.com/_static/connect.html?token=ctok_...). Pass the URL EXACTLY as it appeared in that tool result: do not shorten it, re-encode it, drop query parameters, or wrap it in markdown. Do NOT put the URL in your own reply — calling this tool is the only way to show it. After the user connects, call the original tool again.",
"parameters_json_schema": {
"type": "object",
"properties": {
"connectUrl": {
"type": "string",
"description": "The connect URL copied verbatim from the tool result, including every query parameter (token, connectLink, app).",
"pattern": "^https://pipedream\\.com/_static/connect\\.html\\?token=ctok_[A-Za-z0-9]+&connectLink=true&app=[a-z0-9_-]+$"
},
"appSlug": {
"type": "string",
"description": "The app being connected, e.g. \"github\"."
}
},
"required": ["connectUrl", "appSlug"],
"additionalProperties": false
}
}
]
}
}
The call comes back on the assistant message with isClientTool: true, and you reply with
the tool_call_id / tool_name pair and any result_json — {"connected": true} is
enough; the model only needs to know it can retry.
Four things to get right:
- The description is the implementation. Nothing on the platform notices that a tool result contains a connect URL — the model does, because you told it to. Keep the "IMMEDIATELY", the "EXACTLY", and the "do NOT put the URL in your own reply"; each one is covering a way the model otherwise loses the link.
- Validate the URL before you open it.
parameters_json_schemais what the model reads, not a check the platform runs — arguments are never validated against it. Confirm the string starts withhttps://pipedream.com/_static/connect.html?token=ctok_on your side before rendering it. - You're on a clock. The run waits 5 minutes for your result by default — tunable
with
tool_policy.client_tool_timeout_ms, and there is no unbounded setting. That's the whole budget for noticing the card, tapping it, and finishing OAuth in a browser. Always submit something: an empty or omittedresult_jsonrecords the call as failed, which is a cleaner outcome than burning the timeout. - You never mint the token here. The link comes from Pipedream, through the tool result;
create-connect-tokenisn't in this path at all. So it can only appear when authorization is genuinely missing — an already-connected app just returns its answer, and no card is ever shown to someone who doesn't need one.
The cost is that every attached app's tools sit in the prompt on every turn whether or not they're usable, and that a turn spent asking for authorization is still recorded as a success.
Which one
| Option 1 — connect first | Option 2 — client tool | |
|---|---|---|
| Where the user connects | your own UI, out of band | in the conversation |
| Who decides it's needed | you, before wiring the app | Pipedream, at call time |
| Fires when it isn't needed | no | no |
| Turn waits for the user | n/a | yes, up to 5 minutes |
| You have to build | picker + connect screen | a client-tool handler that renders a URL |
Start with option 1. It's our recommendation and the flow the rest of this guide documents: it's the more predictable of the two, nothing reaches the model until it's known to work, and consent gets a real screen at a moment the user is thinking about integrations.
They compose, though, and most apps end up wanting both — option 2 underneath as the backstop for everything option 1 can't cover: the long tail the user never thought to connect, and a connection that goes stale after you wired the tools.
Managing connections
Health
A connection can rot — the user revokes access upstream, a password change invalidates the
grant, an OAuth app gets uninstalled. Those accounts come back with healthy absent
(i.e. false) and a populated error:
{
"data": [
{
"id": "apn_kAHeAr9",
"name": "user@example.com",
"app": "google_calendar",
"error": "The credentials for this account are no longer valid"
}
]
}
There is no repair call. The fix is to run the connect flow again for that app.
Disconnecting one app
curl -X POST https://api.travila.ai/api/v1/integrations/pipedream/delete-account \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{ "account_id": "apn_kAHeAr9" }'
The app's tools stop working for that user immediately; their other connections are
untouched. An account_id that isn't the caller's own is rejected as not found — the
response deliberately doesn't distinguish "someone else's account" from "no such account".
Offboarding a user entirely
When a user deletes their account with you, remove their integration data too:
curl -X POST https://api.travila.ai/api/v1/integrations/pipedream/delete-external-user \
-H "X-API-Key: sk_your_key_here" \
-H "X-On-Behalf-Of: user_123" \
-H "Content-Type: application/json" \
-d '{}'
{ "deleted": true, "accountsDeleted": 3 }
This removes the user from Pipedream along with every account they connected. It takes no parameters — the user is the caller, and there is no form of the request that can name anyone else. It is not reversible; they can connect again afterwards, but as a new connection.
accountsDeleted is counted immediately before the delete, so a connection created in that
instant is still deleted, just not counted. Treat it as a report, not a receipt.
Related
- MCP Tools — how tools reach the model, approval flows, and client-side tools
- Conversations — threads, messages, and generation
- Third-Party Integrations API reference — every endpoint and field