Skip to Content
ReferencePluginsWalkthrough: generate-doc

Walkthrough: the acme generate-doc plugin

A complete worked example: acme_doc_generator, part of the acme <-> HubSpot connector’s deal flow. It exercises every generic feature of the plugin runtime at once — store read/write, scoped auth, config passthrough — and none of it is special-cased for this connector. A connector’s own configuration lives in its own workspace (plugsync pull); this page walks through the pieces that are product contract.

What it does

When a deal’s dealstage changes to one of a configured set of “trigger” stages, the connector should call an external Cloud Function to generate an implementation document, then write the returned URL back onto the deal. Whether to call the Cloud Function, and what to send it, is imperative logic (HTTP call, one-document-per-deal idempotency) — everything else (the trigger gate, fetching the associated company, writing the result back to HubSpot) is declarative core.

HubSpot journal (deal updated) | v gate_stage: skip the flow unless dealstage is a trigger stage [core: skip + when] | v find_company: traverse deal -> company, hydrate company properties [core: find_associated] | v generate_doc: idempotency check + POST to the Cloud Function [PLUGIN] | v write_doc_url: PATCH the deal's doc_url property [core: update_by_id]

The manifest side (core, declarative)

{ "name": "hubspot_deal_generate_doc", "source": "hubspot_journal", "event_type": "0-3.update", "steps": [ {"id": "gate_stage", "action": "skip", "when": {"not": {"in": {"$.payload.object.properties.dealstage": ["stage_preparazione_offerta", "stage_negoziazione", "stage_attivita_legal_in_corso"]}}}}, {"id": "find_company", "action": "find_associated", "entity": "deal", "source": "$.payload.object.id", "associations": [{"entity": "company", "as": "company", "cardinality": "one", "properties": ["name", "hs_tax_id", "domain", "city", "country"]}]}, {"id": "generate_doc", "action": "plugin", "plugin": {"logical_name": "acme_doc_generator", "config": {"store_keys": ["$.payload.object.id"], "doc_type": "implementazione_hubspot"}}}, {"id": "write_doc_url", "action": "update_by_id", "target": "hubspot", "entity": "deal", "object_id": "$.payload.object.id", "data": {"doc_url": "$generate_doc.doc_url"}} ] }

Note the HubSpot write (write_doc_url) is a core step, not something the plugin does itself — the plugin only returns doc_url; core performs the CRM write. This is the manifest/plugin split in practice, and it’s also why the plugin never needs a HubSpot credential.

The outbound source

{"name": "doc_generator", "type": "rest", "config": {"outbound": {"base_url": "https://generate-doc.example.cloudfunctions.net", "credential_name": "doc_generator_token", "auth_type": "bearer"}}}

A normal outbound EventSource, exactly like any target a post step would use. The plugin never sees doc_generator_token’s raw value directly — it only sees the pre-resolved Authorization: Bearer <token> header, and only because of the auth capability below.

The plugin’s capabilities

{"http": ["generate-doc.example.cloudfunctions.net"], "store": ["read", "write"], "auth": ["doc_generator"]}
  • http: the plugin bundle calls fetch(baseUrl, ...) where baseUrl comes from sources_index, not a literal in the bundle — so the push-time linter has no URL literal to check here and the entry is declarative: it records the host the plugin is meant to reach for the promotion reviewer, not something the runtime enforces.
  • store: read to check for an existing doc_url before calling the Cloud Function again; write to record one after a successful call.
  • auth: doc_generator only — the plugin can build an Authorization header for that one outbound target, never for anything else, and never for HubSpot.

The plugin source

exports.handler = async (event) => { const deal = (event.payload || {}).object || {}; const dealId = String(deal.id || ""); if (!dealId) { throw new Error("generate-doc plugin: payload.object.id missing"); } const store = event.store || {}; const existing = store[dealId]; if (existing && existing.doc_url) { return { action: "skip", reason: "doc already generated for deal " + dealId }; } const src = ((event.sources_index || {}).doc_generator || {}).config || {}; const baseUrl = (src.outbound || {}).base_url; if (!baseUrl) { throw new Error("generate-doc plugin: doc_generator outbound.base_url not configured"); } const headers = Object.assign( { "content-type": "application/json" }, (event.auth || {}).doc_generator || {} ); const companyBlob = (event.outputs || {}).find_company || {}; const company = companyBlob.company || null; const resp = await fetch(baseUrl, { method: "POST", headers, body: JSON.stringify({ deal: { properties: deal.properties || {} }, company: { properties: (company && company.properties) || {} }, doc_type: (event.config || {}).doc_type || "implementazione_hubspot", }), }); if (resp.status === 429 || resp.status >= 500) { return { action: "retry", reason: "generate-doc HTTP " + resp.status }; } if (!resp.ok) { throw new Error("generate-doc failed: HTTP " + resp.status); } const data = await resp.json(); return { action: "continue", doc_url: data.url, store_writes: [{ key: dealId, value: { doc_url: data.url } }], }; };

Walking through it against payload-contract.md:

  • Idempotency: event.store[dealId] was pre-loaded because plugin.config.store_keys resolves $.payload.object.id to the deal id, and the plugin holds store: ["read"]. If a doc already exists for this deal, the handler returns action: "skip" — the flow halts here, write_doc_url never runs, and a redelivered event (delivery is at-least-once) does not regenerate or re-POST the write.
  • Target URL: read from event.sources_index.doc_generator.config.outbound.base_url — config, not a secret, always present regardless of capabilities.
  • Auth header: event.auth.doc_generator — present only because the plugin holds auth: ["doc_generator"]; this is the only header the plugin ever sees.
  • Company data: event.outputs.find_company.company — the output of the earlier find_company core step, read the same way a manifest step would with $find_company.company.
  • config.doc_type: event.config.doc_type — the authored step’s static setting, passed straight through.
  • Transient vs permanent failure: HTTP 429/5xx maps to action: "retry", so the event is retried; any other non-OK response throws, which Lambda reports as a FunctionError and the runtime maps to a permanent failure — the event is not retried and the record goes terminal as failed. These are 4xx-shaped failures the Cloud Function isn’t going to resolve on its own.
  • Success: returns doc_url at the top level (not wrapped in a data key — there is no required wrapper), plus store_writes to persist the idempotency marker. The marker has no ttl_seconds, so it is kept for the life of the connector (see store_writes). write_doc_url’s manifest step reads it back as $generate_doc.doc_url.

Where the source of truth lives

The authoritative copy of a plugin’s source is whatever you last pushed to plugsync — never a file in a git repository. The connector’s workspace pushes it with plugsync plugin push, a convenience wrapper over POST /api/plugins followed by POST /api/plugins/{id}/push. The CLI always bundles src/index.ts with esbuild and sends the result as bundle, and the text of src/index.ts as source_code. A handler written in plain JS goes through the same path: put it in src/index.ts, which esbuild accepts as is.

Cutover status

Standing this flow up in an environment takes more than the manifest: a real Cloud Function URL and doc_generator_token credential, real HubSpot pipeline stage IDs in the trigger gate, a provisioned doc_url custom property on deals, and the plugin promoted to live_version for a live connector (or a test connector created by the same user who pushed the plugin). Whether any given environment has completed that is independent of this guide.

See also

Last updated on