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 callsfetch(baseUrl, ...)wherebaseUrlcomes fromsources_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 existingdoc_urlbefore calling the Cloud Function again; write to record one after a successful call.auth:doc_generatoronly — the plugin can build anAuthorizationheader 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 becauseplugin.config.store_keysresolves$.payload.object.idto the deal id, and the plugin holdsstore: ["read"]. If a doc already exists for this deal, the handler returnsaction: "skip"— the flow halts here,write_doc_urlnever 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 holdsauth: ["doc_generator"]; this is the only header the plugin ever sees. - Company data:
event.outputs.find_company.company— the output of the earlierfind_companycore 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 aFunctionErrorand 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_urlat the top level (not wrapped in adatakey — there is no required wrapper), plusstore_writesto 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.