Skip to content

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):

TypeScript
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})` })
  },
})
FieldTypeDoes
idstringRequired. Unique across extensions
namestringRequired. The name in Settings
descriptionstringOne line under the name
icon{ gui?, tui? }An icon name for the desktop and web apps, a glyph for the terminal app
methodsIntegrationMethod[]Required. The ways to connect (methods)
beforeConnect(method)string | undefinedRuns before any connect. Return a message to refuse: nothing is contacted (gate)
verify(credential)string | undefinedChecks a credential before it is stored. Return a message to refuse it
account(credential)string | undefinedThe account the credential belongs to (an email, a user name). Read once after connecting
parseCommandOutput(stdout)CredentialReads what a command method printed
legacyCredentialKeystringAdopts 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.

FieldDoes
flow'pkce' (a browser redirect) or 'device' (a code the user enters on another page)
authorizeUrl, tokenUrlThe provider's endpoints
clientId, clientSecret?The OAuth client. clientSecret is sent to the token endpoint when set
scopesThe scopes to ask for
redirectUriFor pkce: a loopback URL with an explicit port, such as http://localhost:51121/callback
deviceUrlFor device: the device authorization endpoint
paramsExtra 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.

TypeScript
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.

TypeScript
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:

MemberDoes
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
signalAborts 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:

TypeScript
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:

CredentialFrom
{ 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.

MemberReturnsDoes
connections(id)Connection[]Every connection, the default first
connection(id, { account? })Connection | undefinedThe 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)DisposableCalls 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:

TypeScript
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 oauth or custom method, 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:

TypeScript
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#

AppSettingsNotes
Terminal appSettings 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 defaultOffers every method, with a pick when there are several
Desktop appSettings › Extensions lists each account with Default, Make default, Disconnect and Add accountSign-in questions show as dialogs
Web appThe same list in the extension's settingsThe 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 pkce method needs a loopback redirectUri with 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 showCode or prompt for remote use.
  • A custom connect is not bounded by the callback timeout, since sign-ins can take minutes. beforeConnect, verify, account and parseCommandOutput are.
  • 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 id is shared by every extension. Pick one that names your service, not a generic word.
  • Settings: secret settings and the values beforeConnect reads.
  • Catalogs: provider descriptors.
  • Providers: how sessions pick a provider and an account.