Connections
Let functions act on the account of the person running them, such as their Gmail or calendar, with your own OAuth app.
Connections let a function act on the account of the person running it: their Gmail, their calendar, their Slack. Each person signs in once with the service, and every run they start gets a fresh access token for their own account.
Use secrets when a function should use one shared account, such as your company's Stripe key. Use a connection when each person should use their own.
The services you can connect are connectors. Each has a setup guide on the connectors page: Google, Microsoft, Slack, GitHub, Notion and any other OAuth 2.0 service. This page is the reference for declaring and using them.
How it works
You bring the OAuth app, and Callables runs everything around it:
| You | Callables |
|---|---|
| Create an OAuth app with the service and choose its scopes | Hosts the redirect, so people can sign in |
| Put the app's client ID and secret in project secrets | Stores each person's tokens encrypted, and refreshes them |
| Declare the connection in code and read its token | Gives each run a token for the person who started it |
| Asks people to connect, reconnect or approve new scopes |
Because the app is yours, you choose any scopes the service offers, and the sign-in screen shows your app's name, so people see exactly who they're granting access to.
Declare a connection
from callables import Connection, deploy
import requests
google = Connection("google", provider="google", scopes=["gmail.modify", "calendar.readonly"])
@deploy(title="Triage my inbox", connections=[google])
def triage_inbox(max_messages: int = 25) -> str:
"""Labels your newest unread emails by topic."""
headers = {"Authorization": f"Bearer {google.token}"}
resp = requests.get("https://gmail.googleapis.com/gmail/v1/users/me/messages",
headers=headers, params={"q": "is:unread", "maxResults": max_messages})
...- The first argument is the connection's name. It's how Callables tells connections apart in a project, so renaming it makes everyone connect again.
providerpicks a preset that knows the service's sign-in details. See providers.scopesare the permissions you ask for. Presets accept short names, so"gmail.modify"meanshttps://www.googleapis.com/auth/gmail.modify. Full scope URLs work too.- Declaring a
Connectionat module level is cheap and makes no network calls, so it's safe at import time.
Inside the function, read these properties. They only have values during a run:
| Property | What it is |
|---|---|
google.token | An access token for the person running the function, valid for the whole run |
google.account | Which account they connected, such as [email protected], when the service says |
google.scopes_granted | The scopes they granted |
google.extra | Other values the service returned at sign-in, such as Salesforce's instance_url |
The token is also in the environment as <NAME>_ACCESS_TOKEN (here GOOGLE_ACCESS_TOKEN), for tools that read it from there.
A module-level object whose token belongs to whoever is running it is safe here because every run is its own fresh process: one run, one person. Don't copy this pattern into a long-running web server.
A group can declare connections once for every action in it: Group("mail", connections=[google]).
Set up the OAuth app
Once per project:
- Create an OAuth app (sometimes called a client or an app registration) in the service's developer console.
- Add this redirect URI:
https://callables.app/connections/callback - Add the scopes your code asks for, if the console asks you to list them.
- In the project's secrets, set the app's client ID and secret. By default they're named after the connection:
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRETfor a connection namedgoogle. To pick the names, passclient_id=Secret("...")andclient_secret=Secret("..."). See secrets and values. - Push, then open the project page. Its Connections card shows the redirect URI to copy, any missing secrets, and a Connect button to try the connection with your own account.
The redirect goes to the Callables web app, which hands the code to the API with your signed-in session. So a connection is always saved for whoever is signed in, and nobody can attach their account to someone else's.
Whether the service needs to review your app is between you and the service. Apps only your own organization uses usually don't need review: for Google, set the app's user type to Internal on a Google Workspace account. Google apps left in Testing status get tokens that expire after 7 days, so people would have to reconnect every week. An External Google app can also be switched to In production without verification: people see an "unverified app" warning, and it's capped at 100 users.
Connections are built for apps your own team uses. Google's verification, for a public app with no warning or user cap, requires the redirect URI's domain to be one you own, and the redirect is on callables.app, so verified Google apps aren't supported yet.
What people see
The first time someone opens a function that needs a connection, they see what it uses and a Connect button instead of Run:
- Connect Google opens the service's own sign-in screen, showing your app's name and the permissions it asks for.
- After they allow it, they're sent back and can run the function.
- If they untick a permission on the consent screen (Google lets people do this for Gmail, Drive and others), the connection is refused with a message naming what's missing, so they never end up connected without the access the function needs.
- Other functions in the same project that use the same connection run right away.
- If you add scopes, their next run asks them to Reconnect, and the service asks only for the new permissions.
- If they remove access from the service's side, the function asks them to reconnect instead of failing during the run.
Each person manages their connections on the Connections page in the sidebar: which account each one uses, which project it's for, its permissions and when it was last used. Disconnect deletes the tokens from Callables and revokes them with the service.
Whose account a run uses
| How the run started | Connection it uses |
|---|---|
| Someone runs it from the form | Theirs |
| A schedule | The person who made the schedule |
A run never uses another person's connection. If the person it runs as hasn't connected, it doesn't start, and they're asked to connect. That's different from secrets, where a missing secret doesn't block the run.
Builders never see anyone's tokens in the web app. Your code does receive them during a run, so only ask for the scopes the function needs.
Security
- Tokens are encrypted at rest, and only the API and the run workers decrypt them.
- A token only reaches runs started by the person it belongs to, or by schedules they made.
- Tokens are hidden in run logs, like secrets.
- Disconnect deletes the tokens and, where the service allows it, revokes them.
- Removing a connection from your code disconnects everyone who connected it, on the next deploy.
- The URLs a connection uses must be public
httpsaddresses.
Providers
These presets fill in each service's sign-in details:
| Provider | Covers | Scope examples |
|---|---|---|
google | Gmail, Calendar, Drive, Sheets, Docs, Contacts | gmail.modify, calendar.readonly, drive.file |
microsoft | Outlook mail and calendar, OneDrive, Teams, Excel, SharePoint | Mail.ReadWrite, Calendars.Read, Files.ReadWrite |
slack | Messages, channels, users | chat:write, channels:read |
github | Repos, issues, pull requests | repo, read:org |
notion | Pages and databases the person shares with your app | none (Notion sets access when they connect) |
Presets handle each service's quirks for you. For example, google asks for offline access so the connection lasts, microsoft adds offline_access to the scopes, and slack asks for a user token so the run acts as the person.
Microsoft's URLs contain {tenant}, which is common unless you fill it in. common works for multi-tenant apps. An app only your own organization uses (single tenant) needs its Directory (tenant) ID, from a project secret:
outlook = Connection(
"outlook",
provider="microsoft",
scopes=["Mail.ReadWrite"],
client_id=Secret("MS_CLIENT_ID"),
client_secret=Secret("MS_CLIENT_SECRET"),
values={"tenant": Secret("MS_TENANT_ID")},
)See the Microsoft connector guide.
Secrets and values
A connection never has your app's details written in its code. They come from secrets:
- Client ID and secret. By default a connection reads the project secrets
<NAME>_CLIENT_IDand<NAME>_CLIENT_SECRET, named after the connection, soConnection("google", ...)readsGOOGLE_CLIENT_ID. Name your own withclient_id=Secret("MS_CLIENT_ID")andclient_secret=Secret("MS_CLIENT_SECRET"). Several connections can then share one app's secrets. - Values in the URLs. Any
{placeholder}in a connection's URLs orauthorize_paramsis filled fromvalues. That's how oneConnectioncovers services with their own domains, tenants or regions, without an SDK update.
| Value | Where it comes from | Use it for |
|---|---|---|
Secret("NAME") | The project secret NAME on Callables, and the environment variable (or .env) locally | Anything that's the same for everyone: a tenant ID, an Okta or Auth0 domain, a region |
Secret("NAME", default="...") | The same, or the default when it isn't set | An optional setting, such as Microsoft's tenant (common) |
Ask("prompt") | The person connecting: the Connect button shows a text box with the prompt, and call connect asks in the terminal. Each person's answer is saved with their connection | Something that differs per person, such as their Shopify store |
| A plain string | Your code | A constant that never changes |
Secret is a reference to the secret, not its value. Callables reads connection settings when it builds, before secrets exist, and signs people in without running your code, so it needs the name and looks the value up itself.
Every placeholder must be filled, and every value must match a placeholder. A typo fails at import, so call check catches it before you push. Filled-in values must be plain names (letters, digits, . and -), so they can't change the shape of a URL.
Any other OAuth service
For a service without a preset, give its URLs instead of provider. Most services only need the first three:
crm = Connection(
"crm",
authorize_url="https://login.somecrm.com/oauth/authorize",
token_url="https://login.somecrm.com/oauth/token",
scopes=["contacts.read"],
)When a service does things its own way, these options cover it:
| Option | Default | Use it when |
|---|---|---|
authorize_params | none | The service needs extra values on the sign-in link, such as {"audience": "api.example.com"} |
token_endpoint_auth_method | "client_secret_post" | The service wants the client ID and secret as HTTP Basic auth: set "client_secret_basic" |
token_request_format | "form" | The service wants the token request as JSON: set "json" |
scope_separator | " " | The service separates scopes with commas: set "," |
scope_param | "scope" | The service reads scopes from another parameter, such as Slack's user_scope |
pkce | False | The service requires PKCE |
token_path | top level | The token sits deeper in the response, such as "authed_user.access_token" |
userinfo_url | none | A URL that returns the signed-in account, so people see which one they connected |
account_field | "email" | The field that names the account: in userinfo_url's response, or in the token response when there's no userinfo_url |
revoke_url | none | Where to revoke a token when someone disconnects |
values | none | The URLs or authorize_params contain {placeholders}. See secrets and values |
A service on your own domain, such as Okta, fills the domain from a secret:
okta = Connection(
"okta",
authorize_url="https://{domain}/oauth2/v1/authorize",
token_url="https://{domain}/oauth2/v1/token",
scopes=["openid", "email"],
values={"domain": Secret("OKTA_DOMAIN")},
)A service where each person has their own instance, such as Shopify, asks them:
shop = Connection(
"shopify",
authorize_url="https://{shop}.myshopify.com/admin/oauth/authorize",
token_url="https://{shop}.myshopify.com/admin/oauth/access_token",
scopes=["read_orders"],
scope_separator=",",
values={"shop": Ask("Your store name, the part before .myshopify.com")},
)Callables refreshes tokens on its own, keeps the new refresh token when a service changes it on every refresh, and treats a token with no expiry as long-lived. Connections need OAuth 2.0. For a service that only gives out API keys, use secrets.
Locally
Sign in once on your machine, and call local and call <function> run with your own account:
call connect google- Put the connection's secrets in the project's
.env(or one at the top of the repo): the client ID and secret, and anySecret(...)values. They're the same names and values as the project's secrets. - Add
http://localhost:8765/callbackas a second redirect URI on the app, next to the Callables one. - Run
call connectand pick the connection (orcall connect <name>). It opens the service's sign-in in your browser, catches the redirect on your machine, and checks that every permission was granted.
Nothing goes through Callables. The tokens are kept in your user config folder (~/.config/callables/ on Linux and macOS), readable only by you, and refreshed when they're about to expire. This local connection is separate from the one you make in the web app. call disconnect <name> forgets it.
A token you set yourself wins: put one in .env or your shell as <NAME>_ACCESS_TOKEN, for CI or a token from elsewhere. --port uses another port for the redirect, and --no-browser prints the sign-in link instead of opening it.
call check catches problems before you push, such as an unknown provider, a URL that isn't https, or one connection name defined twice with different settings. It also lists the secrets each connection reads.
Rules
- Read
.tokeninside the function, never at module level. There's no token at build time. - Never print or log a token. Callables hides known token values in logs, but don't rely on it.
- Ask for the narrowest scopes that work, such as
drive.fileinstead ofdrive. - Don't take a token, password or account email as a form input. Use a connection.
- Use one connection per service in a project, and put every scope the project needs on it, so people only connect once.