How Integrations Work
What an integration is, how an agent reaches a third-party service through it, how users connect accounts, and how credentials stay out of the agent's reach.
An integration is an AFPS package of type integration, addressed as @scope/name (for example @appstrate/gmail). It declares how to authenticate against a third-party service, which URLs a credential may be sent to, and how the agent reaches the service. Appstrate ships 70 of them; you can write your own.
This page is the overview. The runtime detail lives in the repository: INTEGRATIONS_RUNTIME.md and, for the wire format, the AFPS specification.
Integration, connection, agent
Three things are kept apart on purpose.
| Concept | What it is | Who owns it |
|---|---|---|
| Integration | The definition of a service: auth methods, authorized URIs, tools. A package. | The platform (system integrations) or your organization (custom ones) |
| Connection | One authenticated account on one auth method of an integration (a Gmail login, a Stripe key). Credentials are encrypted at rest with CONNECTION_ENCRYPTION_KEY. | A user, or an end-user |
| Agent | Declares which integrations it needs and which tools and scopes it uses. At run time the platform binds connections to it. | Your organization |
Sources: how the agent reaches the service
Every integration declares a source.kind.
source.kind | What the agent gets | Typical use |
|---|---|---|
none | An api_call tool: the agent supplies method, URL, headers and body, and the sidecar injects the credential. No MCP server involved. | Plain REST APIs. 59 of the 70 shipped integrations. |
remote | The tools of a hosted MCP server (Streamable HTTP or SSE). The sidecar opens the MCP connection and injects the access token on every request. | Vendors that publish an MCP server: Gmail, GitHub, Notion, ClickUp, Canva, MCP Emails, GitLab, Twenty, Coolify. |
local | The tools of an mcp-server package (Node, Python, binary or uv runtime) started in its own sandboxed runner container, one per connection. | Work that is not a single HTTP API: a git working tree, SSH. |
api_call is a capability that any integration can opt into, whatever its source. Tools reach the agent under a namespace prefix, {namespace}__{tool}. When an agent has several connections bound to one integration, every tool of that integration gets a required connection argument listing the connection labels, so the model chooses explicitly.
A local integration needs an isolating run adapter (docker or firecracker). The default process adapter refuses to start a local runner, because a same-user subprocess could read the sidecar's environment. See Isolation and Security.
Authentication methods
Each integration declares one or more entries under auths, each with a type.
type | How the user connects | Example |
|---|---|---|
oauth2 | OAuth 2.0 authorization code flow (with PKCE when the integration declares it), endpoint discovery and automatic token refresh | Gmail, Slack, Notion |
api_key | The user pastes a key on a hosted form | Stripe, Firecrawl, Brevo |
basic | Username and password | |
mtls | Client certificate and key, mounted as files for the runner | |
custom | A free-form set of fields defined by the integration, optionally with a declarative login or a login tool | WordPress, WooCommerce, SSH |
An integration may declare several auths (the GitHub MCP integration accepts OAuth or a personal access token). OAuth 1.0a is not a built-in auth type.
Credential isolation
The agent runs in one container and the credentials live in another, the sidecar. Three rules hold for every integration:
- The agent never sees a credential. For
api_call, the sidecar validates the target against the connection'sauthorized_uris, injects the header and forwards the request. The agent only gets the response (status, filtered headers, body). - Credentials are bound to hosts. Each auth lists
authorized_uris(glob patterns such ashttps://api.stripe.com/**). A pattern can reference a connection field, for example{$credential.site_url}/**for WordPress, rendered per connection. A call that carries a credential cannot useallow_all_uris: it must match a listed pattern, and so must every redirect hop. A host wildcard binds a credential only under a registrable domain written in the pattern (https://*.zendesk.com/**, nothttps://*.vercel.app/**), judged with the Public Suffix List, and only to hosts whose own registrable domain stays inside it; see Custom Integrations. A pattern can also reference a connection variable, such as the instance URL of a self-hosted GitLab. Private and loopback addresses are blocked by default, and so are the nameslocalhostand every*.localhost. - With
httpdelivery, the integration's own server never reads the token either. A per-run proxy inside the sidecar adds the header on the way out, refreshes an expiring OAuth token and retries once after a401. Only integrations that declareenvorfilesdelivery receive the secret in their runner, and that runner can reach only the hosts its connection allows.
How a credential is injected is the delivery block of the auth: http (a header, cookie or similar, added by the sidecar), env (environment variables of a local runner) or files (a mounted file, for certificates and SSH keys).
Setting up an integration
An admin does this once per integration. Members then connect their own accounts.
Activate the integration in the space
Activation uses the same call as every other package type: POST /api/spaces/{spaceId}/packages, and DELETE /api/spaces/{spaceId}/packages/{scope}/{name} to deactivate. Deactivating keeps the settings and the existing connections. An operator can make integrations active by default for every organization with the SYSTEM_INTEGRATIONS environment variable (see Environment Variables).
Provide an OAuth client (OAuth 2.0 only)
OAuth 2.0 needs a client registered with the third-party service. Where it comes from depends on the integration. For a classic confidential client (Google, Slack, GitHub and most others) it is the first that exists of: a space client, an organization client (a space client can be promoted to the organization tier), or a system client shipped by the operator through SYSTEM_INTEGRATIONS. For a remote MCP integration that declares a public client (Notion, ClickUp, Canva, MCP Emails, GitLab, Twenty), the platform registers a client automatically at connect time using discovery and dynamic client registration, so there is nothing to configure, and registering a manual client for it is refused.
Registering your own client uses POST /api/integrations/{scope}/{name}/auths/{authKey}/oauth-clients with client_id and client_secret (a public client is declared with token_endpoint_auth_method: "none" and no secret). The redirect URI to register at the provider is APP_URL followed by /api/integrations/callback; the same value is shown in the integration's setup panel. API-key integrations skip this step.
Optionally restrict who can connect
block_user_connections (PATCH /api/integrations/{scope}/{name}/settings) stops members from creating their own connections for an integration in the space. Governance actions need the integrations:configure permission, which an API key can never hold.
Connecting an account
Connecting is mostly agent-driven: it happens from the agent's screens (the connection section of an agent, or the prompt shown when a run cannot start). That is what lets the platform ask only for the OAuth scopes the agent's tools need. The Connections tab of an integration's page also has a connect button, and lists the accounts already connected.
There are two ways in, both needing the integrations:connect permission.
Hosted Connect portal (interactive). POST /api/integrations/{scope}/{name}/auths/{authKey}/connect/session returns a single-use connect_url, valid for ten minutes by default (CONNECT_SESSION_TTL_MS). Opening it sends the user to the provider's OAuth screen, or to a hosted form for API keys and custom fields. The secret is typed on the platform's page and never passes through the caller, the model or a chat transcript. This is the surface agents and UIs should use.
Programmatic. For a backend that already holds the credential, POST .../connect/fields imports a connection from { "credentials": { ... } }. To drive the OAuth redirect yourself, POST .../connect/oauth2 returns an auth_url.
Connection variables. Some integrations, such as GitLab, Twenty and Coolify, ask where the service lives before anything else: the URL of your instance. The hosted form collects these values first, even for an OAuth integration, and the programmatic routes take them as variables next to the credentials. They are not secrets: they are stored in plaintext, shown on the connection (variables on the connection), and changed only by reconnecting. Each URL built from them must pass the egress rules, so a private address is refused unless the operator lists its host in EGRESS_ALLOW_INTERNAL_HOSTS. When the OAuth server depends on that URL, the platform discovers it and registers a client there by itself, and the server answers on a redirect URI of its own, /api/integrations/callback/{tag}.
appstrate api POST /api/integrations/@appstrate/stripe/auths/primary/connect/session
# { "connect_url": "https://.../api/integrations/connect/start?token=...", "expiresAt": "..." }List your own connections with GET /api/me/connections and delete one with DELETE /api/me/connections/{connectionId}. The delete is destructive: it removes the connection everywhere, and it is refused with 409 connection_pinned while an admin pin or an organization default names it. In the same transaction:
- your own member pins drop the connection, and a pin left empty is removed;
- your own schedule overrides drop it, and a schedule whose override for an integration empties loses that integration and is disabled, so an unattended run never falls back to another account;
- other actors' enabled schedules naming it are disabled (
disabled_reason: connection_deleted) with their overrides kept; - other members' pins keep the id, and their runs fail with
pinned_connection_unavailableuntil they pick again.
GET /api/me/connections/{connectionId}/delete-impact previews the delete from the same plan the delete applies: the pins and schedules of yours it rewrites, and other_schedules_disabled_count, the number of other actors' schedules it disables, never their names. It answers empty lists for a connection you do not own. In the dashboard, the delete confirmation shows that preview and cannot be confirmed until it has loaded.
Scopes
Scopes are inferred, not typed in by hand. An integration lists the scopes the provider offers in scope_catalog and maps each tool to the scopes it needs in tools_policy.{tool}.required_scopes. An agent declares the tools it wants, and the platform requests the union of the integration's default scopes, the scopes of the selected tools, and what the account already granted. Re-consenting never shrinks an existing grant. If a later token refresh returns a narrower grant than an installed agent needs, the connection is flagged as needing reconnection.
Declaring integrations in an agent
An agent manifest splits the version from the configuration. Excerpt of the agent manifest:
{
"dependencies": {
"integrations": { "@appstrate/gmail-mcp": "^2.0.0" }
},
"integrations_configuration": {
"@appstrate/gmail-mcp": {
"tools": ["search_threads", "get_thread", "create_draft"]
}
}
}- Leaving
toolsout uses the integration'sdefault_tools(["api_call"]for the API integrations). An explicit empty list selects nothing. - An agent whose integration exposes no callable tool is refused when a version is published or imported, and a run fails at boot if the tool set is empty, rather than letting the model improvise unauthenticated HTTP.
tools: "*"(every tool the upstream server advertises) is allowed only when the integration setsallow_undeclared_tools: true.scopesadds scopes beyond the inferred ones, andauth_keypicks one auth when the integration has several.
See Agents for the rest of the manifest.
Which connection a run uses
A run binds a set of one to twenty connections per integration. The platform resolves it through six layers, the first non-empty one winning:
- An admin pin for that agent (
PUT /api/integrations/{scope}/{name}/pins/{agentScope}/{agentName}). - An enforced organization default (
PUT /api/integrations/{scope}/{name}/defaultwithenforce: true). - A launch override:
connection_overridesin the run request, or the override saved on a schedule. - A member pin (
PUT /api/me/integration-pins/...). - A soft organization default (
enforce: false). - The fallback: the caller's single own connection on an auth that serves the selected tools.
A connection is private to its owner until the owner shares it with the space it lives in (shared_with_org: true on PATCH /api/integrations/{scope}/{name}/connections/{connectionId}). An end-user's connection can never be shared (409 end_user_connection_not_shareable). When a shared connection stops being shared, by its owner, by a holder of integrations:configure or because the owner lost access to the space, other actors' enabled schedules naming it are disabled (disabled_reason: connection_unshared) with their overrides kept. A shared connection is never picked implicitly: it has to be named by a pin, a default or an override. An admin pin or an enforced default can bind a shared connection only, so an admin cannot take over a member's private account. When the platform cannot decide, the run request fails with 409 missing_integration_connection, listing for each integration the reason (not_connected, needs_reconnection, insufficient_scopes, must_choose_connection, ...) and the candidates. Check an agent beforehand with GET /api/agents/{scope}/{name}/connection-readiness.
For headless use, create an end-user and connect accounts to it; runs launched with the Appstrate-User header act as that end-user, and the fallback layer binds the end-user's own connection.
Every layer yields connections the actor can still reach. A pinned or default connection that was deleted or unshared fails the run (pinned_connection_unavailable) instead of silently falling back to another account.