Connectors
What It Is
Connectors are a general-purpose framework for integrating external services — Google Drive, Notion, a payment provider, or any system that exposes an HTTP/REST (or other) API — and reusing those integrations everywhere on the platform: in automations, in the custom PHP script action, in custom apps, and in the Cloud Console.
A connector advertises a set of operations (functions such as "list files" or "create record") through a manifest. Each operation can be invoked as a native automation action and through the Internal API as $api->connectors->get('<connection>')->someOperation(...). Credentials live encrypted on the platform, so the operation code — and the automations and apps that call it — never handle secrets directly.
Connectors are distinct from MCP servers (which expose tools to AI agents). Use a connector when you want a reusable, credential-backed integration to drive from automations and code.
Why It Matters
- One integration, used everywhere. Define an operation once; call it from an automation, custom PHP, a custom app, or the Cloud Console.
- Credentials stay safe. API keys and OAuth tokens are encrypted at rest and injected into operations at run time — never exposed to callers.
- Multiple accounts per service. A connector can have many authenticated connections (e.g. "Drive – work" and "Drive – personal"); each call targets one.
- Open-ended. Anything reachable over HTTP (or other transport) can be wrapped — there is no fixed catalog of supported services.
Mental Model
A connector has two layers, plus operations that bridge to the outside:
- A definition is a connector type. It is one of two kinds:
- Core — a trusted integration shipped in the platform codebase, running with full server-side access.
- Bundle — a deployed integration (like a custom app): a manifest plus sandboxed handler files, importable from the connector store or uploaded as a
.zip.
- A connection is an authenticated instance of a definition — one account, with its own credentials. Operations always run against a chosen connection.
- An operation is a named function the connector performs. Operations surface in two places: as their own automation builder actions and through the Internal API.
Connector definition: "googleDrive" (core or bundle)
├─ connection: drive-work → credentials A
└─ connection: drive-personal → credentials B
$api->connectors->get('drive-work')->listFiles(['folderId' => 'root'])
Using Connectors
In automations
Every operation marked as an action appears in the automation builder under the Connectors group, named "<Connector>: <Operation>" (e.g. REST API: HTTP request). Add the action, pick a Connection, fill the operation's inputs, and wire its On success / On error endpoints. On success always provides a result, plus any output parameters the operation declares.
From code ($api->connectors)
Available in the custom PHP script action, custom-app BFF handlers, and the Cloud Console:
// Fetch a connection and call an operation
$files = $api->connectors->get('drive-work')->listFiles(['folderId' => 'root']);
// Equivalent explicit form
$files = $api->connectors->get('drive-work')->call('listFiles', ['folderId' => 'root']);
// One-shot, without holding a handle
$files = $api->connectors->call('drive-work', 'listFiles', ['folderId' => 'root']);
// Discover what is connected / available
$connections = $api->connectors->list(); // slug => name
$operations = $api->connectors->get('drive-work')->operations();
// Read or persist a connection's non-secret config (e.g. a saved folder id)
$cfg = $api->connectors->get('drive-work')->config();
$api->connectors->get('drive-work')->setConfig(['folderId' => '123']);
The Cloud Console autocompletes $api->connectors, the connection proxy methods, and the operation names known to the tenant.
Connections
A connection is created and authenticated from a connector's detail page under Connections. Two fields are always shown, plus credential fields that depend on the connector's auth type:
| Field | Shown for | Description |
|---|---|---|
| Connection name | always | Label for this account; the connection slug is derived from it. |
| Base URL | always | Overrides the connector's base_url for this connection. |
| Advanced config (JSON) | always | Merged into the connection's non-secret config — e.g. {"url": "https://…"} (webhook) or OAuth endpoint/scope overrides. |
| API key / token | apiKey, bearer | The key or token; the connection is connected immediately. |
| Username / Password | basic | HTTP basic-auth credentials. |
| OAuth client ID / secret | oauth2 (optional) | Per-connection override of the connector's OAuth application; normally left blank. Adding the connection then redirects you to authorize and the platform manages tokens. |
A none-auth connector needs no credentials.
OAuth Connections
For oauth2 connectors the platform runs the full authorization-code flow (with PKCE) and manages the resulting tokens — discovering endpoints, registering a client where supported, redirecting the user to authorize, exchanging the code, and refreshing the access token over time. A handler never sees any of this; it just reads the current access token.
Manifest
The auth block seeds the defaults every new connection starts from:
{
"name": "Acme Docs",
"slug": "acmeDocs",
"auth": {
"type": "oauth2",
"base_url": "https://api.acme.com",
"authorization_endpoint": "https://auth.acme.com/oauth/authorize",
"token_endpoint": "https://auth.acme.com/oauth/token",
"scope": "documents.read documents.write",
"client_id": "your-registered-client-id"
},
"operations": { "...": {} }
}
Every field except type is optional:
- Omit
authorization_endpoint/token_endpointand they are discovered frombase_url(RFC 8414 / 9728 well-known metadata). scopeandresourceare passed through on the authorize request.authorize_paramsadds extra query parameters to the authorize redirect (the standard ones —response_type,client_id,state, PKCE, …, always win). Google, for example, requires{ "access_type": "offline", "prompt": "consent" }to return a refresh token; without it the connection stops working after the first access token expires.
The OAuth client (registered once)
OAuth always needs an OAuth client (client_id + client_secret) — there is no credential-free OAuth — but whether you register one manually depends on the connector's manifest and the provider. The platform resolves the client in this order:
- Per-connection override — a connection's own
client_id/client_secret(under "Use a different OAuth client" on the add-connection form), to point one account at a different OAuth app. - The connector's OAuth application — set once on the connector's admin page (a dedicated OAuth application card appears for
oauth2connectors). After you register the client once, every connection just clicks Connect and authorizes — the one-click experience. - A manifest
client_id— if the connector author shipped one in theauthblock (e.g. a public PKCE client), it's used as-is. - Dynamic client registration (RFC 7591) — if the manifest declares (or discovery from
base_urlfinds) aregistration_endpoint, the platform registers a client automatically.
So you only set up the OAuth application manually when none of (3)/(4) apply — i.e. the provider has no dynamic registration and the manifest ships no client. That's the case for Google, Slack, GitHub, Notion and most consumer APIs; for those, register the client once via the card. Connectors targeting an IdP/server that supports RFC 7591 (or that ship a public client_id) need nothing — leave the card blank. Non-OAuth connectors (apiKey/bearer/basic/none) never involve any of this.
The admin OAuth application card reflects this automatically: it is shown only when the connector needs a hand-registered client — when the manifest's auth declares no registration_endpoint, no client_id, and not dynamicRegistration: true (and isn't already configured). For a connector whose provider supports dynamic registration, set "dynamicRegistration": true in the manifest auth block and the card stays hidden.
Register the connector's redirect URI with the provider — the connector page shows the exact value:
https://<your-tenant-host>/connector/<slug>/oauth/callback
The flow
One-time, per connector (admin): register the OAuth client with the provider, add the redirect URI above, and save its client id/secret under the connector's OAuth application. After that, adding accounts is one click:
- Add a connection (Connectors → the connector → Connections) — just a name when the connector's OAuth application is set.
- Connect. The platform resolves the endpoints (manifest → connection config → discovery), generates a PKCE verifier and a
state, and redirects the browser to the provider's authorization page. - The user approves; the provider redirects back to the callback URL with
code+state. - The platform exchanges the code for tokens, stores them encrypted, and marks the connection connected. Reconnect re-runs the flow (for example to widen scopes or recover from a revoked grant).
A bundle connector must have an active version before you add connections (its manifest declares the auth type). Adding a connection earlier is refused with a clear message.
Tokens at run time
Access, refresh, and client secrets are encrypted at rest (in the platform's secret store), never in the connection row and never exposed to callers. Before each operation call the platform refreshes an access token that is expired or within ~2 minutes of expiry, when a refresh token is available — so a handler only ever needs:
$token = $connector->accessToken(); // always current; refreshed transparently
The reference REST API connector does this for you: on an oauth2 connection it sends Authorization: Bearer <access_token> automatically.
Authoring a Bundle Connector
A bundle connector is a .zip deployed exactly like a custom app version:
bundle.zip
├── connector.json # manifest: metadata, auth, operations
├── server/ # sandboxed operation handlers
│ └── send.php
└── public/ # optional frontend assets (file pickers, etc.)
Download a starter from Connectors → Connector templates, adapt it, then deploy the zip from the connector's Deploy a new version form and Activate it. On activation, an automation builder action is generated for each operation marked exposeAsAction.
The connector.json Manifest
{
"name": "Outbound Webhook",
"slug": "webhook",
"version": "1.0",
"description": "POST a JSON payload to a configured URL.",
"icon": "<i class=\"bi bi-send\"></i>",
"auth": { "type": "none" },
"operations": {
"send": {
"name": "Send payload",
"inputs": { "payload": { "type": "object|array|null", "name": "Payload" } },
"outputs": { "status": { "type": "int", "name": "HTTP status" } },
"handler": "server/send.php",
"exposeAsAction": true,
"exposeInApi": true
}
}
}
The manifest is read from the deployed file on every request — it is the single source of truth, so editing it (or redeploying) takes effect immediately.
Top-level fields:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | Display name shown in the admin and the connector store. |
slug | string | no | Stable identifier used in URLs, connection slugs, and generated action class names. Defaults to the deployment slug. |
version | string | no | Free-form version label for the manifest. |
description | string | no | Shown in the admin and the connector store. |
icon | string (HTML) | no | Icon markup — a Bootstrap icon tag, e.g. <i class="bi bi-send"></i> — used on generated actions. |
auth | object | no | Authentication scheme; defaults to {"type":"none"}. See auth. |
operations | object | yes | Map of operation id → definition. See operations. |
frontend | object | no | Optional UI (static pages + BFF routes + setup button). See frontend. |
auth
The authentication scheme and (for oauth2) defaults seeded into every new connection's config. Discovery (RFC 8414/9728) fills in omitted endpoints; declaring registration_endpoint, shipping a client_id, or setting dynamicRegistration: true lets the platform obtain a client automatically and hides the admin OAuth application form. See OAuth Connections for the flow and token handling.
| Field | Type | Default | Description |
|---|---|---|---|
type | string | none | One of oauth2, apiKey, bearer, basic, none. |
base_url | string | — | Service base URL; used for OAuth endpoint discovery and seeded into connection config. |
authorization_endpoint | string | discovered | OAuth2 authorization URL. |
token_endpoint | string | discovered | OAuth2 token URL. |
registration_endpoint | string | discovered | RFC 7591 dynamic-client-registration URL. Its presence hides the manual OAuth-application form. |
scope | string | — | OAuth scope(s), space-separated. |
resource | string | — | OAuth resource parameter, when the provider requires it. |
client_id | string | — | A public/shipped OAuth client id. Its presence hides the manual OAuth-application form. |
authorize_params | object | — | Extra query params for the authorize redirect, e.g. {"access_type":"offline","prompt":"consent"} (Google). Standard params always win. |
dynamicRegistration | bool | false | Set true when the provider supports RFC 7591 via discovery; hides the manual OAuth-application form. |
Never put
client_secret(or any secret) in the manifest — it is non-secret and visible. Client secrets are entered once in the connector's OAuth application card and stored encrypted.
operations
A map of operation id → definition. Each operation:
| Field | Type | Default | Description |
|---|---|---|---|
name | string | operation id | Display label; the builder action is named "<Connector>: <name>". |
info | string (HTML) | — | Help text shown on the generated action. |
group | string | Connectors | Palette group override. |
inputs | object | — | Map of input id → field definition (see below). Becomes the action's inputs and the handler's $input. |
outputs | object | — | Map of output id → field definition; exposed on the action's On success endpoint alongside the always-present result. |
handler | string | — | Handler file relative to the bundle root (bundle connectors only; core connectors use a driver method). |
exposeAsAction | bool | false | Generate an automation builder action for this operation. |
exposeInApi | bool | true | Callable via $api->connectors and suggested in the Cloud Console. |
Each entry in inputs / outputs is a field definition (the same shape automation actions use):
| Key | Type | Description |
|---|---|---|
name | string | Field label shown in the builder. |
type | string | Value-type hint, e.g. string|null, int|null, object|array|null, scalar, mixed. |
default | mixed | Default value (inputs only). |
control | string | Optional control hint, e.g. checkbox. |
frontend
Optional UI for interactive flows such as a file/folder picker, served under /connector/<slug>/. See Connector Frontends for what is served where and the handler contract.
| Field | Type | Description |
|---|---|---|
setup | string | Path the per-connection Configure button opens ("" = the public/index.html root, or a sub-path like "setup"). Its presence adds the button to connected connections. |
routes | object | Map of "<METHOD> /<route>" → route entry, exposing sandboxed BFF endpoints at /connector/<slug>/api/<route>. |
Each routes entry:
| Key | Type | Description |
|---|---|---|
handler | string | Handler file relative to the bundle root. |
auth | string | Reserved; connector frontends are signed-in only, so every route effectively requires a user. |
Operation Handlers
A handler is a PHP file that runs in the platform sandbox. It receives:
$connector— the operation context:credentials(),accessToken(),apiKey(),config(),configValue($key, $default),http(), andapi().$input— the resolved operation inputs.$api/$jetstack— the full Internal API.
Return the operation's result (it becomes the result of the automation action and the return value of $api->connectors->…). Throw an exception to fail.
<?php
$url = (string) $connector->configValue('url', '');
if ($url === '') {
throw new \Exception('This connection has no "url" configured.');
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input['payload'] ?? []));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$response = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['status' => $status, 'body' => json_decode((string) $response, true)];
To share code across a bundle's handlers, use $api->include('<file>'): it runs a sibling PHP file in the sandbox and returns its return value, resolving the path relative to the handler's directory (or, for a path beginning with /, relative to the tenant root — e.g. $api->include('/local/lib/http.php')). An /apps/<slug>/… or /connectors/<slug>/… path automatically targets that item's active version, so you can omit the v<id> segment — e.g. $api->include('/connectors/<slug>/server/_client.php') (add an explicit v<n> to pin a specific version). For safety, includes are confined to the current tenant's local/, apps/, and connectors/ directories (resolved through realpath, so symlinks and ../ cannot escape them). Included files do not inherit the handler's variables, so pass $connector (and any inputs) in as arguments. See the recipe Building A Custom Connector for a worked example.
Connector Frontends
Some integrations need an interactive step the user performs against the connected service's own data — picking a Drive file, choosing a Slack channel, mapping fields. A connector can ship a small frontend for that. It is entirely optional; most connectors are headless (operations + the Internal API are enough).
What is served, and where
Add a public/ directory to the bundle and a frontend block to the manifest. The active version is then served under the connector's own URL space:
| URL | Serves |
|---|---|
/connector/<slug>/ and /connector/<slug>/<path> | static assets from the bundle's public/ (with an SPA index.html fallback for extensionless paths) |
/connector/<slug>/api/<route> | the sandboxed BFF handler mapped in frontend.routes |
/connector/<slug>/oauth/callback | the OAuth callback (for oauth2 connectors) |
"frontend": {
"routes": {
"GET /search": { "handler": "server/search.php", "auth": "user" }
}
}
Each route key is "<METHOD> /<route>" mapped to a sandboxed handler file. A frontend BFF handler receives $request, $query, $post, $body, and $api / $jetstack (note: not $connector — a frontend page is not tied to one connection, so it passes the connection slug in the request and the handler resolves it with $api->connectors->get(...)). It returns its response with $jetstack->sendJson(...).
It is signed-in only
Unlike a public custom app, a connector frontend always requires a signed-in user — it is an operator/implementer tool, not a public surface. Both the static assets and the BFF reject anonymous requests, so treat every route as auth: "user".
Launching it from a connection (the setup button)
This is the built-in default for the common "connect the account, then pick one folder/channel/list to scope it to" flow. Add a setup path to the frontend block:
"frontend": {
"setup": "",
"routes": {
"GET /folders": { "handler": "server/folders.php", "auth": "user" },
"POST /save-folder": { "handler": "server/saveFolder.php", "auth": "user" }
}
}
With setup present, every connected connection shows a Configure button on the connector's admin page. It opens the frontend at /connector/<slug>/<setup>?connection=<connection-slug> ("" opens the public/index.html root; a value like "setup" opens a sub-path). The page reads the connection query parameter, lets the user choose, and persists the choice into the connection's config by calling a BFF route that uses the connection proxy:
// server/save-folder.php (a frontend BFF handler)
$api->connectors->get($body['connection'])->setConfig(['folderId' => $body['folderId']]);
setConfig(array $patch)merges non-secret values into the connection'sconfig(never credentials) and returns the updated config.config()reads the current config back.
The saved values then appear under the connection in the admin, and operation handlers read them with $connector->configValue('folderId') to scope every call to that folder. A complete worked example is in the recipe: Storing a choice on the connection.
Where it renders
The platform does not auto-embed the frontend anywhere — it does not appear inside the connector admin page or the automation builder on its own. You choose where it surfaces:
- Open it in a popup or new tab from a custom app, a custom button, or a link — e.g.
window.open('/connector/acmeDocs/?connection=docs-work')— let the user pick, and hand the choice back to the opener. - Embed it in an
<iframe>inside a custom app or a canvas, exchanging messages with it.
Because the page is served from its own path it knows its base URL from window.location and calls its BFF at /connector/<slug>/api/... directly. There is no injected window.__JETSTACK__ bootstrap or effects runtime (those are custom-apps features); a connector frontend is intentionally minimal and self-contained.
A worked picker example (manifest block, page, and BFF handler) is in the recipe: A connector frontend.
Authoring a Core Connector
A core connector is a PHP class shipped in the codebase that implements the connector driver contract — use it for trusted integrations that need full platform access or shared helper logic. It declares the same manifest shape via a static manifest() and implements one public method per operation (the base driver maps an operation id to a like-named method). The platform discovers it automatically and registers a core definition.
Core connectors surface in the builder through hand-written action classes (the reference REST API connector ships one); every operation marked exposeInApi is callable via $api->connectors regardless.
Access Control
Each connector definition has an Access setting: any signed-in user, or only specific roles. A role-restricted connector rejects operation calls from users who lack a matching role (superusers and trusted server-side/system contexts are always allowed). This applies uniformly to the automation action path and the Internal API path.
Reference Connectors
Three reference connectors ship with the platform: a core REST API connector, and two bundle templates — Outbound Webhook and Google Drive.
REST API (core)
Calls any HTTP/REST API. Each connection points at a base URL and carries credentials; the request, get, and post operations issue requests and return the decoded response. It is the fastest way to reach a service that does not have a dedicated connector yet.
Connection configuration
The REST connector reads the keys below. Config values are non-secret — set them in the connection's Base URL field or its Advanced config (JSON) field. Credential values are encrypted — set them in the connection's secret fields (which the form shows according to the auth type).
| Key | Location | Default | Purpose |
|---|---|---|---|
base_url | config | — (required) | Root URL every request is built against: base_url + / + the operation's path. A trailing slash is trimmed. |
api_key | credential | — | The secret sent for apiKey auth. |
api_key_header | config | Authorization | Header name the API key is sent in (e.g. X-API-Key). |
api_key_prefix | config | Bearer (note the trailing space) | String prefixed to the key in that header. Set to "" for a bare key. |
username / password | credential | — | Used for basic auth. |
access_token | credential | — | Bearer token for bearer / oauth2 auth (set and refreshed automatically for OAuth). |
How authentication is applied
The connection's auth type decides the Authorization header the connector adds. If your operation already passes an Authorization header in headers, that always wins and the connector adds nothing.
apiKey→<api_key_header>: <api_key_prefix><api_key>. With the defaults this producesAuthorization: Bearer <api_key>. For a header-style key, setapi_key_headerto e.g.X-API-Keyandapi_key_prefixto"".basic→Authorization: Basic base64(username:password).bearer/oauth2/none→Authorization: Bearer <access_token>when an access token is present (foroauth2the platform refreshes it before the call), otherwise no auth header is added.
Request building
The request operation accepts method (default GET), path (appended to base_url), query (array → query string), headers (array, merged in), and body; get and post are the same with the method fixed. Rules:
- An array/object
bodyis JSON-encoded andContent-Type: application/jsonis added (unless yourheadersalready set a content type). - A scalar
bodyis sent verbatim. - Redirects are followed; the timeout is 30s (15s to connect).
- The result is
{ status, body }, wherebodyis decoded JSON when the response parses as JSON, otherwise the raw response string.
Example connections
A bearer-style API key (the default behaviour):
- Base URL:
https://api.example.com/v1 - API key:
sk_live_… - → requests carry
Authorization: Bearer sk_live_…
A header API key (a service expecting X-API-Key):
- Base URL:
https://api.example.com - Advanced config (JSON):
{"api_key_header": "X-API-Key", "api_key_prefix": ""} - API key:
abc123 - → requests carry
X-API-Key: abc123
Then call it from code:
$res = $api->connectors->get('example-api')->request([
'method' => 'GET',
'path' => 'contacts',
'query' => ['page' => 1, 'per_page' => 50],
]);
// $res === ['status' => 200, 'body' => [ ...decoded JSON... ]]
Outbound Webhook (bundle template)
POSTs a JSON payload to a URL set on the connection ({"url": "https://…"} in Advanced config; auth type none). A minimal example of the manifest + sandboxed-handler pattern — download it from Connectors → Connector templates as a starting point.
Google Drive (bundle template)
A full OAuth2 + setup-frontend example: connect a Google account, then use the connection's Configure button to pick one Drive folder, which is saved to the connection's config. Operations (listFiles, downloadFile, uploadFile) are scoped to that folder; listFolders powers the picker. It demonstrates authorize_params (offline access), a frontend.setup folder picker, and setConfig() persistence end-to-end.
To use it: deploy and activate the version; create an OAuth client in Google Cloud Console (Drive API enabled) and register the redirect URI https://<your-host>/connector/googleDrive/oauth/callback; save the client id + secret once under the connector's OAuth application. After that each connection is one click: add it, Connect (authorize with Google), then Configure to pick the folder. The end-to-end build is walked through in the recipe (Storing a choice on the connection).
For a full walkthrough of building your own, see the recipe Building A Custom Connector.