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 callsfetch(baseUrl, ...)wherebaseUrlcomes fromsources_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 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 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 (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 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", so the event is retried; any other non-OK response throws, which Lambda reports as aFunctionErrorand the runtime maps to a permanent failure — the event is not retried and the record goes terminal asfailed. 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. The marker has nottl_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.