Multi-tenant apps
By default, Swytchcode runs every call with your connected accounts. When you build an app whose own users each connect their Gmail, Slack or Stripe, every call has to run with that user’s account instead.
Swytchcode calls these users end users (or tenants). You name each one with your own id for them, the tenant id, and pass it on every call made for them.
Your app (logged-in user "alice") │ exec("gmail.user.profile.get", args, { tenantId: "alice" }) ▼Swytchcode picks alice's own Gmail credential │ ▼Gmail API (as alice)A call made for an end user never falls back to your own account. If alice has not connected Gmail, the call fails with tenant_not_connected and nothing is sent.
Before you begin
- The Runtime SDK on your server (JavaScript or Python), or the CLI.
- Your server signed in with a Swytchcode API key (
SWYTCHCODE_TOKEN) and linked to a workspace. - Each provider your end users connect set up once by you (step 1 below).
1. Set up each provider once
Run this yourself once per provider:
swy auth connect gmailThis is where you choose which OAuth app your end users sign in through: Default Swytchcode (ours) or your own developer app (Business and Enterprise). Your end users never see or change this choice. Until you have made it, connecting an end user to that provider fails with setup_required.
2. Connect an end user (OAuth)
Add one server route that creates a connect link for the logged-in user and returns it to the browser.
// Node.js (Express)const { connect } = require("@swytchcode/runtime");
app.post("/integrations/gmail/connect", requireLogin, (req, res) => { const { url } = connect({ provider: "gmail", tenantId: req.user.id }); res.json({ url });});# Python (FastAPI)from swytchcode_runtime import connect
@app.post("/integrations/gmail/connect")def connect_gmail(user = Depends(current_user)): return {"url": connect("gmail", user.id)["url"]}In the browser, open the link in a popup directly from the user’s click:
button.onclick = async () => { const popup = window.open("", "connect", "width=520,height=720"); const { url } = await (await fetch("/integrations/gmail/connect", { method: "POST" })).json(); popup.location.href = url;};Opening the popup inside the click (and loading the link afterwards) keeps browsers from blocking it. The end user signs in to Google and closes the window; the connection is ready.
Each link works once and expires after 10 minutes, so create a new one on every click. A reopened or forwarded link is refused.
Bots and mobile apps
There is no browser click to open a popup from, so hand the end user the link itself. Create it on your server only when they ask to connect, from the identity your bot or app already trusts:
- Chat bots (Slack, Telegram, WhatsApp, Discord): send the link in a direct message to that user only, never in a shared channel or group, where anyone could open it first. Take the tenant id from the platform’s verified sender id, not from text the user typed.
- Mobile apps: have your backend create the link for the signed-in user and open it in the system’s in-app browser (
ASWebAuthenticationSessionon iOS, Custom Tabs on Android), not an embedded web view: Google and other providers refuse sign-in inside web views.
The same rules apply: one link per request, it works once, and it expires after 10 minutes. When the user finishes, the page tells them they can close it. Your bot or app learns the account is ready from the next call made for them, which no longer fails with tenant_not_connected, or from swy auth status --tenant <id>.
3. Run calls for the end user
Pass the tenant id on every call made for that user:
const { exec } = require("@swytchcode/runtime");
const profile = await exec("gmail.user.profile.get", { params: { userId: "me" } }, { tenantId: req.user.id });from swytchcode_runtime import exec
profile = exec("gmail.user.profile.get", {"params": {"userId": "me"}}, tenant_id=user.id)swy exec gmail.user.profile.get --tenant aliceThe first call for an end user fetches their credential and caches it on your server; later calls use the cache and refresh it when needed.
If your policies ask for human approval, also say who the end user is, so the approver knows whose account the call runs on:
await exec("stripe.refund.create", args, { tenantId: req.user.id, tenantLabel: `${req.user.name} (${req.user.email})` });exec("stripe.refund.create", args, tenant_id=user.id, tenant_label=f"{user.name} ({user.email})")The label only reaches the approval message. Without it, the approver sees the tenant id alone.
API-key providers
For providers that use API keys (Stripe, OpenAI, …), collect the key in your own form, send it to your server, and save it there:
const { saveKey } = require("@swytchcode/runtime");
saveKey({ provider: "stripe", tenantId: req.user.id, key: req.body.stripeKey });from swytchcode_runtime import save_key
save_key("stripe", user.id, form.stripe_key)The key is stored encrypted on your server only and is never sent to Swytchcode. Swytchcode records only that this end user has a key, so they count toward your plan’s end-user limit; saving a key therefore needs your API key and a connection to Swytchcode.
Disconnect an end user
When a user disconnects a provider or deletes their account:
const { disconnect } = require("@swytchcode/runtime");
disconnect({ provider: "gmail", tenantId: req.user.id });from swytchcode_runtime import disconnect
disconnect("gmail", user.id)AI agents
When an AI agent picks the tools, bind the client to the end user so every tool it runs uses that user’s accounts. Create one client per request, from your server’s session:
import { Swytchcode } from "@swytchcode/runtime";
const client = new Swytchcode(provider, { tenantId: req.user.id, tenantLabel: req.user.email });const tools = await client.tools.get({ toolkits: ["gmail"] });// ... pass tools to your agent framework; every call runs for req.user.idfrom swytchcode_runtime import Swytchcode
client = Swytchcode(provider, tenant_id=user.id, tenant_label=user.email)tools = client.tools.get(toolkits=["gmail"])This covers client.tools.get() with any framework (OpenAI Agents, Vercel AI, LangGraph, CrewAI) and handleToolCalls / handle_tool_calls.
Policies
Policies apply to calls made for end users like any other call, with these rules:
- What a policy remembers is kept per end user.
called_before,returned_byandrepeatonly see the same end user’s earlier calls: a charge listed for alice never allows bob to refund it, and bob sending what alice sent is not a repeat. - Approvals are per end user. An approval (and its
reusewindow) for alice’s call never covers bob’s. - Volume limits are shared unless you say otherwise.
rateandsumcount every end user together, which protects your provider quota. Add"per": ["$tenant"]to give each end user their own limit:
{ "id": "emails-per-user", "target": ["gmail.send"], "when": { "operator": "rate", "window": "1h", "max": 20, "per": ["$tenant"] }, "action": { "type": "RATE_LIMITED", "message": "{{$tenant}} has sent {{$count}} emails this hour" }}$tenantworks in any condition, for example{"field": "$tenant", "operator": "in", "value": ["trial-1", "trial-2"]}.- Policy alerts go out on your own account, never the end user’s.
Rules that keep end users apart
- Take the tenant id from your server’s session. Never from the browser, a request body, a query string or a header the client controls: anyone could send another user’s id.
- Never call without the tenant id for an end user. A call without one runs on your own account.
- Create connect links on the server and hand them only to the user they were made for.
- Keep the provider choice to yourself. It is made once with
swy auth connect <provider>.
Errors
| Category | Exit code | Meaning | What to do |
|---|---|---|---|
tenant_not_connected | 3 | This end user has not connected the provider. | Show them your Connect button. Do not retry. |
setup_required | 3 | You have not set the provider up yet, or it needs your own app and you have not registered one. | Run swy auth connect <provider> once. |
plan_limit | 3 | Your plan does not allow this: the end-user limit is reached, or the provider needs your own app. | Upgrade (the error’s docs_url links to it). |
In the SDKs the category is on error.details.category (JavaScript) or error.details["category"] (Python).
Plans and limits
- End users per plan: Developer 10, Pro 1,000, Business 10,000, Enterprise custom.
- An end user is a tenant id within one workspace. alice connecting Gmail and Google Calendar in one workspace counts once; the same id in another workspace (another project) counts again. OAuth connections and saved API keys count the same way.
- Reconnecting an end user who already counts is always allowed, even at the limit.
- Executions made for end users count toward your monthly execution limit like any other, including those made with an API key.
- Providers that charge per API call (for example X) run only on your own developer app, available on Business and Enterprise.
Audit and approvals
-
Every local audit entry records the tenant id and the connected account used (never the credential). Filter with
swy audit stats --tenant alice(alsonetworkandpolicy). -
Cloud audit logs carry
tenant_idandconnected_account_uuid. -
Human approvals are per end user: an approval for alice’s call never covers bob’s, and the approved call runs again for alice only.
-
The approver’s Slack or Telegram message says who asked and for whom:
Approval RequiredRefund of 750 for alice needs approvalRequested by: Jane Doe (Acme Inc, [email protected])Workspace: blue-otter-4f2aEnd user: Alice Smith ([email protected]), id alicePolicy: big-refundThe first line is the policy’s
message, with its placeholders filled in (without one:<policy id> - <tool>). “Requested by” is your Swytchcode account and workspace. “End user” is thetenantLabelyou passed, with the tenant id; without a label it shows the id alone. Calls on your own account have no “End user” line.
CLI equivalents
| SDK | CLI |
|---|---|
connect({ provider, tenantId }) | swy auth connect <provider> --tenant <id> --json |
saveKey({ provider, tenantId, key }) | swy auth connect <provider> --tenant <id> with the key on stdin |
disconnect({ provider, tenantId }) | swy auth disconnect <provider> --tenant <id> |
exec(id, args, { tenantId }) | swy exec <id> --tenant <id> |
exec(id, args, { tenantId, tenantLabel }) | swy exec <id> --tenant <id> --tenant-label "<label>" |
| - | swy auth status --tenant <id> lists one end user’s accounts |