I built a Model Context Protocol (MCP) server that lets an AI agent author HubSpot automation end-to-end through tool calls: create a workflow, define who gets enrolled, wire the steps, set the conversion goal, and build the matching audience. Every mutation is verified by reading state back before it claims success.
View the code on GitHub →A marketing-ops team's recurring work is building workflows: enroll this segment, wait, send this email, branch on behavior, mark them converted. In HubSpot that's a point-and-click job: slow, manual, and exactly the sort of structured, schema-heavy task an AI agent should be able to take on.
The question wasn't whether you can call the HubSpot API from code. It was what would make these operations safe to hand to a model that acts on its own. That turned out to be the entire engineering problem.
200 OK for an
invalid workflow filter, then silently store it as "always false." The workflow
enrolls nobody, raises no error, and looks fine. A status code is not proof that anything happened.
The problem owner was the marketing team: lifecycle workflows, audience segments, and campaign infrastructure are recurring, schema-heavy work, and all of it queued behind the ops seat. The recurring cost was easy to size (it showed up every week), and the blast radius of getting it wrong (a workflow that silently enrolls nobody, or the wrong ten thousand people) is what kept it manual.
Alternatives I rejected:
200 OK. The trap below is why that's not a product.The bet was that the trust layer was the product: make each operation safe enough to run unattended and the agent absorbs the recurring work, not just one task. Scope followed the same logic: workflows first (the recurring job), lists second (they validate the filters workflows can't), CRM reads third (they size audiences before anything sends). Each domain shipped with the reads an agent needs to verify its own writes.
Success was measured in work absorbed: the operators ran in production automating campaign creation and system administration for the marketing team, saving six figures a year in ops cost. The pattern proved portable enough to extend to two more systems of record.
Every tool, read or write, returns one envelope shape. The part that matters is the
audit block, and specifically audit.verified:
{
"ok": true,
"operation": "workflows.set_enrollment_criteria",
"data": { /* request echo + the re-fetched object */ },
"audit": {
"attempted": true,
"verified": true, // re-read the object and confirmed the change
"targetId": "1234567890",
"targetName": "Trial → Activation Nurture"
}
}
A write tool performs its mutation and then re-fetches the object to confirm the change
actually landed before reporting success. Creates read the new record back; deletes
re-list to confirm the object is gone; list-membership writes re-read each record's
memberships. The agent branches on audit.verified, never on a bare HTTP status.
This single convention is what makes the tools trustworthy enough to run unattended.
Mutations resolve a workflow by ID or exact name only. No fuzzy match ever selects a flow to change; ambiguity returns the candidate list and refuses to act.
The v4 flows API makes you PUT the whole flow to change one field. Sanitizer helpers strip server-managed fields so a single-field edit round-trips the entire flow safely.
"Bad request" and "this portal can't do this via the API" are different signals.
Unsupported steps return unsupported_via_api, not a generic error.
Workflow enrollment, goals, and list filters share one nested AND/OR
filterBranch shape. Tools pass it through: full HubSpot expressiveness, no
reinvented query language.
One prompt ("enroll un-activated trial contacts into a 3-step nurture and mark them converted when they activate") becomes a sequence of verifiable tool calls:
# size the audience before building anything crm.search { objectType:"contacts", count:true, filterGroups:[…] } → total: 4,812 # build the flow incrementally, each step verified workflows.create_manual { name:"Trial → Activation Nurture" } → verified workflows.set_enrollment_criteria{ workflowId, enrollmentCriteria:[…] } → verified workflows.set_actions { workflowId, actions:[email,delay,email…] }→ verified workflows.set_goal_criteria { workflowId, goalFilterBranch:[…] } → verified workflows.set_enabled { workflowId, isEnabled:true } → verified
Because each step is re-read from HubSpot, the agent can detect the "always false" trap before turning the workflow on, the failure mode that's otherwise invisible until a campaign sends to nobody.
24 tools across three domains, every one returning the same envelope. Coverage isn't the point: each domain ships the reads an agent needs to verify its own writes.
| Workflows · automation/v4/flows | |
|---|---|
| workflows.search | List / find by ID, exact name, or partial query |
| workflows.get | Full flow detail |
| workflows.create_manual | Create an empty, disabled flow shell |
| workflows.set_enrollment_criteria | Set who gets enrolled (raw enrollmentCriteria branch) |
| workflows.set_actions | Replace the action graph; auto-derives startActionId |
| workflows.set_goal_criteria | Set the conversion goal (goalFilterBranch) |
| workflows.rename / set_enabled / delete | Lifecycle, by ID or exact name |
| workflows.clone_basic | Copy criteria + steps into a new disabled flow |
| workflows.add_go_to_workflow_step | Append a cross-workflow jump (flags unsupported_via_api) |
| Lists · crm/v3/lists | |
| lists.search / lists.get | Find lists; get reads the filterBranch back |
| lists.create | Dynamic (or manual / snapshot) list from a raw filterBranch |
| lists.update_filters | Replace a dynamic list's filters in place |
| lists.rename / lists.delete | Lifecycle; delete is a verified soft-delete |
| lists.members.list / add / remove | Manual membership, verified per record |
| CRM · crm/v3/objects | |
| crm.search | Query / by-ID / structured filterGroups; count:true returns only the total |
| crm.get | One record with optional properties + associations |
| crm.update_properties | Patch a record, verified by readback |
| crm.associations.get | Associated records of another object type |
Every HubSpot request flows through a single fetch choke point with typed errors, every tool renders the same envelope, and the sanitizers that make read-modify-write safe live in one place. Small enough to audit in a sitting:
src/ server.ts MCP bootstrap: registers all 24 tools, one envelope renderer hubspot.ts the single fetch choke point + typed HubSpotApiError config.ts .env loading + requireEnv types.ts ToolEnvelope / ToolAudit, the shared contract utils.ts envelopes, exact-match targeting, read-modify-write sanitizers workflows.ts the 11 flow tools (the centerpiece) lists.ts the 9 list tools, incl. per-record membership verification crm.ts the 4 CRM tools, incl. count-mode search
That insight only comes from running the thing in production and watching a "successful" workflow enroll zero contacts. That is the difference between a wrapper and an operator.
HubSpot was the first system, not the last. The same contract (verify-after-write auditing, exact-match targeting, honest failure signals) now runs against two more systems of record in the GTM stack, both in production:
Operates the Analytics reporting surface: listing, running, and updating reports, report types, and folders, with new reports created by cloning a known-good template and patching its metadata. No delete tool by design; every write re-reads the report metadata and compares field by field before claiming success. Shipped real deliverables: campaign-member exports, a user-audit workaround, partner-overlap reports.
The hard-won lesson inverted HubSpot's: Salesforce's reporting API rejects whole report types outright and caps filters near 2,200 characters, so the agent builds by cloning known-good templates and chunking its ID filters. Loud arbitrary failure instead of silent success.
Operates sequences and prospects at the sequence-state level: a guarded generic query across the core objects, prospect lookup by email with owner and opt-out checks, enrollment against a mailbox, and finish/pause/resume transitions. It grew in production: v1 shipped read-only to diagnose why a sequence wasn't enrolling, and once the root cause was found, write tools were added under a scope re-auth and repaired the gap live.
The hard-won lesson here was auth, not API shape: Outreach rotates its refresh token on every refresh, single-use, so the server must persist each newly minted token or permanently lock itself out. Sequence states also have no PATCH; changing one means dedicated action endpoints, exactly the sharp edge the tool layer exists to encapsulate.
Three systems in, the pattern holds: the API wrapper is the easy 20%. The trust layer is the product.
filterGroups search, count-only mode, verified property writesThe interesting work in agent tooling is the trust layer: verification, unambiguous targeting, honest failure signals, and encapsulating the API's sharp edges so a model can't fall on them. Build those in and an agent can do real, consequential work in a system of record without a human double-checking every step.