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

Role · Sole designer & engineer Stack · TypeScript, MCP, HubSpot REST v3/v4 Surface · 24 tools across workflows, lists, CRM
View the code on GitHub →

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.

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.

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 product case

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:

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.

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 tool surface

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.searchList / find by ID, exact name, or partial query
workflows.getFull flow detail
workflows.create_manualCreate an empty, disabled flow shell
workflows.set_enrollment_criteriaSet who gets enrolled (raw enrollmentCriteria branch)
workflows.set_actionsReplace the action graph; auto-derives startActionId
workflows.set_goal_criteriaSet the conversion goal (goalFilterBranch)
workflows.rename / set_enabled / deleteLifecycle, by ID or exact name
workflows.clone_basicCopy criteria + steps into a new disabled flow
workflows.add_go_to_workflow_stepAppend a cross-workflow jump (flags unsupported_via_api)
Lists · crm/v3/lists
lists.search / lists.getFind lists; get reads the filterBranch back
lists.createDynamic (or manual / snapshot) list from a raw filterBranch
lists.update_filtersReplace a dynamic list's filters in place
lists.rename / lists.deleteLifecycle; delete is a verified soft-delete
lists.members.list / add / removeManual membership, verified per record
CRM · crm/v3/objects
crm.searchQuery / by-ID / structured filterGroups; count:true returns only the total
crm.getOne record with optional properties + associations
crm.update_propertiesPatch a record, verified by readback
crm.associations.getAssociated records of another object type

How it's put together

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

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. That 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
6 figures
saved annually in production
TypeScript (strict, ESM) Model Context Protocol HubSpot REST v3/v4 Zod tool schemas Verify-after-write

Takeaway

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

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.