Integration platform
Add your integration to the library
Writing run() is only the first step. To make an action appear in the workflow builder, wire it into the runtime registry (worker execution), the web display registry (icons and canvas labels), and the add-step menu (library list). This guide follows the exact paths in this repository.
Visual: code the definition → register → verify → deploy → users see it in the builder.
TL;DR
To make an integration visible and runnable you need four things:
- Executable definition —
IntegrationDefinitionwithkey,inputSchema, andrun()(packages/integrations/src/registry.ts). - Runtime registry entry — same file, object
integrationRegistrymaps"provider.action"→ your definition. This is what the worker calls. - Credential secret — users connect an account whose JSON matches
authType(api_key,bearer_token, oroauth2). This powers the Connect Account flow and decrypts intorun(). - Web UI wiring —
INTEGRATION_REGISTRYfor canvas chrome + a row inadd-step-chooser.tsx. This registry step is what makes the step appear in the library menu.
How it works (four moves)
Each card ties a concept to where it lives in code. Hover for a subtle focus state.
Define the integration
This file tells the platform how to execute the step: Zod inputs, auth type, and async run().
IntegrationDefinition
Register the action
This registry step is what wires the step key to your run() implementation for the worker.
integrationRegistry["acme.ping"]
Attach UI metadata
Display registry + chooser rows tell the builder which icon, label, and category to show.
INTEGRATION_REGISTRY + integrationItems
Load into the library
Without the web entries, the step runs in the worker but never appears in Add step → Integrations.
chooser + INTEGRATION_REGISTRY
Step 1 — Recommended project structure
This file tells the platform what the integration is at execution time: one exported IntegrationDefinition per action (e.g. slackSendMessage).
In this monorepo, definitions live together inside packages/integrations/src/registry.ts. For larger teams, split files logically — the important part is that they are imported into that module and added to integrationRegistry.
integrations/
slack/
sendMessage.ts # IntegrationDefinition + Zod schema
sendEmail.ts # optional second action
acme/
ping.ts
registry.ts # imports + integrationRegistry objectsendMessage.ts— action implementation (run,inputSchema,authType).registry.ts— collects actions intointegrationRegistry(worker entry point).
▸Why this matters
integrationRegistry. File names are organizational; the exported object and its key field are authoritative.Step 2 — The integration definition (your “manifest”)
There is no separate JSON manifest in this stack — the TypeScript object is the manifest. These fields drive runtime behavior and catalog metadata:
key— Globally unique step type id, usuallyprovider.action(e.g.slack.sendMessage). Must match the builder + stored workflow JSON.name— Human label in API catalog helpers (listIntegrationCatalog()).provider— Namespace for credentials and for parsing the prefix before.in the canvas display helper.authType—api_key|bearer_token|oauth2. This credential definition is what powers the Connect Account UI shape expected in the vault.inputSchema— Zod schema forconfigJsoninputs validated beforerun.run— Async function receiving decryptedauth, parsedinputs, and ids (tenantId,runId, …). Return JSON becomes step output.
const slackSendMessage: IntegrationDefinition<unknown> = {
key: "slack.sendMessage",
name: "Send message",
provider: "slack",
authType: "oauth2",
inputSchema: SlackSendMessageInputsSchema,
run: async ({ auth, inputs }) => {
const parsed = SlackSendMessageInputsSchema.parse(inputs);
// call Slack Web API…
return { ok: true };
},
};Step 3 — Register actions in the registry object
Import each definition and add it to integrationRegistry. This registration layer is what allows the worker to load executable steps via getIntegration(stepType).
export const integrationRegistry: Record<string, IntegrationDefinition<unknown>> = {
"slack.sendMessage": slackSendMessage,
"gmail.sendEmail": gmailSendEmail,
// "acme.ping": acmePing,
};Step 4 — Credentials and authType
Credentials are stored per tenant (encrypted). When a run executes, the worker loads the bound credential and parses it with IntegrationAuthSchema. Your run() receives that object as auth.
This credential definition is what powers the Connect Account UI: the product expects users to save JSON matching the discriminated union (type: "oauth2" with access_token, etc.). Your integration code should branch on auth.type the same way existing Slack / Stripe helpers do in registry.ts.
// oauth2 example (simplified)
{
"provider": "slack",
"type": "oauth2",
"access_token": "xoxb-…"
}Step 5 — Global runtime registry (worker)
Critical
If your action is not in integrationRegistry, the worker cannot execute it — runs will fail when they hit that step type.
After editing packages/integrations, rebuild packages and redeploy worker-service (it imports @orchestrator/integrations). The web app can be updated separately for UI.
Step 6 — Making the library & canvas reflect your integration
Most missed step
If you skip either INTEGRATION_REGISTRY or integrationItems in add-step-chooser.tsx, users will not see a polished entry in the UI — even if the worker can run the step.
Canvas / node chrome — apps/web/src/lib/integration-registry.ts maps the provider prefix (text before . in slack.sendMessage) to name, icon, and category. This file tells the builder how to draw the node (icon + title).
Add step → Integrations list — apps/web/src/components/workflow-builder/add-step-chooser.tsx contains the integrationItems array. Each row's id must equal the integration key (e.g. slack.sendMessage). This registry step is what makes it appear in the library dropdown.
Future improvement: drive integrationItems from listIntegrationCatalog() via an API route so the menu stays in sync automatically — today it is explicit for each action.
{
"key": "slack.sendMessage",
"name": "Send message",
"provider": "slack",
"authType": "oauth2"
}Slack · Send message
Post to a channel with a connected workspace
Registry defines the executable step…
Animation: catalog fields ↔ how a picker row is presented (conceptual).
export const INTEGRATION_REGISTRY: Record<string, IntegrationRegistryEntry> = {
slack: { name: "Slack", icon: "/icons/slack.svg", category: "Communication" },
acme: { name: "Acme", icon: "/icons/acme.svg", category: "Core" },
};const integrationItems = [
// …
{
id: "acme.ping",
title: "Acme",
action: "Ping",
desc: "Health check your Acme workspace",
badge: <BrandBadge letter="A" bgClass="bg-violet-600" />,
},
] as const;Step 7 — Test locally (checklist)
npm run build(or workspace build) for@orchestrator/integrationsand restart worker-service + api-service as you normally run them.- Restart apps/web dev server after changing
add-step-chooserorintegration-registry. - Open the workflow builder → Add step → Integrations → search or scroll for your title.
- Drop the step onto the canvas — node should show the correct icon if
INTEGRATION_REGISTRYis set for the prefix. - Bind a real credential in the step inspector, then run a test workflow and confirm the worker log shows your
run()path.
Step 8 — Publish / release
- Validate Zod schemas and error handling for third-party APIs.
- Test each
authTypepath you claim to support. - Confirm icon file exists under
apps/web/public/icons/and paths match the registry. - Document the new
keyfor your users (changelog / internal Notion). - Deploy registry updates: publish
@orchestrator/integrationsbuild artifact, roll worker + web together.
End-to-end mental model
One horizontal pass from code to runtime. Each node briefly highlights in sequence.
Common mistakes
- !Forgot add-step row — Worker runs
acme.pingbut users cannot pick it in the UI. - !Prefix mismatch — Key is
acme.pingbutINTEGRATION_REGISTRYonly hasslack; canvas falls back to generic labels. - •Credential key mismatch — Saved secret type does not match
authTypeyourrun()expects. - •Wrong integration key string — Typo between workflow JSON and
integrationRegistrymap key. - •Worker not redeployed — Web shows the step; execution still uses old bundle without your code.
Build your first visible integration (tabs)
Use the tabs to jump between the definition, auth notes, registry merge, and web wiring — fastest path to “I see it in the builder”.
// packages/integrations/src/registry.ts (pattern)
const AcmePingInputsSchema = z.object({ endpoint: z.string().url() });
const acmePing: IntegrationDefinition<unknown> = {
key: "acme.ping", // full step type id (must match builder + worker)
name: "Ping", // short label in catalogs
provider: "acme", // groups credentials + display prefix
authType: "api_key", // drives allowed credential JSON shape
inputSchema: AcmePingInputsSchema,
run: async ({ auth, inputs }) => {
const parsed = AcmePingInputsSchema.parse(inputs);
// use auth (decrypted), call API, return JSON → step output
return { status: "ok" };
},
};