Skip to Content
ReferencePluginsWalkthrough: generate-doc

Walkthrough: the Compass generate-doc plugin

This is a real plugin from a real connector: compass_doc_generator, part of the Compass <-> HubSpot connector’s deal flow (issue #484, shipped in PR #549). It is the first production consumer of the Lambda plugin runtime, and every generic gap in LambdaPluginInvoker that it needed (store read/write, scoped auth, config passthrough) was built against this real use case rather than hardcoded for it. The connector’s own configuration lives in its customer workspace (plugsync pull), not in this repository; 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": "compass_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 from ADR-0001 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 — but the linter still needs the actual resolved host declared, or the push is rejected.
  • 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 by the worker 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 (SQS 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" (SQS redrive); any other non-OK response throws, which Lambda reports as a FunctionError and the runtime maps to a non-retryable failure (DLQ) — 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. write_doc_url’s manifest step reads it back as $generate_doc.doc_url.

Where the source of truth lives

Per ADR-0003, the authoritative copy of a plugin’s source is whatever is currently stored on its Plugin row in PostgreSQL — never a file in a git repository. The connector’s workspace pushes it there with plugsync plugin push, which is a convenience wrapper over POST /api/plugins followed by POST /api/plugins/{id}/push, sending the JS as both bundle and source_code (plain JS needs no esbuild step, so the two are byte-identical).

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, the plugin promoted to live_version for a live connector (or a test connector with created_by_id set), the Lambda execution role present in the target AWS account, and PLUGSYNC_PLUGIN_RUNTIME=lambda flipped for that environment’s flow_worker. See PR #549 for the complete checklist; whether any given environment has completed it is independent of this guide.

See also

Last updated on