Back to Activepieces

Building Pieces

brain/wiki/pieces-engine/building-pieces.md

0.87.011.8 KB
Original Source

Building Pieces

How to build, test, and publish custom pieces. Pieces are npm packages written in TypeScript; ~60% are community-contributed. Hot reload shows local changes in ~7s. Source: docs/build-pieces/.

Build a piece (tutorial track)

  • Setup — fork the repo or use GitHub Codespaces / dev container; local development setup.
  • Definitionnpm run cli pieces create scaffolds under packages/pieces/community/<name>/; src/index.ts exports createPiece({ displayName, logoUrl, auth, authors, actions, triggers }).
  • Authentication — set auth via PieceAuth (e.g. PieceAuth.SecretText(...), PieceAuth.None()); more forms in the auth reference.
  • Actionsnpm run cli actions create scaffolds an action file; define with createAction(...).
  • Triggersnpm run cli triggers create; three techniques: Polling (periodic checks), Webhook (single URL), App Webhook (OAuth subscriptions, not supported). Built with createTrigger({ ..., type: TriggerStrategy.WEBHOOK | POLLING | APP_WEBHOOK, onEnable, onDisable, ... }).

Piece reference

Authentication, triggers (polling/webhook), properties + validation, flow control, persistent storage, files, external libraries, piece versioning, examples, custom API calls, output schema, i18n.

Gotchas

  • Engine vitest needs a fresh core-execution dist. Enums like LoopBatchMode live in @activepieces/core-execution and are re-exported through @activepieces/shared. The engine vitest config aliases @activepieces/shared to source, but that source pulls core-execution from its dist — so adding an enum value without rebuilding fails even the PR's own tests with Cannot read properties of undefined (reading 'ITEMS_PER_BATCH'). Run npx turbo run build --filter=@activepieces/core-execution first. CI's turbo dep graph handles this; local ad-hoc runs don't.
  • A non-string property can reach run() as a JSON string. The builder's fx / dynamic-value toggle renders a text input, so getValueForInputOnDynamicToggleChange (auto-form-field-wrapper.tsx) JSON.stringifys whatever was there — ["year","month"], true, a dropdown's object option value. It saves and publishes silently because buildSchema (packages/pieces/framework/src/lib/property/util.ts) deliberately unions a z.string() branch onto those types for exactly this reason, and that schema is what both the form and the server-side step validator use. The only place to heal it is the engine's variables/processors/ map (props-processor.ts is the single choke point for actions, triggers, and the agent/MCP tool path). Precedent: objectProcessor (#5636), then multi-select + checkbox (#14389). Coercion is only safe where the property's value type is unambiguous — DROPDOWN/STATIC_DROPDOWN are deliberately excluded because a legitimate string option value like "[1,2]" would be corrupted into an array, so that gap is still open. Pair every new processor with a validateProperty case: without one, a string the processor can't parse reaches the piece with zero errors and fails opaquely deep inside run(). An empty dynamic input is '', not nil — a processor maps it to undefined and lets validateProperty decide, which is why jsonProcessor and its followers never read property.required (optional passes, required errors). Toggling back to manual is the mirror trap: that branch used to discard the value and return getDefaultPropertyValue, so the field silently reset to the piece's defaultValue (on Date Helper, ['year'], which reads as "it kept only the first item"). The toggle-back path now routes through formUtils.parseDynamicValue (packages/web/src/features/pieces/utils/form-utils.tsx, beside getDefaultPropertyValue, its only caller), which restores a value only when its shape is unambiguous for the property; single-select dropdowns still reset, deliberately. Coercion that guesses belongs in the engine processor, never in that shared helper. multiSelectProcessor wraps any non-array resolved value into a single-item array, so {{ trigger.body.tag }} carrying one tag still works; the helper must stay strict, because the builder toggle calls it on values that are still expressions and a wrap there would persist ['{{ trigger.body.units }}'] as a real selection. Note what this makes unnecessary: no flow migration. A dynamic JSON property has had the same stringified-value problem forever and never got one — the engine parses at runtime and the text box is simply how dynamic mode looks. A migration flipping DYNAMICMANUAL to bring the picker back was written for #14389 and dropped; the toggle-back path already does that on demand, without a schema bump, a backup, or a breaking-change note.
  • Tests that load a real piece run locally, but only once that piece is built. The piece-loader does await import('<abs>/pieces/core/<x>/dist/src/index.js'), so a piece with no dist/ fails with ERR_MODULE_NOT_FOUND and the test looks fundamentally broken. It isn't — npx turbo run build --filter=<piece> first and it passes (verified: packages/server/engine/test/handler/flow-with-delay.test.ts 5/5, and test/integration/ce/flows/flow-run/execute-flow-e2e.test.ts 8/8 including the three parent→child callFlow subflow cases). Two traps when you run these: the server API workspace is named plain api, not @activepieces/server-api, so a turbo --filter on the latter dies with "No package found"; and the integration tests need their env, so invoke them as cd packages/server/api && export $(cat .env.tests | xargs) && AP_EDITION=ce npx vitest run <path>. Rebuild the piece after editing it — the test executes dist/, not your source, so a stale dist silently green-lights the old code.
  • Parsing CSV: always pass bom: true, and don't expect trim: true to cover it. csv-parse leaves the UTF-8 BOM in place, and its trim option only strips space/tab, not U+FEFF. With columns: true, a BOM-prefixed file (what Excel and Google Sheets "Download as CSV" produce) yields a first header of "id" and row keys to match, so {{ ...rows[0].id }} resolves to nothing in the flow while every step reports success: no error, correct row counts, green run. Verified against the repo's pinned [email protected]headers[0] charCodes come back [65279, 105, 100] and rows[0].id is undefined. Pair it with relax_column_count: true (as knowledge-base.service.ts and subflows/csv.ts do) so one ragged row doesn't throw CSV_RECORD_INCONSISTENT_COLUMNS and abort a whole file mid-way; the trade is that a row with extra columns silently loses them. piece-csv and google-sheets still parse without bom: true.
  • columns: has two more silent-green failure modes that relax_column_count does not cover; one has a built-in fix, one does not. Both verified against pinned [email protected]. (1) Duplicate header names collapse, last value winsa,a with 1,2 gives {a:'2'}, one column silently gone, while a header array captured from the columns callback still reports ['a','a'] and so no longer describes the rows. Duplicate columns are ordinary in real exports ("Notes","Notes"). Fix is one option, group_columns_by_name: true — dup columns arrive as {a:['1','2']} and non-duplicate columns are untouched. The cost is that a dup column's value is string[] where every other column is string, so type row values as string | string[]. (2) A row shorter than the header omits the missing keys entirely — headers a,b,c with row 1,2 gives {a:'1',b:'2'}, no c key, not c:''. So {{ row.c }} resolves to nothing on ragged rows, run still green. No parser option covers this; back-fill in an on_record hook if you need shape-stable rows. subflows/csv.ts is the reference for both, pinned by subflows/test/csv.test.ts.
  • Pass the whole context to pollingHelper, never { store, auth, propsValue }. The destructured form is the dominant shape in the repo (306 of 403 onEnable call sites) and it type-checks, so it reads as idiomatic — but the helper's param type is wider than those three fields, and TypeScript only rejects excess properties, never missing optional ones. So each field added to the polling context is silently absent in every destructuring trigger. context.isRepublish is the first one that changes behaviour: pollingHelper.onEnable uses it to keep the existing lastPoll/lastItem instead of resetting to now, so a destructuring trigger still drops every event between its last poll and a republish (triggers.md has the platform-side thread). Scaffolding (npm run cli triggers create), docs/build-pieces/, and the piece-builder skill all pass context, so new triggers are fine — the trap is copying from a neighbouring piece, since the wrong shape is the majority there. The legacy sites are being fixed on touch rather than by one repo-wide codemod: whoever edits a piece switches that piece's calls over, which rides an existing version bump and rebuild instead of forcing one on ~300 pieces nobody is running.
  • Streaming a file into a piece is Property.File({ streaming: true }). It resolves to an ApStreamingFile with body: Readable (pieces-framework ≥ 0.35.0, 000014) and accepts a URL, a base64 data URL, the builder's file picker, or a previous step's file — a strict superset of a URL text field, with the fetch owned by the engine. amazon-s3/upload-file.ts and subflows/stream-csv-to-flow.ts are the references. Three things to know: the engine's fileProcessor swallows fetch failures and returns null, which for a required: true prop surfaces as the confusing Expected file url or base64 with mimeType validation error rather than a fetch error (so no isNil guard in your run() is needed — the action never starts); the engine's fetch has no timeout, so a source that connects then stalls burns FLOW_TIMEOUT_SECONDS; and .pipe() does not forward 'error', so you still need file.body.on('error', ...) or a mid-stream network drop becomes an uncaught exception in the sandbox.
  • Every piece you touch in a PR needs a version bump, and CI only names the first one. validate-publishable-packages runs packagePrePublishChecks (tools/scripts/utils/package-pre-publish-checks.ts) over every piece directory: if the piece's package.json version is already the npm latest and git diff origin/main -- <piece> is non-empty, it throws package version not incremented — unless that piece's own package.json also changed, which is how a bump satisfies it. Two traps. The diff is against origin/main, not the PR base, so a stacked PR inherits every piece its base touched and must bump those too. And the checks run in Promise.all batches of 10, so the first thrown error kills the process — the log names one piece (azure-ad) when 27 are equally broken. Don't fix the named one and re-push; enumerate git diff --name-only origin/main...HEAD | grep pieces/ and bump the whole set at once. Patch bump is the convention even for behaviour changes like added OAuth scopes. packages/pieces/framework and packages/pieces/common are exempt (explicit notPublished list in validate-publishable-packages.ts — pieces inline them at build time), as is everything outside packages/pieces/. The script is runnable locally, and takes ~3 min: npx ts-node -r tsconfig-paths/register -P packages/server/engine/tsconfig.lib.json tools/scripts/validate-publishable-packages.ts.

Sharing & misc

  • Sharing — contribute to community, publish a community piece, or keep it private.
  • Misc — build/bundle/publish piece, pieces CI/CD, migrate nx→turbo, migrate pieces to bundles, private fork, testing pieces, dev container, Codespaces, create a new AI provider.