Skip to Content
ReferencePluginsOverview

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 + function fields on the same plugin step 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 the PLUGSYNC_PLUGIN_RUNTIME environment variable on the flow_worker process (python is still the default everywhere; lambda is 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.

PageWhat is there
payload-contract.mdEvery field of PluginPayload and PluginResponse, and what is deliberately stripped.
capabilities.mdThe http / store / auth capability allow-list: format, where it’s set, real examples.
dev-loop.mdplugsync plugin init / push / invoke / logs, no AWS credentials needed, test connectors and $LATEST.
promotion.mdThe draft -> dev -> staging -> live state machine, version pinning, and how ARN+version get embedded in a published config.
generate-doc-walkthrough.mdA complete real plugin, end to end: Compass’s compass_doc_generator (issue #484, PR #549).

Prerequisites

  1. Your org’s tier has plugin_system_enabled (Enterprise and above as of this writing; every plugin endpoint 402s otherwise). See capabilities.md.
  2. plugsync CLI installed (pip install plugsync-cli from PyPI, no repo checkout needed — issue #957; or pip install -e cli/ from a checkout for local dev) and pointed at your API: plugsync config set api_url <url>, then plugsync auth login.
  3. Node.js (any LTS >= 18) and esbuild on PATH (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/function fields, (internal plugsync tooling - see the source repo)) — dormant, superseded by the Lambda runtime.
  • Porting the old fooshop Python 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.
Last updated on