Catalog entries
How to declare one AI integration - the AiIntegrationSpec fields, the auth axis of none, api key, HTTP Basic, OAuth sign-in and MCP headers, plan-gated entries, and a worked Google Search Console entry.
A catalog entry is pure, non-secret metadata in packages/config/src/ai/integrations.ts: it renders the connect form, says how the backend reaches the service, and decides whether the model may call it. The storing, probing and tool-building are AI integrations.
Add an entry
aiIntegrations array and adapt it - it carries one worked example per auth style.oauth entry, register an OAuth client with the provider on the redirect URI <API_URL>/ai/integrations/oauth/<id>/callback, then set that client's id and secret in your server env under the names the entry declares.Google Search Console, as an api entry authenticated by sign-in:
// packages/config/src/ai/integrations.ts
export const aiIntegrations: readonly AiIntegrationSpec[] = [
{
id: "google-search-console",
name: "Google Search Console",
kind: "api",
description: "Sign in to read search performance for a verified site property.",
baseUrl: "https://searchconsole.googleapis.com",
credentials: [{ key: "siteUrl", label: "Site URL", type: "url", required: true }],
auth: {
method: "oauth",
oauth: {
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
tokenUrl: "https://oauth2.googleapis.com/token",
scopes: ["openid", "email", "https://www.googleapis.com/auth/webmasters.readonly"],
clientIdEnv: "GOOGLE_OAUTH_CLIENT_ID",
clientSecretEnv: "GOOGLE_OAUTH_CLIENT_SECRET"
}
},
health: { path: "/webmasters/v3/sites" },
callable: true
}
];Fields
| Field | Purpose |
|---|---|
id | Stable catalog id stored connections reference. Letters, digits, _ or - only - it can become a tool name. |
name | Display name in the picker. |
kind | api | mcp - what the backend talks to, see the two kinds. |
auth | How it authenticates - see the auth axis. |
baseUrl | The fixed origin reached. Omit to make the entry generic: the user supplies the host in a baseUrl credential. |
description | One-line picker copy (optional). |
credentials | The connect-form schema: { key, label, type: "text" | "password" | "url", required } per field. |
health | api only: { path, method?, identityPath? } - the probe proving the credentials work and, for a key-authenticated entry, naming the account (optional). A sign-in entry is labelled with the signed-in account's email instead - add the openid and email scopes; the email is used only when the ID token is issued to your client id and marks it verified. |
callable | Whether the model may call it as a tool. A non-callable entry is still connectable. |
mutating | Marks a write-capable tool (optional; omitted means read-only). |
allowedScopes | Where this entry may be connected: any of project, organization, user (see scope). Omitted offers every scope the deployment enables; listing fewer can only ever narrow. |
maxInstances | Cap on connected instances, counted per scope context - 1 allows one in each project, one shared with the organization, and one of the user's own (optional; omitted = unlimited). |
surfaces | ("web" | "desktop")[] - which app's picker offers it, and nothing else (optional; omitted means both). A presentation filter, not a permission: every kind executes server-side, so a connection made on one surface is a tool on all of them. |
allowed | (context) => boolean | Promise<boolean> - gates the entry on the payer's plan (optional; omitted = always allowed). See plan-gated entries. |
An id can become a tool name, so name every one like an identifier. A provider-pinned entry gets the tool <id>_request, and mcp_ is reserved for the tools of connected MCP servers, so no id may start with it. Tool names leave your server: a user's coding CLI joins them into one comma-separated allowlist it reads as permission rules, so an id like acme,Bash - or a merely exotic one like acme.crm - breaks every terminal session that user opens. Every id is charset-checked where the catalog is read, so a bad one fails the app at boot.
The auth axis
Authentication is a separate axis from the kind, so a key-authenticated API and a sign-in service are the same kind with different auth.method.
kind | auth.method | What it does |
|---|---|---|
api | none | Sends the request unauthenticated. |
api | apikey | Renders the declared placements from the stored credentials. |
api | basic | Sends the two fields named by usernameField and passwordField as one HTTP Basic Authorization header. |
api | oauth | Runs the authorization-code + PKCE flow and sends the auto-refreshed bearer token. |
mcp | none | Dials with no auth. |
mcp | headers | Renders the declared header placements onto the dial. |
Placements
A placement says which stored credential rides where, so a new provider is an entry rather than new code. A blank credential's placement is skipped, which makes an optional credential declarable.
placements: [
{ carrier: "header", name: "Authorization", prefix: "Bearer ", field: "apiKey" },
{ carrier: "query", name: "key", field: "apiKey" }
]Login and password entries
A basic entry declares the two fields in credentials, so the connect form asks for them; both are stored encrypted like any key and never returned to a client. The header is UTF-8 encoded, and a login containing : is refused, since Basic cannot carry one.
credentials: [
{ key: "login", label: "Login", type: "text", required: true },
{ key: "password", label: "Password", type: "password", required: true }
],
auth: { method: "basic", usernameField: "login", passwordField: "password" }Sign-in entries
An oauth entry carries an AiIntegrationOAuthDescriptor of static, non-secret values. Client credentials are referenced by env var name - set the values in your server env, beside the other integration keys.
| Field | Value |
|---|---|
authUrl / tokenUrl | The provider's authorization and token endpoints. |
scopes | The scopes the sign-in requests. |
clientIdEnv / clientSecretEnv | The env var names holding your OAuth client id and secret. |
A sign-in entry must declare baseUrl - the fixed origin its minted token is host-locked to. The catalog refuses one without it at boot.
Plan-gated entries
allowed runs on the server with the plan that funds the connection: the organization owner's for an organization or organization-project connection, the user's own for a personal one. It never trusts the client.
allowed: ({ planId }) => planId === "pro"| Context field | Value |
|---|---|
userId | The user connecting, or the user a call acts for. |
organizationId | The organization funding the connection, or null for a personal one. |
payerUserId | The user whose plan funds it. |
planId | The payer's active plan id, or null when none is active. |
When allowed answers false | What happens |
|---|---|
| Picker | The entry shows locked. Picking it shows only the not-included line and a See plans link to the billing page - no credential instructions and no fields. |
| Connect, test, edit, health re-check, sign-in start and callback | Refused (403 on the routes). |
| Calls through an existing connection | Refused with a message the model relays. The connection is kept, so it works again after an upgrade. |
AI integrations
The backend-owned catalog of third-party services a signed-in user connects once, so chat, automated runs and desktop agents can all act on them as tools.
Connection scope
Where an AI integration connection lives - the project, organization and user levels, the flags that offer each, how visibility unions, and the allowedScopes narrowing.