Case study · AI agents × GTM systems

Teaching an AI agent to operate HubSpot

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.

Role · Sole designer & engineer Stack · TypeScript, MCP, HubSpot REST v3/v4 Surface · 24 tools across workflows, lists, CRM

The problem

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.

The trap: HubSpot's automation API will return 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 core idea: verify-after-write

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.

Four more decisions that made it safe

Unambiguous targeting

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.

Encapsulated read-modify-write

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.

Distinguished failure modes

"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.

Raw filters, no leaky DSL

Workflow enrollment, goals, and list filters share one nested AND/OR filterBranch shape. Tools pass it through — full HubSpot expressiveness, no reinvented query language.

What it looks like in use

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.

The most useful thing I learned

Validate a filter against a list, not the workflow. Because the flows API hides invalid filters, the reliable check is to create a throwaway dynamic list with the same branch: a list returns precise per-filter validation errors and a real member count. Confirm the count is sane, apply the branch to the workflow, delete the probe list. Cheap list create/get/delete tools exist largely to make that loop fast.

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.

An operator pattern, not a one-off

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:

Salesforce Operator · 7 tools

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.

Outreach Operator · 5 tools

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.

At a glance

24
tools across 3 domains
100%
of writes verify by readback
3
GTM operators on one pattern
TypeScript (strict, ESM) Model Context Protocol HubSpot REST v3/v4 Zod tool schemas Verify-after-write

Takeaway

The 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.

The public demo is a sanitized, standalone reconstruction of an internal tool I designed and ran in production. All organization-specific data — real object IDs, audience definitions, and credentials — has been removed; the demo targets HubSpot's standard objects against your own test portal. It builds, boots, and registers all 24 tools.