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.jsmust export a namedhandlerfunction, not a default export:export async function handler(event: PluginPayload): Promise<PluginResponse> { // ... } -
The CLI bundles
src/index.tswithesbuild --bundle --platform=node --target=node18 --format=cjs, which turnsexport async function handler(...)intoexports.handler = ...— exactly whatindex.handlerresolves. Aexport defaultentrypoint will bundle fine but Lambda will fail to find thehandlerexport at invoke time.plugsync plugin initscaffolds a stub that already matches this: a namedexport async function handler(event), with no import (there is no@plugsync/sdkpackage — 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’sevent/return value, no dependency required. -
There is no per-plugin
package.json/node_modulesinstall step server-side:esbuild --bundleinlines whatever youimport/requirefrom 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
}| Field | Always present | Notes |
|---|---|---|
schema_version | yes | 1 today. Bump signals a breaking payload shape change. |
connector_id | yes | The connector this event is running under. |
flow_name | yes | The name of the flow this step belongs to. |
source_name | yes | The EventSource name that produced this event. |
external_id | yes (nullable) | Message-level external id from the event envelope, distinct from payload. |
payload | yes | The inbound event body, exactly as $.payload resolves it in the manifest. |
canonical | yes | The shared canonical dict a transform step (if any, earlier in the flow) wrote into. |
outputs | yes | Every 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_index | yes | The connector’s resolved entities (entity name -> schema config), no secrets. |
sources_index | yes | Every 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. |
item | yes (nullable) | Set on per-item execution paths (batch/many actions); null otherwise. |
capabilities | yes | This plugin’s capabilities as currently stored on the Plugin row (dict or list shape — see capabilities.md), not anything authored on the step. |
config | yes | The 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": "..."}. |
store | only with store: ["read"] | Pre-loaded key/value snapshot — see below. |
auth | only with a non-empty auth grant | Resolved 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-scopedauth:<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 value | Runtime 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.