Dev loop: push and test a plugin without AWS credentials
Every AWS side effect (creating, updating, deleting a Lambda function) happens server-side, driven by the publish_outbox pattern — the API enqueues a row, outbox_dispatcher consumes it and makes the AWS call. 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, no repo checkout needed (issue #957)
plugsync config set api_url http://localhost:8013 # the local dev API default; point at your target env
plugsync auth login -e you@example.comWorking from a checkout of this repo instead (e.g. to test an unreleased CLI change)? pip install -e cli/ installs the local copy in editable mode.
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 plugin_system_enabled (Enterprise and above); every plugin endpoint below returns 402 Payment Required otherwise.
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 (cli/plugsync_cli/commands/plugin.py, cli/plugsync_cli/bundler.py, (internal plugsync tooling - see the source repo)):
- 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, or resolved from 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 (
app/services/plugin_bundle_linter.py) 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), the server storessource_code+bundle+bundle_hashon thePluginrow and enqueues adeploy_plugin_devoutbox row carrying your user id. The plugin’sstatusis stilldraftat this instant — the actuallambda:CreateFunction/UpdateFunctionCodecall happens asynchronously whenoutbox_dispatcherdrains that row.
Poll until the deploy lands:
plugsync plugin status <plugin_id>
# status: draft -> devstatus: dev means a real per-developer Lambda function now exists, named deterministically as plugsync-{stage}-dev-{your_user_id:.8}-{plugin_id:.8}, always resolved at $LATEST.
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, the resolver (app/services/plugin_resolver.py) picks the per-developer function at $LATEST — but only if the function name it derives from connector.created_by_id matches the function name 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 plugin.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. The full command-by-command version of this (connector, entity, flow, publish, event, verification) is worked in docs/runbooks/plugin-lambda-dev-e2e.md against a real AWS account.
The runtime flag
Whether an event actually reaches your Lambda, or silently runs the legacy in-process path instead, depends on the PLUGSYNC_PLUGIN_RUNTIME environment variable on the flow_worker process — not anything per-org or per-connector:
python(the default everywhere as of this writing) takes the legacy in-process path unconditionally, ignoring any embeddedfunction_arn.lambdainvokes your function for steps that have a resolvedplugin.function_arn, falling back to the legacy path only for steps still authored withmodule/function.
This is an environment-level setting, not something a plugin author flips themselves — in a shared dev/staging/prod environment, enabling it is an infrastructure change coordinated with whoever operates that flow_worker deployment.
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 or PLUGSYNC_PLUGIN_RUNTIME setting. A plugin step authored for the Lambda runtime (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 (issue #1113): 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. Before this fix, a Lambda-authored step’s dry-run fell through to the legacy in-process path with nothing to import, crashing with a raw 'NoneType' object has no attribute 'startswith' traceback instead of a useful outcome. The rest of the flow keeps rehearsing past a not_executed step, same as an unverifiable associate step (#822) — 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 '{"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 the deterministic org function name as a fallback) — useful for iterating on your handler logic without publishing a connector config at all.