Plugin runtime (Lambda + TypeScript)
A plugin is the imperative escape hatch for step logic the Event Flow Manifest can’t express: an HTTP call to a third-party system, stateful per-record logic (idempotency, dedup), or branching too complex for the when DSL. Everything else — field mapping, identity resolution, HubSpot reads/writes, associations — stays in the declarative manifest. See ADR-0001 for why that split exists and stays enforced: plugins never write to HubSpot directly, they return data that a core step (update_by_id, upsert, …) writes.
A plugin runs as an AWS Lambda function written in TypeScript/JavaScript. Its source lives authoritatively in PostgreSQL — there is no plugins-ts/ directory and no @plugsync/sdk package in this repository or on npm. See ADR-0003. You author, push, test, and promote a plugin entirely through the plugsync CLI and the plugin API; you never need AWS credentials.
Mental model
flow step (action: "plugin")
|
v
flow_worker builds a PluginPayload from the live execution context,
stripping every credential and live client object
|
v
AWS Lambda invoke (function_arn + version, resolved and embedded at publish time)
|
v
your handler(event) runs, returns a PluginResponse (plain JSON object)
|
v
flow_worker maps action (continue/skip/retry) and writes the response into
ctx.outputs[step.id] for later steps to read via `$<step_id>.<field>`The step that invokes your plugin looks like this in an authored draft:
{"id": "generate_doc", "action": "plugin",
"plugin": {"logical_name": "compass_doc_generator",
"config": {"store_keys": ["$.payload.object.id"]}}}You author only logical_name (the Plugin.name in your org) and an optional config object of static settings. At publish time the backend resolves the plugin’s Lambda coordinates and rewrites the step’s plugin block into {logical_name, function_arn, version, capabilities, config} — this is what actually gets replicated to the DynamoDB manifest the workers read. See Promotion and version pinning.
There is also a legacy, in-process Python plugin form (
module+functionfields on the samepluginstep action), predating issue #190. It is not covered here; new plugins should use the Lambda form (plugin.logical_name) described in this guide. Whether an event actually invokes Lambda instead of the legacy path is controlled by thePLUGSYNC_PLUGIN_RUNTIMEenvironment variable on theflow_workerprocess (pythonis still the default everywhere;lambdais opt-in per environment) — see Dev loop.
When do you need a plugin, and which tier enables it
When you need one: only for step logic the Event Flow Manifest genuinely cannot express — an HTTP call to a third-party system, stateful per-record logic (idempotency, dedup, a running counter), or branching too complex for the when DSL. Field mapping, identity resolution, conflict handling, HubSpot reads/writes, and associations are all declarative and never need a plugin; reach for one only after confirming the manifest can’t do it (see ADR-0001).
Which tier enables it: the Plugin System requires the org’s tier to have plugin_system_enabled ((internal plugsync tooling - see the source repo)) — today that is Enterprise and Custom-Dedicated (provisioned as internal); Free, Starter, Pro, Scale, and the reverse trial do not have it. Every plugin API endpoint (POST /api/plugins, POST /{id}/push, POST /{id}/promote) returns 402 Payment Required for a tier without the flag, and plugsync plugin push/plugin init will surface that 402 with your current tier and an upgrade link rather than a raw error (issue #955). See Tier gate for the enforcement details.
If you are authoring a draft and already know you’ll need a plugin step before your org has the tier, the preflight check on your connector’s draft (GET /api/connectors/{id}/preflight, surfaced in the dashboard’s Review-changes drawer) flags it with a plugin_step_tier_not_allowed warning as soon as the step is saved — well before you get to plugin push and hit the 402 there.
Trust model, briefly
A plugin is not sandboxed against malicious code. The security boundary is: a capability allow-list declared on the plugin (Capabilities), a best-effort static linter enforced at push time, a promotion gate (dev -> staging -> live), and the contractual trust of the org’s tier. Secrets never cross into the plugin payload; see PluginPayload / PluginResponse for exactly what is and isn’t included. This is documented in full in ADR-0003 and ADR-0020.
Where to read next
| Page | What is there |
|---|---|
| payload-contract.md | Every field of PluginPayload and PluginResponse, and what is deliberately stripped. |
| capabilities.md | The http / store / auth capability allow-list: format, where it’s set, real examples. |
| dev-loop.md | plugsync plugin init / push / invoke / logs, no AWS credentials needed, test connectors and $LATEST. |
| promotion.md | The draft -> dev -> staging -> live state machine, version pinning, and how ARN+version get embedded in a published config. |
| generate-doc-walkthrough.md | A complete real plugin, end to end: Compass’s compass_doc_generator (issue #484, PR #549). |
Prerequisites
- Your org’s tier has
plugin_system_enabled(Enterprise and above as of this writing; every plugin endpoint 402s otherwise). See capabilities.md. plugsyncCLI installed (pip install plugsync-clifrom PyPI, no repo checkout needed — issue #957; orpip install -e cli/from a checkout for local dev) and pointed at your API:plugsync config set api_url <url>, thenplugsync auth login.- Node.js (any LTS >= 18) and
esbuildonPATH(npm install -g esbuild) — the CLI bundles your TypeScript by shelling out to it.
Out of scope
This guide deliberately does not cover:
- The legacy in-process Python plugin path (
module/functionfields, (internal plugsync tooling - see the source repo)) — dormant, superseded by the Lambda runtime. - Porting the old
fooshopPython plugin, or a TypeScript SDK package — both explicitly excluded by ADR-0003. - A
hubspot:*capability broker letting a plugin call HubSpot directly. Reserved in ADR-0020 but not implemented; deferred to issue #381. - Plugin lifecycle hooks (setup/teardown on connector attach/detach, issue #109) — shipped separately (
outbox_processor._handle_invoke_plugin_lifecycle_hook), not something still to be built. Left out of this guide only because it’s a different, minimal payload shape invoked outside the per-event path covered here, not because it doesn’t exist. - A dashboard UI for plugins (issue #263). Everything here is CLI/API-driven.