Skip to main content

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.endCursorafter, 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.

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 raw token, but the link is the one to build on: it already targets app_slug and 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 expiresAt rather than assuming a window. Mint one at the moment the user taps Connect. Don't cache them, don't mint them in advance.
  • state comes 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.

Response fields are camelCase

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.

An active agent profile replaces this list

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. status is TOOL_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[].text off the assistant message — from send-message-sync's messages or conversation-state's messageHistory — and take the https://pipedream.com/_static/connect.html?... URL out of it. That copy is there regardless of what the model chose to say.
Don't rewrite the URL

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.

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_schema is what the model reads, not a check the platform runs — arguments are never validated against it. Confirm the string starts with https://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 omitted result_json records 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-token isn'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 firstOption 2 — client tool
Where the user connectsyour own UI, out of bandin the conversation
Who decides it's neededyou, before wiring the appPipedream, at call time
Fires when it isn't needednono
Turn waits for the usern/ayes, up to 5 minutes
You have to buildpicker + connect screena 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.