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, 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.com

Working 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-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 (cli/plugsync_cli/commands/plugin.py, cli/plugsync_cli/bundler.py, (internal plugsync tooling - see the source repo)):

  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, or resolved from 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 (app/services/plugin_bundle_linter.py) 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), the server stores source_code + bundle + bundle_hash on the Plugin row and enqueues a deploy_plugin_dev outbox row carrying your user id. The plugin’s status is still draft at this instant — the actual lambda:CreateFunction/UpdateFunctionCode call happens asynchronously when outbox_dispatcher drains that row.

Poll until the deploy lands:

plugsync plugin status <plugin_id> # status: draft -> dev

status: 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 embedded function_arn.
  • lambda invokes your function for steps that have a resolved plugin.function_arn, falling back to the legacy path only for steps still authored with module/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/status

plugin 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.

Last updated on