Skip to Content
ReferencePluginsPayload contract

PluginPayload / PluginResponse contract

This page documents exactly what your plugin receives and must return. It is derived directly from the runtime code — (internal plugsync tooling - see the source repo) (LambdaPluginInvoker, which builds the payload and interprets the response), (internal plugsync tooling - see the source repo) (how the embedded plugin block gets rewritten when a plugin is repromoted), and the plugin step action in (internal plugsync tooling - see the source repo) (_action_plugin, the dispatch point). If a historical spec or plan document disagrees with this page, this page is wrong and should be fixed — the code is the source of truth.

Handler shape

Your Lambda function is created with Runtime="nodejs20.x" and Handler="index.handler". This means:

  • Your bundled index.js must export a named handler function, not a default export:

    export async function handler(event: PluginPayload): Promise<PluginResponse> { // ... }
  • The CLI bundles src/index.ts with esbuild --bundle --platform=node --target=node18 --format=cjs, which turns export async function handler(...) into exports.handler = ... — exactly what index.handler resolves. A export default entrypoint will bundle fine but Lambda will fail to find the handler export at invoke time.

    plugsync plugin init scaffolds a stub that already matches this: a named export async function handler(event), with no import (there is no @plugsync/sdk package — see ADR-0003: no SDK ships in this repo or on npm). The shapes below are plain TypeScript interfaces you can paste directly into your plugin to type the stub’s event/return value, no dependency required.

  • There is no per-plugin package.json/node_modules install step server-side: esbuild --bundle inlines whatever you import/require from npm packages you installed locally into a single CJS file. Node built-ins (net, child_process, dgram, dns) are rejected by the push-time linter regardless of capabilities; see capabilities.md.

PluginPayload (what your handler receives)

interface PluginPayload { schema_version: number; // currently 1 connector_id: string; flow_name: string; source_name: string; external_id: string | null; payload: Record<string, unknown>; // the inbound event body ($.payload in the manifest) canonical: Record<string, unknown>; outputs: Record<string, unknown>; // outputs of earlier steps in this flow, keyed by step id entities_index: Record<string, unknown>; sources_index: Record<string, unknown>; item: Record<string, unknown> | null; capabilities: unknown[] | Record<string, unknown>; // this plugin's granted capabilities, as stored config: Record<string, unknown>; // the authored step's `plugin.config`, passed through verbatim store?: Record<string, unknown>; // present only with the `store: ["read"]` capability auth?: Record<string, Record<string, string>>; // present only with an `auth: [...]` capability }
FieldAlways presentNotes
schema_versionyes1 today. Bump signals a breaking payload shape change.
connector_idyesThe connector this event is running under.
flow_nameyesThe name of the flow this step belongs to.
source_nameyesThe EventSource name that produced this event.
external_idyes (nullable)Message-level external id from the event envelope, distinct from payload.
payloadyesThe inbound event body, exactly as $.payload resolves it in the manifest.
canonicalyesThe shared canonical dict a transform step (if any, earlier in the flow) wrote into.
outputsyesEvery earlier step’s output in this flow, keyed by step id — the same map $<step_id> resolves against for manifest steps. Read a prior step’s output with event.outputs["find_company"].
entities_indexyesThe connector’s resolved entities (entity name -> schema config), no secrets.
sources_indexyesEvery EventSource’s {name, type, config}, including outbound base_url/credential_name — but never resolved secrets. Read a target’s base URL with event.sources_index["doc_generator"].config.outbound.base_url.
itemyes (nullable)Set on per-item execution paths (batch/many actions); null otherwise.
capabilitiesyesThis plugin’s capabilities as currently stored on the Plugin row (dict or list shape — see capabilities.md), not anything authored on the step.
configyesThe authored step’s plugin.config object, preserved verbatim across publish and re-promotion. Static, non-secret settings — e.g. Compass’s {"store_keys": [...], "doc_type": "..."}.
storeonly with store: ["read"]Pre-loaded key/value snapshot — see below.
authonly with a non-empty auth grantResolved outbound headers for the granted source(s) — see below.

store capability payload

config.store_keys (authored on the step’s plugin.config) is a list of $...-style expressions. Each is resolved against the live execution context (the same resolver manifest steps use, e.g. $.payload.object.id) to produce a concrete key string; the worker then fetches that key’s stored value from the plugin’s key/value store (scoped to connector_id + plugin name) and pre-loads it into payload.store, keyed by the resolved key.

"plugin": {"logical_name": "compass_doc_generator", "config": {"store_keys": ["$.payload.object.id"]}}

If the deal id resolves to "12345", your handler sees event.store["12345"] — the previously stored value for that key, or undefined if nothing was ever written for it. This is how a plugin implements one-write-per-record idempotency without maintaining its own database.

auth capability payload

For each source name granted under the auth capability, payload.auth[<source_name>] carries only that source’s resolved outbound headers (e.g. {"Authorization": "Bearer <token>"}), built the same way a manifest post step resolves its own outbound auth. HubSpot credentials are never reachable this way — see “What is never included” below and ADR-0020.

What is never included

The payload builder explicitly omits, and a plugin can never reach:

  • sources_auth — the full map of every source’s plaintext outbound credentials. Only the capability-scoped auth:<source_name> subset described above ever crosses the boundary.
  • sources_credentials — creds-in-body fields (username/password style credentials) for any source.
  • hubspot_client, http_client — live async HTTP client objects; not serialisable, and this is also the token-custody boundary. A plugin cannot call HubSpot with the connector’s OAuth token.
  • identity_service, log — live service handles, not serialisable and not part of the contract.
  • op_records — internal trace bookkeeping, not part of the plugin contract.

PluginResponse (what your handler must return)

Your handler returns a plain JSON-serialisable object. Two keys are reserved and interpreted by the runtime; every other key is yours and is returned verbatim.

interface PluginResponse { action?: "continue" | "skip" | "retry"; // default: "continue" store_writes?: { key: string; value: unknown }[]; [key: string]: unknown; // anything else: passed through untouched }
action valueRuntime effect
"continue" (or omitted, or any value other than "skip"/"retry")The full response object is written to ctx.outputs[step.id]; the flow proceeds to the next step.
"skip"The flow_worker raises a skip signal: the flow halts here (no more steps run for this event).
"retry"The flow_worker treats this as a transient failure, same as an HTTP 429/5xx from a core step: up to 5 delivery attempts, after which the record goes terminal as parked and stays replayable. See Reliability.

The entire response dict is written to ctx.outputs[step.id], not just a data sub-object — there is no required top-level wrapper key. A later manifest step reads any field of it with $<step_id>.<field>. For example, if your plugin’s step id is generate_doc and it returns {"action": "continue", "doc_url": "https://..."}, a later step reads it as $generate_doc.doc_url:

{"id": "write_doc_url", "action": "update_by_id", "target": "hubspot", "entity": "deal", "object_id": "$.payload.object.id", "data": {"doc_url": "$generate_doc.doc_url"}}

store_writes

A list of {key, value} pairs (literal keys, not $... expressions — resolve any dynamic key yourself before returning it). Applied only if the plugin holds the store: ["write"] capability; if store_writes is present in the response but the capability isn’t granted, the writes are dropped and a structured warning is logged server-side (not an error — your flow still completes).

{"action": "continue", "doc_url": "https://...", "store_writes": [{"key": "12345", "value": {"doc_url": "https://..."}}]}

See generate-doc-walkthrough.md for the complete real handler this pattern is drawn from.

Errors

An uncaught exception in your handler (Lambda FunctionError) is treated as non-retryable: the event is sent to the dead-letter queue. If a failure should be retried instead, catch it and return {"action": "retry", ...} explicitly.

Last updated on