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.comThe 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-pluginThis 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:
- If
plugin.json’sidisnull, the CLI first callsPOST /api/pluginswithname+capabilitiesfromplugin.json, and writes the returned id back intoplugin.json. Every push after that reuses the saved id. - The CLI bundles
src/index.tsto a single CJS file by shelling out toesbuild <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 ownpackage.json’sbuildscript, which writes todist/index.js, is not whatplugin pushactually runs).esbuildmust be onPATH(npm install -g esbuild): the CLI runs the bareesbuildcommand and does not look into a localnode_modules/.bin. - It reads the bundle bytes, computes a SHA-256, and
POSTs multipartbundle(file) +source_code(the original TypeScript text, form field) to/api/plugins/{id}/push. - The server runs the capability allow-list linter against the bundle text —
422 plugin_capability_violationif it references a host not covered by yourhttpcapability, or a forbidden Node module. See capabilities.md. - On success (
202 Accepted), the server stores yoursource_code, thebundleand its hash, and starts a dev deploy for your user. The call itself does not change the plugin’sstatus; the Lambda function is created or updated asynchronously, right after. When that dev deploy lands,statusbecomesdevandfunction_arnpoints at your per-developer function, even if the plugin had already been promoted:live_versionandstaging_versionare 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/statusplugin 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 asevent.config, mirroring a step’s authoredplugin.config— useful for testing config-dependent branches without publishing a connector.--event-type <type>sends a string asevent.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 noevent_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’sconfig.outbound— itscredential_name, itsauth_type, itsauth_header— so your handler receives the very headers a real flow would hand it, under the same key:event.auth[<source-name>], the key theauthcapability 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 pass | The 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