Skip to Content

Dev loop: push and test a plugin without AWS credentials

Every AWS side effect (creating, updating, deleting a Lambda function) happens server-side and asynchronously: the API accepts your request straight away and the deploy lands a moment later, which is why push and promote answer 202 Accepted and plugsync plugin status is where you read the outcome. You never hold or configure AWS credentials as a plugin author; the CLI and the API are all you touch.

1. Install and authenticate the CLI

pip install plugsync-cli # standalone install from PyPI plugsync auth login -e you@example.com

The CLI talks to https://app.plugsync.com by default. plugsync auth login saves the returned JWT as api_key in ~/.plugsync/config.yaml; every subsequent command reuses it as a Bearer token. PLUGSYNC_API_URL / PLUGSYNC_API_KEY environment variables override the config file if you prefer that.

Your org’s tier needs the Plugin System: creating a plugin, pushing a bundle and promoting it return 402 Payment Required otherwise. Listing, reading, invoking and reading logs or metrics of existing plugins are not gated. See Plan limits.

2. Scaffold and write your plugin

plugsync plugin init my-plugin cd my-plugin

This creates src/index.ts, plugin.json ({"name": "my-plugin", "id": null, "capabilities": {}}), and a package.json with esbuild/typescript as dev dependencies. The stub already matches the real contract: a named export async function handler(event) (Lambda invokes index.handler, so a default export would not be found at invoke time), no @plugsync/sdk import (there is no such package), and capabilities scaffolded as a JSON object (the API’s create/update schema rejects a JSON array, even an empty one, with 422). See payload-contract.md for the full PluginPayload/PluginResponse shapes to flesh out your handler, and capabilities.md for the capabilities object format.

3. Push

plugsync plugin push .

What happens:

  1. If plugin.json’s id is null, the CLI first calls POST /api/plugins with name + capabilities from plugin.json, and writes the returned id back into plugin.json. Every push after that reuses the saved id.
  2. The CLI bundles src/index.ts to a single CJS file by shelling out to esbuild <dir>/src/index.ts --bundle --platform=node --target=node18 --format=cjs --outfile=<tmp> (<tmp> is a throwaway temp file the CLI creates, reads back, and deletes — your own package.json’s build script, which writes to dist/index.js, is not what plugin push actually runs). esbuild must be on PATH (npm install -g esbuild): the CLI runs the bare esbuild command and does not look into a local node_modules/.bin.
  3. It reads the bundle bytes, computes a SHA-256, and POSTs multipart bundle (file) + source_code (the original TypeScript text, form field) to /api/plugins/{id}/push.
  4. The server runs the capability allow-list linter against the bundle text — 422 plugin_capability_violation if it references a host not covered by your http capability, or a forbidden Node module. See capabilities.md.
  5. On success (202 Accepted), the server stores your source_code, the bundle and its hash, and starts a dev deploy for your user. The call itself does not change the plugin’s status; the Lambda function is created or updated asynchronously, right after. When that dev deploy lands, status becomes dev and function_arn points at your per-developer function, even if the plugin had already been promoted: live_version and staging_version are kept, and live connectors keep running the promoted build (see promotion.md).

The CLI prints exactly that: what got uploaded, the plugin’s status at the moment of the upload (not the outcome of this push), and where to check the async deploy’s outcome:

Bundle uploaded, dev deploy enqueued. Promotion status: draft (live_version none, staging_version none). Run `plugsync plugin promote` to ship it. Dev deploy runs asynchronously; it may take a few seconds. Check outcome: `plugsync plugin status <plugin_id>` or `plugsync plugin logs <plugin_id>`.

On a plugin that has already been promoted before, Promotion status reflects that (e.g. live (live_version 3, staging_version 3)) — it is not telling you this push went live, only what the status was before the dev deploy. Once the dev deploy lands, plugin status reads dev with the same live_version and staging_version.

Poll until the deploy lands — a few seconds is the usual wait:

plugsync plugin status <plugin_id> # status: draft -> dev # staging_version: none # live_version: none # function_arn: none -> arn:aws:lambda:eu-central-1:...:function:<your dev function>

status: dev means a Lambda function dedicated to you now exists and is always invoked at $LATEST.

When a deploy or a promotion fails, plugin status says so: an extra error_message line appears, naming the operation that failed (dev deploy, promotion to staging, promotion to live) and the reason in plain language — a bundle too large for the runtime, a transient error being retried for you, a platform problem to report to support. The line goes away when that same operation succeeds: a failed promotion is cleared by the next promotion that lands, not by an unrelated push, so a push that keeps failing never hides behind a promotion that worked.

status tells you whether that failure is still being worked on. Transient failures are retried for you, and while they are, the status keeps reading the operation in flight (promoting_to_staging, promoting_to_live). When the platform gives up retrying, the status becomes error: a plugin reading promoting_to_live is still on its way, one reading error is not going anywhere until you act.

$ plugsync plugin status <plugin_id> my-plugin id: <plugin_id> status: error staging_version: 1 live_version: none function_arn: arn:aws:lambda:eu-central-1:...:function:<your dev function> error_message: Promotion to live failed: the uploaded bundle is too large for the plugin runtime. Reduce the bundle size and push again.

Push or promote again once you have fixed the cause: when that operation succeeds, the plugin leaves error and the status goes back to describing what the plugin actually is (dev, staging, live), with the error_message line gone.

The message is written for you, not for us: it never carries a stack trace or a raw AWS error. When it points at your own handler (a lifecycle hook that threw), plugsync plugin logs has the handler output; when it points at the platform, retrying and then contacting support is the whole procedure.

Requesting a lifecycle hook again replaces any earlier attempt of the same hook that is still being retried: an older attempt never writes its outcome over a newer request, and never moves the plugin to error.

Full sequence: push -> dev -> promote -> staging/live

Chaining push, status and promote end to end, with the output each command actually prints:

$ plugsync plugin push . Bundling... Uploading bundle (1234 bytes, sha256=deadbeefcafe...)... Bundle uploaded, dev deploy enqueued. Promotion status: draft (live_version none, staging_version none). Run `plugsync plugin promote` to ship it. Dev deploy runs asynchronously; it may take a few seconds. Check outcome: `plugsync plugin status <plugin_id>` or `plugsync plugin logs <plugin_id>`. $ plugsync plugin status <plugin_id> # usually a few seconds later, once the dev deploy has landed my-plugin id: <plugin_id> status: dev staging_version: none live_version: none function_arn: arn:aws:lambda:eu-central-1:...:function:<your dev function> hash: deadbeefcafe... $ plugsync plugin promote <plugin_id> staging Promotion enqueued - status: promoting_to_staging $ plugsync plugin status <plugin_id> # once the new version has been published my-plugin id: <plugin_id> status: staging staging_version: 1 live_version: none function_arn: arn:aws:lambda:eu-central-1:...:function:<your org function> $ plugsync plugin promote <plugin_id> live Promote plugin <plugin_id> to LIVE? This affects all connectors. [y/N]: y Promotion enqueued - status: promoting_to_live $ plugsync plugin status <plugin_id> my-plugin id: <plugin_id> status: live staging_version: 1 live_version: 2 function_arn: arn:aws:lambda:eu-central-1:...:function:<your org function>

Both push and promote return 202 Accepted as soon as the request is accepted — the Lambda call itself always happens after that, server-side, usually within a few seconds. plugin status is the one command that shows the result of that async work: a status still reading promoting_to_staging/promoting_to_live well after promoting means the deploy has not landed yet, error means it will not land without you, and either way the error_message line above says which operation failed and why. Nothing fails silently here, and nothing stays “in progress” forever.

4. Test against a mode=test connector

A connector’s mode is set at creation and is immutable (live by default, or test):

curl -X POST "$BASE/api/connectors" -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \ -d '{"name": "my-test-connector", "mode": "test"}'

POST /api/connectors stamps created_by_id to the calling user. When a flow step references your plugin ("plugin": {"logical_name": "my-plugin"}) and you publish this connector’s draft, publish resolves the step to the per-developer function at $LATEST — the one belonging to the connector’s created_by_id, which is not necessarily the one your push created. In practice: push the plugin and create the test connector as the same logged-in user, or the publish will embed an ARN for a function that doesn’t exist and every invoke will 404.

A live connector resolves differently — the shared org function pinned at live_version. See promotion.md.

Once your test connector’s draft has a flow with a plugin step and is published, send an event through the normal Event API and inspect ctx.outputs[step.id] via the event status endpoint, exactly as any other flow.

What has to be resolved for the invoke to happen

There is no runtime flag to set: Lambda is the only plugin runtime. What decides whether an event reaches your function is whether the step carries a resolved plugin.function_arn, which publish embeds. A step that reaches runtime with only logical_name was never resolved — the plugin does not exist for that org, or has no promoted version — and fails the event with a permanent error naming the step, rather than silently doing nothing.

Dry-run never invokes your plugin

The dashboard/API flow dry-run (POST /draft/flows/{name}/dry-run, see draft-publish.md) and the tools built on the same rehearsal harness (shadow replay, event re-verification) never call your Lambda, in any environment. A plugin step (plugin.logical_name set) is reported in the trace as a dedicated outcome instead:

{"step_id": "sync_lead", "action": "plugin", "not_executed": true, "not_executed_reason": "not executed in dry-run: this step's plugin block is authored for the Lambda runtime; ..."}

Two reasons compound here: a live invocation is a real, billable AWS call — the exact side effect the rehearsal’s stub-only contract forbids — and function_arn/version are only resolved and embedded into the step’s plugin block at publish time, so a draft dry-run never even has coordinates to invoke. The rest of the flow keeps rehearsing past a not_executed step, same as an unverifiable associate step — but any later step whose input actually depends on this plugin’s output ($<step_id>.<field>) will read it as missing, since dry-run never fabricates a plausible response for code it did not run.

To actually exercise your handler, use plugsync plugin invoke (below) or publish to a test-mode connector and send a real event through the Event API — dry-run is for previewing the surrounding manifest steps, not for testing plugin logic.

5. Inspect directly

plugsync plugin logs <plugin_id> # tails recent CloudWatch log lines for the plugin's function plugsync plugin invoke <plugin_id> --payload '{"email": "x@y.z"}' # direct invoke, bypasses any flow plugsync plugin list # every plugin in your org, with id/name/status

plugin invoke calls POST /api/plugins/{id}/invoke, which proxies straight to lambda:invoke using the plugin’s stored function_arn (or your org’s shared function when the plugin has never been deployed) — useful for iterating on your handler logic without publishing a connector config at all. function_arn follows the last deploy that landed: after a push it is the pusher’s per-developer function at $LATEST (someone else’s, if a teammate pushed last), after a promotion it is the shared org function at $LATEST, which runs the last promoted code. Run plugsync plugin status first if it matters which build answers.

The response is {"status_code": <int>, "payload": <your handler's return value, parsed as JSON when it is JSON>, "request_id": "<id>"}. A handler that throws answers 502 with detail.error set to function_error (with the request_id to look up in plugsync plugin logs); a failure to reach the function answers 502 with proxy_error.

--payload is only the event body — it lands at event.payload in your handler, exactly as $.payload resolves it in a real flow. It is not the whole PluginPayload envelope, so it cannot exercise event.config, event.auth or event.event_type on its own. Three more options cover those:

  • --config '<json>' sends a JSON object as event.config, mirroring a step’s authored plugin.config — useful for testing config-dependent branches without publishing a connector.
  • --event-type <type> sends a string as event.event_type, the field a real flow fills with the type of the event being processed (for example --event-type order.created). Without it the event carries no event_type, so pass it when your handler branches on the event type.
  • --auth <source-name> names an event source of your org (never a secret typed on the command line). The server resolves that source’s config.outbound — its credential_name, its auth_type, its auth_header — so your handler receives the very headers a real flow would hand it, under the same key: event.auth[<source-name>], the key the auth capability grant uses.

--auth applies the same capability rule as a real invocation: the plugin must declare auth:<source-name>, otherwise the call is refused with a capability_missing error naming the grant you are missing. Without that rule a manual invoke would go green on a plugin whose published flow fails for the missing grant.

Two more forms cover the cases where no source binding applies:

You passThe server does
--auth <source-name>resolves that source’s outbound auth, exactly as a flow does
--auth <name> with no source of your org called <name>reads it as a stored credential name and uses the default header for its type: Authorization: Bearer <secret> for bearer_token, Authorization: Bearer <token> for oauth2_client_credentials (the token is fetched from the credential’s token endpoint for you), X-API-Key: <secret> otherwise. A credential that yields no header at all — a creds-in-body one, whose fields belong in a request body rather than a header — is refused with a no_auth_headers error instead of invoking with an empty event.auth.
--auth <source-name>=<credential-name>pins that credential onto the named source’s header shape, when you want to try a different key without touching the source

A source that binds none of outbound.credential_name, outbound.default_headers or outbound.secret_headers resolves to nothing at runtime, so --auth on it is refused with a source_without_outbound_auth error rather than silently invoking with an empty event.auth.

# `kvk` is an event source of your org; the plugin declares `auth: ["kvk"]`: plugsync plugin invoke <plugin_id> \ --payload '{"email": "x@y.z"}' \ --config '{"doc_type": "invoice"}' \ --auth kvk # no source called erp_api_key: read as a stored credential name instead plugsync plugin invoke <plugin_id> \ --payload '{"email": "x@y.z"}' \ --auth erp_api_key # same headers as the `kvk` source, but with a credential you pick: plugsync plugin invoke <plugin_id> \ --payload '{"email": "x@y.z"}' \ --auth kvk=erp_api_key
Last updated on