GenerateSaaS

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

Copy a shape from the module's JSDoc into the aiIntegrations array and adapt it - it carries one worked example per auth style.
For an 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.
Restart. A malformed entry fails the app at boot; a valid one appears in the picker on every surface it targets.

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

FieldPurpose
idStable catalog id stored connections reference. Letters, digits, _ or - only - it can become a tool name.
nameDisplay name in the picker.
kindapi | mcp - what the backend talks to, see the two kinds.
authHow it authenticates - see the auth axis.
baseUrlThe fixed origin reached. Omit to make the entry generic: the user supplies the host in a baseUrl credential.
descriptionOne-line picker copy (optional).
credentialsThe connect-form schema: { key, label, type: "text" | "password" | "url", required } per field.
healthapi 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.
callableWhether the model may call it as a tool. A non-callable entry is still connectable.
mutatingMarks a write-capable tool (optional; omitted means read-only).
allowedScopesWhere 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.
maxInstancesCap 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.

kindauth.methodWhat it does
apinoneSends the request unauthenticated.
apiapikeyRenders the declared placements from the stored credentials.
apibasicSends the two fields named by usernameField and passwordField as one HTTP Basic Authorization header.
apioauthRuns the authorization-code + PKCE flow and sends the auto-refreshed bearer token.
mcpnoneDials with no auth.
mcpheadersRenders 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.

FieldValue
authUrl / tokenUrlThe provider's authorization and token endpoints.
scopesThe scopes the sign-in requests.
clientIdEnv / clientSecretEnvThe 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 fieldValue
userIdThe user connecting, or the user a call acts for.
organizationIdThe organization funding the connection, or null for a personal one.
payerUserIdThe user whose plan funds it.
planIdThe payer's active plan id, or null when none is active.
When allowed answers falseWhat happens
PickerThe 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 callbackRefused (403 on the routes).
Calls through an existing connectionRefused with a message the model relays. The connection is kept, so it works again after an upgrade.
A gate that throws, or a payer that cannot be resolved, counts as false, so a failed lookup never unlocks a paid entry. The plan is re-read on every call, so a downgrade applies to the next one.

On this page