Plugin Development
This guide covers building connector and channel plugins for Made Open. By the end you will have a working connector plugin that syncs data from an external API into the unified data model.
Introduction
Plugins are the mechanism by which Made Open integrates with external services. Every plugin runs inside a V8 isolate (via isolated-vm) — a completely separate JavaScript heap with no access to Node.js built-ins, no process.env, no require, and no raw fetch. The plugin sandbox enforces this at runtime.
Plugins communicate with the outside world only through the PluginContext object injected by the sandbox. Any capability not declared in the plugin manifest is denied at the sandbox boundary — not at runtime in the plugin code.
All plugins live in apps/hub/plugins/:
apps/hub/plugins/
├── connectors/
│ ├── ms-graph/ Microsoft Graph connector (contacts, calendar, email)
│ └── <your-plugin>/ Your new connector
└── channels/
├── twilio/ Twilio voice + SMS channel
└── <your-plugin>/ Your new channel
Part 1: Building a Connector Plugin
A connector plugin pulls data from an external service and yields ConnectorDataItem objects. The ETL Service normalises these into the unified entity model and upserts them into the database.
Step 1: Scaffold the Plugin
@made-open/plugin-sdk exports a scaffoldPlugin function that generates the boilerplate. Call it from a build script:
import { scaffoldPlugin } from '@made-open/plugin-sdk';
import { writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
const files = scaffoldPlugin({
pluginId: 'com.example.my-connector',
pluginName: 'My Connector',
type: 'connector',
author: 'Your Name',
outDir: 'apps/hub/plugins/connectors/my-connector',
});
const outDir = 'apps/hub/plugins/connectors/my-connector';
mkdirSync(join(outDir, 'src'), { recursive: true });
for (const [relativePath, content] of Object.entries(files)) {
writeFileSync(join(outDir, relativePath), content, 'utf-8');
}
scaffoldPlugin returns a Record<string, string> mapping relative file paths to their content. It generates three files:
| File | Contents |
|---|---|
plugin.json | Manifest — declare id, permissions, config fields |
src/index.ts | Plugin entry point with ConnectorPlugin stub |
README.md | Authoring documentation |
Step 2: Declare the Manifest (plugin.json)
The manifest is the contract between your plugin and the hub. The Plugin Manager validates it before loading the plugin.
Here is the real Microsoft Graph connector manifest as a reference:
{
"id": "com.microsoft.graph",
"name": "Microsoft Graph Connector",
"version": "0.1.0",
"type": "connector",
"entrypoint": "./index.ts",
"permissions": [
"network:outbound:graph.microsoft.com",
"event:publish:connector.*",
"event:publish:data.*",
"data:write:persons",
"data:write:events",
"data:write:messages",
"data:write:documents"
],
"configSchema": {
"type": "object",
"properties": {
"accessToken": { "type": "string" },
"refreshToken": { "type": "string" },
"tenantId": { "type": "string" }
},
"required": ["accessToken", "tenantId"]
}
}
Field reference:
| Field | Description |
|---|---|
id | Globally unique reverse-DNS identifier. Use your domain, e.g. com.yourname.my-connector. |
name | Human-readable display name shown in the credential wallet UI. |
version | Semver version string (e.g. 0.1.0). |
type | Must be "connector", "channel", or "operator" (rule operator). |
entrypoint | Path to the plugin entry file, relative to the plugin directory. |
permissions | Array of capability strings. Anything not listed is denied at the sandbox boundary. |
configSchema | JSON Schema object describing the config fields the user must supply. |
Note on manifest shape. The real
plugin.jsonfiles underapps/hub/plugins/use the flat string-arraypermissions+configSchemashape shown above — that is what the Plugin Manager actually loads at runtime. ThePluginManifestTypeScript interface inpackages/plugin-sdk/src/types.tsand the output ofscaffoldPlugindescribe a different, structured shape (permissions: { network, data },config: Array<{ key, type, label, required }>, plusdescription/author). The two shapes are inconsistent in the repo today. When authoring a plugin that must be loaded by the hub, mirror the real on-disk format; the SDK typings and scaffold output should be treated as provisional.
Permission string format: capability:direction:resource
| Permission | Meaning |
|---|---|
network:outbound:graph.microsoft.com | May make HTTP requests to graph.microsoft.com only |
event:publish:connector.* | May publish events with subject matching connector.* |
data:write:persons | May write to the persons table via the Data Service |
data:read:persons | May read from the persons table |
network:inbound | May receive inbound webhook payloads (channel plugins) |
Config fields declared in configSchema.required must be present for the plugin to activate. Secret values (tokens, API keys) are stored encrypted in Supabase Vault and injected into PluginContext.config at runtime — the plugin never sees the raw credential store.
Step 3: Implement the ConnectorPlugin Interface
import type { ConnectorPlugin, ConnectorDataItem, PluginContext } from '@made-open/plugin-sdk';
import { paginate, requireConfig, withRetry } from '@made-open/plugin-sdk';
export const plugin: ConnectorPlugin = {
id: 'com.example.my-connector',
async *sync(context: PluginContext): AsyncGenerator<ConnectorDataItem> {
const apiKey = requireConfig(context, 'apiKey');
context.logger.info('my-connector: starting sync');
yield* paginate(async (cursor) => {
const url = cursor
? `https://api.example.com/contacts?cursor=${cursor}`
: 'https://api.example.com/contacts';
const response = await withRetry(() =>
context.http.get(url, { Authorization: `Bearer ${apiKey}` }),
);
const data = response as { contacts: unknown[]; nextCursor?: string };
const items = data.contacts.map((raw) => plugin.mapToUnifiedModel(raw));
return { items, nextCursor: data.nextCursor };
});
context.logger.info('my-connector: sync complete');
},
mapToUnifiedModel(rawItem: unknown): ConnectorDataItem {
const item = rawItem as { id: string; name: string; email: string };
return {
type: 'person',
externalId: item.id,
data: {
display_name: item.name,
emails: [item.email],
},
raw: item,
};
},
};
Interface overview:
sync(context)— Async generator. Called by the Connector Service on a schedule. YieldConnectorDataItemobjects one at a time. The ETL Service processes each item as it arrives without buffering the full dataset.mapToUnifiedModel(rawItem)— Pure function. Maps one raw API item to aConnectorDataItem. Called insidesyncbut can also be called independently (e.g. in tests or webhook handlers).handleWebhook?(payload, context)— Optional. Called when the external service sends a push notification to the hub's webhook endpoint.
ConnectorDataItem fields:
| Field | Type | Description |
|---|---|---|
type | 'person' | 'message' | 'calendar_event' | 'document' | Unified entity type |
externalId | string | The item's ID in the external system (used for idempotent upserts) |
data | Record<string, unknown> | Normalised field values to write to the unified model |
raw | unknown | Original raw payload, preserved for debugging and re-processing |
Step 4: Deploy the Connector
Place the plugin directory in apps/hub/plugins/connectors/<plugin-dir>/. At present the hub does not auto-discover plugins from the filesystem and there is no CONNECTOR_PLUGINS env variable — plugin loading is wired explicitly in apps/hub/src/main.ts via pluginManager.loadPlugin('connectors/<plugin-dir>', { ...config }). To activate a new connector, add a matching loadPlugin call alongside the existing ones (see the ms-graph example in main.ts). A future release is expected to replace this with config-driven discovery.
Step 5: Write a Test
Co-locate a test file with your plugin entry:
// src/index.test.ts
import { describe, it, expect, vi } from 'vitest';
import { plugin } from './index.js';
import type { PluginContext } from '@made-open/plugin-sdk';
function makeContext(overrides?: Partial<PluginContext>): PluginContext {
return {
config: { apiKey: 'test-key' },
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
emitEvent: vi.fn(),
data: {
query: vi.fn().mockResolvedValue([]),
getById: vi.fn().mockResolvedValue(null),
},
http: {
get: vi.fn().mockResolvedValue({
contacts: [{ id: '1', name: 'Alice', email: 'alice@example.com' }],
nextCursor: undefined,
}),
post: vi.fn(),
},
...overrides,
};
}
describe('my-connector plugin', () => {
it('yields ConnectorDataItems from the API', async () => {
const context = makeContext();
const items = [];
for await (const item of plugin.sync(context)) {
items.push(item);
}
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
type: 'person',
externalId: '1',
data: { display_name: 'Alice' },
});
});
it('mapToUnifiedModel maps raw item correctly', () => {
const raw = { id: '42', name: 'Bob', email: 'bob@example.com' };
const result = plugin.mapToUnifiedModel(raw);
expect(result.type).toBe('person');
expect(result.externalId).toBe('42');
expect(result.data['display_name']).toBe('Bob');
});
});
Run the test:
pnpm -F @made-open/hub test
Step 6: Verify in the Hub
After deploying, the Connector Service will trigger a sync on startup (and on schedule). The ETL Service publishes data.PersonCreated or data.PersonUpdated events to NATS for each upserted entity, and the Search Service indexes them in Meilisearch. Synced entities are then queryable via the relevant REST routes under apps/hub/src/api/routes/ (for example search.ts for full-text search and the domain-specific routes such as inbox.ts, email.ts, etc.). Unresolved: there is no bare /contacts REST endpoint at this commit — earlier drafts of this guide showed curl http://localhost:4101/contacts, which does not match any registered route. Use search.ts or query persons via the Data Service for now.
Part 2: Building a Channel Plugin
A channel plugin handles bidirectional real-time communication — it receives inbound webhook payloads from an external service and can send outbound messages or place calls.
Here is the real Twilio channel manifest:
{
"id": "io.twilio.channel",
"name": "Twilio Channel",
"version": "0.1.0",
"type": "channel",
"entrypoint": "./index.ts",
"permissions": [
"network:outbound:api.twilio.com",
"network:inbound",
"event:publish:communication.*",
"data:read:persons",
"data:write:messages",
"data:write:conversations"
],
"configSchema": {
"type": "object",
"properties": {
"accountSid": { "type": "string" },
"authToken": { "type": "string" },
"webhookSecret": { "type": "string" }
},
"required": ["accountSid", "authToken"]
}
}
Step 1: Implement the ChannelPlugin Interface
import type {
ChannelPlugin,
OutboundMessage,
OutboundCall,
CallResult,
PluginContext,
} from '@made-open/plugin-sdk';
import { requireConfig, withRetry } from '@made-open/plugin-sdk';
export const plugin: ChannelPlugin = {
id: 'com.example.my-channel',
channelType: 'my-channel',
async handleIncomingEvent(payload: unknown, context: PluginContext): Promise<void> {
const event = payload as { From: string; Body: string; MessageSid: string };
context.logger.info('my-channel: inbound message', { from: event.From });
// Emit a normalised event to NATS — the Rules Service subscribes to this
await context.emitEvent('communication.MessageReceived', {
channelType: 'my-channel',
externalId: event.MessageSid,
from: event.From,
body: event.Body,
receivedAt: new Date().toISOString(),
});
},
async sendMessage(message: OutboundMessage, context: PluginContext): Promise<void> {
const apiKey = requireConfig(context, 'apiKey');
await withRetry(() =>
context.http.post(
'https://api.example.com/messages',
{ to: message.to, body: message.body },
{ Authorization: `Bearer ${apiKey}` },
),
);
context.logger.info('my-channel: message sent', { to: message.to });
},
async placeCall(call: OutboundCall, context: PluginContext): Promise<CallResult> {
const apiKey = requireConfig(context, 'apiKey');
const result = await withRetry(() =>
context.http.post(
'https://api.example.com/calls',
{ to: call.to, from: call.from },
{ Authorization: `Bearer ${apiKey}` },
),
) as { callSid: string; status: string };
return { callSid: result.callSid, status: result.status };
},
};
Step 2: Deploy the Channel Plugin
Place the plugin directory in apps/hub/plugins/channels/<plugin-dir>/ and wire it in apps/hub/src/main.ts via pluginManager.loadPlugin('channels/<plugin-dir>', { ...config }) alongside the existing channels/twilio load. There is no CHANNEL_PLUGINS env variable.
Step 3: Test Inbound Webhooks
The hub does not currently expose a single generic /webhooks/:channel endpoint. Inbound webhooks are wired per-integration as dedicated Fastify routes under apps/hub/src/api/routes/ (for example whatsappWebhook.ts, calling.ts → POST /webhooks/calling/inbound, billing.ts → POST /webhooks/stripe). When adding a new channel plugin, add a matching route file that receives the external service's HTTP payload, does any signature verification, and forwards the body to your plugin's handleIncomingEvent via the ChannelService.
Unresolved: the exact handoff API between a new webhook route and a channel plugin's handleIncomingEvent is not documented in the SDK today — inspect apps/hub/src/services/channels/ChannelService.ts and the whatsappWebhook.ts route as working references.
Helper Functions Quick Reference
All four helpers are importable from @made-open/plugin-sdk:
import { validateManifest, requireConfig, paginate, withRetry } from '@made-open/plugin-sdk';
| Function | Signature | Description |
|---|---|---|
validateManifest | (manifest: unknown) => { valid: boolean; errors: string[] } | Validate a plugin.json object. Use in tests to assert your manifest is well-formed. |
requireConfig | (context: PluginContext, key: string) => string | Assert a required config key is present and return it as a string. Throws a descriptive error if missing. |
paginate | <T>(fetchPage: (cursor?: string) => Promise<{ items: T[]; nextCursor?: string }>) => AsyncGenerator<T> | Iterate over a cursor-paginated REST API. Automatically follows the cursor chain until exhausted. |
withRetry | <T>(fn: () => Promise<T>, maxAttempts?: number, baseDelayMs?: number) => Promise<T> | Execute an async function with exponential back-off retry. Defaults: 3 attempts, 100ms initial delay. |
Further Reading
- Plugin System Architecture — V8 sandbox internals, permission enforcement, plugin lifecycle
- Event Catalog — Canonical NATS events to emit from plugins
- Data Model — Unified entity schemas that
ConnectorDataItem.datamaps to - Credential Wallet — How config fields flow from the UI to
PluginContext.config