Skip to content
Pilotoo
v0.1

Providers

A provider has two halves. The host defines it in code: its id, how it authenticates, and which tools it offers. Each tenant admin then enables it and supplies the secrets. The host never holds a tenant’s OAuth client secret.

Id Auth Default scopes Tools
github OAuth 2 with PKCE read:user, repo list_repositories (read), list_issues (read), create_issue (write, needs scope repo)
gmail Credential type google_oauth2 (OAuth 2 with PKCE, offline access) gmail.readonly, gmail.compose, gmail.modify search_messages, get_message, list_labels (read); create_draft, send_message, create_reply_draft, reply_to_message (write, need gmail.compose); modify_labels, create_label, archive_messages, trash_messages, untrash_messages, set_read (write, need gmail.modify)
logicpanel-hub Personal access token (api_key), checked with GET /auth/me none One tool per Logicpanel Hub API operation, such as ticket_comments_create: GET is read, every other method write
mail Credential type imap_smtp (fields), checked by logging in to IMAP and SMTP none list_folders, search_messages, get_message (read); send_message, reply_to_message (write); move_messages, set_flags, delete_messages, create_folder (write)

githubProvider() is exported from @pilotoo/server and from @pilotoo/connections-core; gmailProvider(), logicpanelHubProvider() and mailProvider() from @pilotoo/connections-core. Register them with connections: { providers: [githubProvider(), gmailProvider()] }.

GitHub has no revocation endpoint, so disconnecting forgets the token without revoking it. The user revokes it in GitHub’s settings.

Gmail needs its own Google OAuth client with the Gmail API enabled and the three scopes added. The scopes are restricted: an External client in Testing only admits its listed test users and its refresh tokens expire after 7 days; an Internal client of a Google Workspace has neither limit for that workspace. The user must tick every permission on the consent screen, or send_message and create_draft answer forbidden. A 403 right after consent means the Gmail API is disabled or a permission was left unticked. reply_to_message and create_reply_draft take the original message id, body, replyAll? and cc?: the reply stays in the original thread with In-Reply-To and References, its subject gets Re: once, and it goes to the Reply-To or sender, or with replyAll also to the original recipients except the connected address. Replying to a message the connected account sent (labelled SENT) goes to its original To recipients, with replyAll also its Cc, never to the account. The organising tools need gmail.modify; a Gmail connection made before that scope existed lacks it, so its organising tools answer forbidden until the user reconnects once to grant it. modify_labels takes messageIds (1 to 1000), and add and remove lists of labels given by id or by name, which it resolves through the label list; to tag a message use a user label, to star it add STARRED, to take it out of the inbox remove INBOX. create_label takes a name (a / nests it) and an optional visibility (show, hide or show_if_unread). archive_messages removes INBOX from up to 1000 messages. set_read takes messageIds and read: true marks them read, false unread. trash_messages and untrash_messages take up to 50 ids each; trashed messages stay recoverable for 30 days, and there is no tool that deletes permanently, since that needs the full mail scope.

Logicpanel hub builds its tools from the hub’s CLI manifest with the @logicpanel/lphub SDK. The manifest is a snapshot in packages/connections-core/src/providers/logicpanel-hub-manifest.ts; refresh it with LPHUB_TOKEN=<token> pnpm --filter @pilotoo/connections-core sync:lphub-manifest. Operations that upload a file (image.create) and the three whose tool name would pass 48 characters (git-provider-account-connection.*) are not offered. logicpanelHubProvider({ baseUrl }) points it at another deployment; Connect reads LOGICPANEL_HUB_API_URL. A path value of ., .. or an empty string is refused before anything is sent, and an answer that is not JSON, or a verification answered with anything but 401 or 403, is reported as the hub being unavailable rather than the token refused.

import { z } from 'zod';
import { ProviderHttpError, type ProviderDefinition } from '@pilotoo/server';
export const tickets: ProviderDefinition = {
id: 'tickets',
name: 'Tickets',
description: 'Tickets of the account the user connects.',
auth: {
type: 'oauth2',
authorizeUrl: 'https://tickets.example.com/oauth/authorize',
tokenUrl: 'https://tickets.example.com/oauth/token',
revokeUrl: 'https://tickets.example.com/oauth/revoke',
defaultScopes: ['tickets:read'],
account: async (accessToken, http) => {
const me = await (await http('https://tickets.example.com/me', { headers: { Authorization: `Bearer ${accessToken}` } })).json();
return { id: String(me.id), label: me.email };
},
},
setup: {
consoleUrl: 'https://tickets.example.com/settings/oauth',
steps: ['Create an OAuth app and set its callback URL to {callbackUrl}.', 'Paste the client id and secret here and enable the provider.'],
notes: ['The scopes requested are {scopes}.'],
},
tools: [
{
name: 'list_tickets',
description: 'Lists open tickets.',
parameters: z.object({ limit: z.number().int().min(1).max(50).default(20) }),
access: 'read',
execute: async (args, { credential, http, signal }) => {
if (credential.type !== 'oauth2') throw new Error('unexpected credential');
const response = await http('https://tickets.example.com/api/tickets', {
headers: { Authorization: `Bearer ${credential.accessToken}` },
signal,
});
if (!response.ok) throw new ProviderHttpError(response.status, await response.text());
return response.json();
},
},
],
};

Register it with connections: { providers: [githubProvider(), tickets] }.

Field Rule
id [a-z][a-z0-9-]*. Stable, because it is stored in every connection row.
name, description Shown in the settings UI and used by connections_search.
auth or credential Exactly one. auth is oauth2, api_key or fields, below; credential names a credential type.
scopes With a credential of an OAuth 2 type: the scopes this provider’s tools need, added to the type’s baseScopes.
setup Optional guide shown to admins, below.
tools Each with name ([a-z][a-z0-9_]*, unique in the provider), description, parameters (a zod object), access (read or write), optional category (groups tools in listings), optional requiredScopes, and execute(args, context).

A credential type says how an account is proven, once, apart from the providers that use it: { id, name, auth, test? }, where id matches [a-z][a-z0-9_-]* and auth is an OAuth 2 definition (with baseScopes instead of defaultScopes), { type: 'api_key' } or { type: 'fields' }. The catalog knows google_oauth2 and imap_smtp; a host adds its own with connections: { credentialTypes: [myType], providers }, or new ProviderCatalog(providers, { credentialTypes }).

const tickets: ProviderDefinition = {
id: 'tickets',
name: 'Tickets',
description: 'Tickets of the account the user connects.',
credential: 'acme_oauth',
scopes: ['tickets:read'],
tools: [],
};
const docs: ProviderDefinition = { id: 'docs', name: 'Docs', description: 'Documents.', credential: 'acme_oauth', scopes: ['docs:read'], tools: [] };

Providers of one type share three things:

  • The OAuth client. A tenant admin enters the client id and secret once, on any provider of the type; the others read it. It is stored under the type’s id.
  • The account. One connection serves every enabled provider of the type; connecting again through another provider of the type updates it in place.
  • One consent. The authorization asks for the scopes of the provider being connected and of every other enabled provider of the type (a tenant’s scopes replace a provider’s defaults). A tool whose requiredScopes the connection lacks answers forbidden with a reconnect message, and connections_search marks it needsReconnect.

A provider with an inline auth still works: the catalog registers a credential type of its own, with the provider’s id. test is an optional on-demand check of a stored credential; without it the OAuth 2 account lookup or the verify of the other kinds is used.

setup is { consoleUrl, steps, notes? }: plain sentences, no Markdown. The admin screen shows it collapsed as “How to set up” on the provider’s card. consoleUrl must be an https URL. {callbackUrl} is replaced by the host’s CONNECTIONS_CALLBACK_URL and {scopes} by the provider’s defaultScopes, not by the scopes the tenant configured; any other placeholder fails when the catalog is built.

Option Default Meaning
authorizeUrl, tokenUrl required The provider’s endpoints.
revokeUrl none RFC 7009 endpoint; disconnecting calls it best-effort.
defaultScopes required Used when the tenant sets none.
scopeSeparator ' ' Joins scopes in the authorization URL.
pkce true Sends a PKCE challenge.
clientAuth 'body' 'basic' sends the client credentials in a Basic header.
authorizeParams none Extra query parameters, such as access_type=offline.
account none Reads who the account is, right after the code exchange. Its id tells accounts apart, so a user can connect several accounts of the provider; without it each user has one slot.

Email (IMAP/SMTP) connects any mailbox reachable over IMAP and SMTP, for mail without OAuth. The user enters email, an optional display_name and username (the email by default), password, and the IMAP and SMTP host, port and security (tls, starttls or none; IMAP 993 with TLS and SMTP 587 with STARTTLS by default, since many hosts block outgoing 465). Connecting logs in to both servers, so a wrong password is refused at once. The tools need a mail transport from the host: pass mail: createMailTransport() from @pilotoo/connections-mail next to providers, as Pilotoo Connect does. That transport refuses a host resolving to a private, loopback or link-local address, and any port but SMTP 25, 465, 587, 2525 and IMAP 143, 993; createMailTransport({ allowPrivateHosts: true }) lifts the address rule on a host where reaching the local network is legitimate. send_message takes to, cc?, bcc?, subject and text, html or both; reply_to_message takes the original’s folder and uid, reply_all? and the body, and follows the same thread and recipient rules as Gmail’s. Both save a copy in the folder marked as Sent and answer savedToSent: false when there is none or saving fails, without failing the send. The organising tools work on folder and uids taken from search_messages: move_messages moves up to 500 messages to another existing target folder, set_flags sets or clears seen (read) and flagged (starred) and leaves the flags not given as they are, delete_messages moves them to the Trash folder (the one marked as trash, else one named Trash, Bin or Deleted Items), and create_folder creates a path. A mailbox with no Trash folder deletes permanently, and the answer says so; deleting from the Trash folder itself is refused. Messages get new uids when moved. A server with neither MOVE nor UIDPLUS cannot move or delete selected messages safely, so those tools fail with an explanation instead of touching other messages.

{ type: 'api_key', verify? }. verify(apiKey, http) may return the account, or throw a ConnectionError('invalid_request') for a key the provider refuses. The user supplies the key in the settings UI.

{ type: 'fields', fields, verify? } for a credential made of several values. Each field has name ([a-z][a-z0-9_]*), label, kind (text, secret, number or select), required, and optional options (for select), default, hint and group. The settings UI opens the form from Connect or Add account, one input per field, fields of one group together under its name; saving an account already connected updates it, another account adds one. Submitted values are checked against the fields, an unknown name, a missing required value, a non-number for number or a value outside options is refused with invalid_arguments, and a missing optional value takes its default. verify(values, ports) may return the account, or throw ConnectionError('invalid_request') for values the service refuses. No stored value is ever returned, and every secret value is scrubbed from tool errors.

Ports are what a provider reaches beside its credential. ConnectionPorts is an interface with http; a module that adds a kind of port declares it by augmenting the interface, as the mail transport does with mail?. A host supplies them once, with connections: { providers, ports: { mail, pg } } (mail is also accepted on its own). A port the host did not supply is absent from the context, so a tool should fail with a clear message when it needs one.

execute receives { credential, signal, identity } plus the host’s ports: http always, and every other port the host supplied, such as mail. Use that http and signal so the call honours the deadline. Throw ProviderHttpError(status, body) for any non-success answer: a 401 makes the kit refresh the credential once and retry once. Whatever you return is serialized as JSON for the model.

Per provider, through PUT /admin/connections/providers/:provider or the admin settings section:

Field Meaning
enabled Required. Enabling an OAuth provider also takes a client id and client secret.
clientId The OAuth app’s client id.
clientSecret Sealed on write and never returned. Omit it to keep the stored one; null removes it.
scopes Overrides defaultScopes; null falls back to them.
allowedTools Unqualified tool names this tenant allows; null allows every tool. Unknown names are rejected.
includeTools Patterns over unqualified tool names, * matching any run of characters, such as ticket_*; when set, only matching tools are offered. null or empty includes every tool.
excludeTools Patterns a tool must not match, such as *_delete; applied after allowedTools and includeTools.

Register the OAuth app at the provider with the redirect URL set to your CONNECTIONS_CALLBACK_URL.

An admin can also connect an organisation account (POST /admin/connections/shared/:provider/authorize, PUT .../api-key or PUT .../fields). PUT /admin/connections/:id/sharing sets which roles may use it (roles, null for any role in CONNECTIONS_ROLES) and with which tools (tools, null for whatever the provider configuration allows). Users can call its tools but cannot connect, disconnect or refresh it.

Terminal window
curl -X PUT https://api.example.com/admin/connections/providers/github \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{ "enabled": true, "clientId": "<client id>", "clientSecret": "<client secret>", "allowedTools": ["list_repositories", "list_issues"] }'

GET /admin/connections/providers lists every host-defined provider with configured, enabled, hasClientSecret (never the secret), and its tools.

One OAuth client per shared credential type

Section titled “One OAuth client per shared credential type”

The Apps view of the admin section groups providers by credential type: a shared type is one app, and its page lists every stored credential of the app, one row per account, with the providers it serves.

Providers that name the same OAuth credential type, such as every provider of google_oauth2, use one OAuth client per tenant. Set it once in the Setup step of that app’s page, or through the API:

Terminal window
curl -X PUT https://api.example.com/admin/connections/credentials/google_oauth2 \
-H "Authorization: Bearer $ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{ "clientId": "<client id>", "clientSecret": "<client secret>" }'

An omitted clientId or clientSecret is kept, and null removes it. A removal is refused while any provider of the type is enabled. GET /admin/connections/credentials lists the shared types with clientId, hasClientSecret (never the secret) and the providers that use each one. A provider of a shared type reports it in credentialType, and is then enabled without a client of its own. A provider with an inline auth keeps its own client on PUT /admin/connections/providers/:provider.

A tool with requiredScopes is refused with forbidden when the user’s connection lacks one of them. Scopes are compared exactly, and a user who connected before an admin widened the scopes must reconnect. A tool a tenant did not allow never appears in connections_search.

  • Changing scopes does not alter existing connections; only a new authorization picks them up.
  • Deleting a provider’s configuration (DELETE /admin/connections/providers/:provider) disables it for the tenant. Existing connections stay stored but unusable until it is enabled again.
  • An id you rename in code orphans every connection stored under the old one.