I built a Model Context Protocol (MCP) server that lets an AI agent author HubSpot automation end-to-end — create a workflow, define who gets enrolled, wire the steps, set the conversion goal, and build the matching audience — through tool calls, with every mutation verified by reading state back before it claims success.
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.
So the question wasn't "can you call the HubSpot API from code?" — anyone can. 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.
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.
That insight only comes from running the thing in production and watching a "successful" workflow enroll zero contacts — which 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 isn't the API calls — it's 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.