Extension integrations
Let the user connect a service with an API key, OAuth, a command or a custom flow, with several accounts, and sign providers in through it.
An integration is a service the user connects in Settings: Connect, Disconnect, and one row per account. The extension declares how to connect (its methods). The app runs the flow, keeps the secret in the auth store, and refreshes OAuth tokens. The extension reads a connection and resolves its credential when it needs one.
Use an integration for any account on another service: an API key, an OAuth sign-in, a token a CLI prints. For one secret with no accounts or checks, a secret setting is enough.
Register an integration#
From the kitchen-sink example (examples/extension-kitchen-sink/integrations.ts):
ctx.integrations.register({
id: 'kitchen-cloud',
name: 'Kitchen Cloud',
description: 'A pretend service: any key starting with ks_ works.',
icon: { gui: 'cloud', tui: 'K' },
methods: [{ type: 'apiKey', id: 'key', label: 'API key', placeholder: 'ks_...', help: 'Find it in your Kitchen Cloud account.' }],
verify: async credential => (credential.type === 'apiKey' && credential.key.startsWith('ks_') ? undefined : 'Keys start with ks_.'),
account: async credential => (credential.type === 'apiKey' ? `chef-${credential.key.slice(3, 7)}` : undefined),
})
ctx.commands.register({
id: 'cloud-whoami',
title: 'Kitchen Cloud: who am I?',
run: async () => {
const connection = ctx.integrations.connection('kitchen-cloud')
if (!connection) {
await ctx.integrations.connect.start('kitchen-cloud', 'key').catch(() => undefined)
return
}
const credential = await ctx.integrations.resolve(connection)
await ctx.ui.toast({ text: `Connected as ${connection.account} (${credential.type})` })
},
})| Field | Type | Does |
|---|---|---|
id | string | Required. Unique across extensions |
name | string | Required. The name in Settings |
description | string | One line under the name |
icon | { gui?, tui? } | An icon name for the desktop and web apps, a glyph for the terminal app |
methods | IntegrationMethod[] | Required. The ways to connect (methods) |
beforeConnect(method) | string | undefined | Runs before any connect. Return a message to refuse: nothing is contacted (gate) |
verify(credential) | string | undefined | Checks a credential before it is stored. Return a message to refuse it |
account(credential) | string | undefined | The account the credential belongs to (an email, a user name). Read once after connecting |
parseCommandOutput(stdout) | Credential | Reads what a command method printed |
legacyCredentialKey | string | Adopts a provider sign-in saved under its own key (adopt) |
register returns a handle: dispose(), and update(patch) to change name, description, icon, methods or beforeConnect later. An invalid patch throws a TypeError and keeps the spec as it was.
Methods#
apiKey#
The user pastes a key. { type: 'apiKey', id, label, placeholder?, help? }. The credential is { type: 'apiKey', key }.
oauth#
A browser sign-in. The app runs the flow and refreshes the token.
| Field | Does |
|---|---|
flow | 'pkce' (a browser redirect) or 'device' (a code the user enters on another page) |
authorizeUrl, tokenUrl | The provider's endpoints |
clientId, clientSecret? | The OAuth client. clientSecret is sent to the token endpoint when set |
scopes | The scopes to ask for |
redirectUri | For pkce: a loopback URL with an explicit port, such as http://localhost:51121/callback |
deviceUrl | For device: the device authorization endpoint |
params | Extra parameters for the authorize URL, such as access_type and prompt |
The credential is { type: 'oauth', accessToken, expiresAt?, scopes?, extra? }. The app keeps the refresh token and never hands it out.
command#
The app runs a command and reads its output. By default, the trimmed output is the token: { type: 'token', token }. parseCommandOutput reads other formats.
ctx.integrations.register({
id: 'gh',
name: 'GitHub',
methods: [{ type: 'command', id: 'gh-cli', label: 'Use the gh CLI', command: ['gh', 'auth', 'token'], help: 'Runs gh auth token.' }],
})A non-zero exit or empty output fails the connect, with the first line of the error output.
custom#
A flow the extension runs itself: a bespoke OAuth, an invite code, a sign-in that needs custom headers. connect(io) drives the app's sign-in UI and returns the credential.
import type { IntegrationCustomMethod } from 'zidane'
const invite: IntegrationCustomMethod = {
type: 'custom',
id: 'invite',
label: 'Use an invite code',
connect: async (io) => {
const code = await io.prompt({ message: 'Your invite code' })
if (!code)
throw new Error('Cancelled.')
io.log('Checking the code')
const response = await fetch('https://example.com/api/redeem', { method: 'POST', body: JSON.stringify({ code }), signal: io.signal })
const { token, email } = await response.json() as { token: string, email: string }
return { credential: { type: 'token', token }, account: email }
},
}
ctx.integrations.register({ id: 'example', name: 'Example', methods: [invite] })io shows each step the way the app shows an OAuth flow, in whichever window started it:
| Member | Does |
|---|---|
openUrl(url) | Shows a link to open |
showCode(code, url?) | Shows a code to enter, and where |
prompt({ message, placeholder?, allowEmpty?, options? }) | Asks in a dialog. With options, a choice that resolves to the picked id. undefined when cancelled |
log(message) | Shows a progress line |
signal | Aborts when the user cancels or the extension unloads |
connect returns { credential, account?, label? }. An oauth credential from a custom method may carry refreshToken: the store keeps it, and refresh(credential) gets it back when the token is about to expire. Without refresh, an expired custom credential asks the user to connect again.
integrationMethodFromOAuthProvider(provider, { id?, label?, help? }) turns a zidane/oauth provider into a custom method. The Bolt extension signs in with its device flow this way:
import type { IntegrationSpec, OAuthProviderInterface } from 'zidane'
import { integrationMethodFromOAuthProvider } from 'zidane'
declare const boltOAuthProvider: OAuthProviderInterface
export const boltIntegration: IntegrationSpec = {
id: 'bolt',
name: 'Bolt',
methods: [integrationMethodFromOAuthProvider(boltOAuthProvider, { help: 'Shows a code to approve on Bolt.' })],
legacyCredentialKey: 'bolt',
}Credentials#
ctx.integrations.resolve(connection) returns one of:
| Credential | From |
|---|---|
{ type: 'apiKey', key } | An apiKey method, or connect.key |
{ type: 'oauth', accessToken, expiresAt?, scopes?, extra? } | An oauth method, or a custom one. resolve refreshes the token when it is about to expire. extra holds provider fields kept with the grant, such as an account id |
{ type: 'token', token } | A command method, or a custom one |
Call resolve each time you need the secret. Do not keep it: a refresh replaces it.
Accounts#
An integration holds several connections, one per account. The first becomes the default. Connecting an account that is already connected replaces it. Connections are shared: any extension reads any integration's connections.
| Member | Returns | Does |
|---|---|---|
connections(id) | Connection[] | Every connection, the default first |
connection(id, { account? }) | Connection | undefined | The default connection, or the one of account |
setDefault(id, account) | Promise<void> | Makes that account the default. Rejects when there is none |
disconnect(id, { account? }) | Promise<void> | Removes one account, or every one |
onChange(id, listener) | Disposable | Calls listener(defaultConnection) when any connection of the integration changes |
connect.key(id, key) | Promise<Connection> | Connects with a key, as if the user pasted it |
connect.start(id, methodId, { signal? }) | Promise<Connection> | Starts an oauth, command or custom flow. The app shows its UI. Resolves when it completes |
A Connection is { integrationId, methodId, account, label?, isDefault?, connectedAt }. account is 'default' when the integration names none.
Gate the connect#
beforeConnect runs before any connect, with the method the user picked. Return a message to refuse: the user sees it, and nothing is contacted. The Antigravity extension refuses until the user accepted a risk and set an OAuth client, and updates its method when the client changes:
import type { ExtensionContext, IntegrationMethod, SettingsSchema } from 'zidane'
const settings = {
items: {
acceptRisk: { type: 'boolean', label: 'I accept the risk', default: false },
clientId: { type: 'string', label: 'OAuth client ID', default: '' },
},
} as const satisfies SettingsSchema
const method = (clientId: string): IntegrationMethod => ({
type: 'oauth',
id: 'google',
label: 'Sign in with Google',
flow: 'pkce',
authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
clientId,
redirectUri: 'http://localhost:51121/oauth-callback',
params: { access_type: 'offline', prompt: 'consent' },
})
export function setupSignIn(ctx: ExtensionContext<typeof settings>): void {
const integration = ctx.integrations.register({
id: 'antigravity-demo',
name: 'Antigravity',
methods: [method(ctx.settings.get('clientId') ?? '')],
beforeConnect: () => (ctx.settings.get('acceptRisk') !== true ? 'Accept the risk in Settings first.' : !ctx.settings.get('clientId') ? 'Set an OAuth client first.' : undefined),
})
ctx.settings.onChange('clientId', id => integration.update({ methods: [method(id ?? '')] }))
}Providers that sign in#
A provider descriptor with integration signs in through it (providers):
- Pickers show the provider signed in while the integration has a connection, one entry per account, the default active.
- "Sign in" starts the integration's
oauthorcustommethod, and asks which when there are several. - Usage probes run with each connection.
The provider's factory resolves its token through ctx.integrations. With accounts: true, a session can pin one account, and the factory gets it as options.account:
import type { ProviderDescriptor, ProviderFactoryOptions } from 'zidane'
declare function myProvider(options?: ProviderFactoryOptions): ReturnType<ProviderDescriptor['factory']>
ctx.providers.register({
key: 'my-gateway',
label: 'My gateway',
factory: options => myProvider(options),
envKey: undefined,
integration: 'my-gateway',
accounts: true,
})
async function tokenFor(account?: string): Promise<string | undefined> {
const connection = ctx.integrations.connection('my-gateway', account ? { account } : undefined)
if (!connection)
return undefined
const credential = await ctx.integrations.resolve(connection)
return credential.type === 'oauth' ? credential.accessToken : undefined
}Adopt a saved sign-in#
A provider may keep its sign-in in credentials.json under its own key. legacyCredentialKey names that key. When the integration registers and has no connection, the entry under that key becomes one: tokens and extra fields kept, the account from the entry's account or email, else 'default'. The first oauth or custom method refreshes it.
The old entry stays, so going back to an older zidane still works. It is marked, so a later Disconnect sticks.
Refreshes#
An OAuth refresh fires the oauth:refresh hook to every extension that observes it with ctx.hooks.on('oauth:refresh', …), after the new tokens are stored.
In each app#
| App | Settings | Notes |
|---|---|---|
| Terminal app | Settings has an Integrations tab: a row per account with a default marker. Enter connects, adds an account or disconnects one. D makes an account the default | Offers every method, with a pick when there are several |
| Desktop app | Settings › Extensions lists each account with Default, Make default, Disconnect and Add account | Sign-in questions show as dialogs |
| Web app | The same list in the extension's settings | The flow runs in the browser that started it, even on another machine than the server |
Secrets live in the auth store of the process that runs the extension: the daemon's or the server's in those modes.
Pitfalls#
- A
pkcemethod needs a loopbackredirectUriwith a port, and the OAuth client must allow it. - A custom flow that runs its own local callback server cannot finish from a remote window: the callback reaches the server's machine. Use
showCodeorpromptfor remote use. - A custom
connectis not bounded by the callback timeout, since sign-ins can take minutes.beforeConnect,verify,accountandparseCommandOutputare. - Usage probes do not refresh an expired integration token. The account shows as unauthorized until the provider's next run refreshes it.
- An integration's
idis shared by every extension. Pick one that names your service, not a generic word.