The gate belongs inside the tool: giving an AI agent write access to a production CRM

By Izzy Aly, Co-Founder, Systems and Architecture


An AI agent will eventually try something dumb at scale. The gate design that lets me write to HubSpot anyway: previews, typed record counts, and undo.

I spent the last few months building a Claude Code plugin that runs HubSpot in plain English. You type "find duplicate contacts and merge them," and it routes the request to one of 44 specialist sub-agents, each scoped to one corner of the CRM: contacts, deals, workflows, hygiene, and so on. It ships with 79 tools, and it is open source under MIT.

The AI part turned out to be the easy half. The hard half was a design question: how do you give an agent write access to a production CRM when you know it will eventually try to do something dumb at scale?

My answer became one rule. The agent does the work, and a person approves every single change before it lands. The part I want to defend in this article is where that gate lives: inside the tool itself, on every write, with no way around it. Not in a flag. Not in a client setting. In the tool.

Update, October 2026: since version 0.2.8 (22 July 2026), the plugin can also auto-apply low-risk writes that are reversible and stay under a configurable record ceiling (100 by default), each with an undo command. Destructive, over-ceiling, non-undoable, sensitive-field and workflow writes still stop at the gate this article describes. Version 0.2.12, the same day, added pattern approval: you approve one rule, and it applies record by record with a compare-and-set check that skips any record that changed since the preview.

Why optional safety fails

HubSpot ships its own agent tooling, and I used it properly before building mine. The Agent CLI, in public beta since June 2026 and still in beta as of October 2026, has a preview. It is a --dry-run flag you add to each command. Forget the flag and most writes just run; only some destructive commands demand a dry-run safety digest first. HubSpot's own beta warning says that running it against a production account means you accept the risk of unintended changes, including data loss.

Their remote MCP server puts approval somewhere else: in the client. In Claude, for example, you can set write tools to require approval. You can also set them to always allow. And once someone flips that switch for convenience, every write runs unchecked. Most users, me included, click yes on permission dialogs until the dialogs stop appearing.

Both designs share the same assumption: the human will remember to be careful. I built on the opposite assumption. The tool should stay safe even when the human is tired, distracted, or has stopped reading the prompts.

Three places an approval gate can live for agent writes to HubSpot Three columns. HubSpot's Agent CLI puts the gate in an opt-in --dry-run flag per command; forget the flag and most writes run. HubSpot's remote MCP server puts approval in a client setting; flip it to always allow and every write runs unchecked. This plugin puts the gate inside the tool on every write; no flag or client setting can skip it, so destructive, over-ceiling and sensitive writes always need a person's approval. The first two assume the human remembers to be careful; the third does not. WHERE THE APPROVAL GATE LIVES Agent CLI THE GATE --dry-run flag, opt-in per command SKIPPED BY forgetting the flag Most writes run anyway Remote MCP THE GATE approval setting in the client SKIPPED BY flipping it to “always allow” Every write runs unchecked This plugin THE GATE inside the tool, on every write SKIPPED BY nothing: no flag, no client setting Risky writes always stop here The first two assume the human remembers to be careful. The third doesn’t.
Three places an approval gate can live for agent writes to HubSpot Three rows. HubSpot's Agent CLI puts the gate in an opt-in --dry-run flag per command; forget the flag and most writes run. HubSpot's remote MCP server puts approval in a client setting; flip it to always allow and every write runs unchecked. This plugin puts the gate inside the tool on every write; no flag or client setting can skip it, so destructive, over-ceiling and sensitive writes always need a person's approval. Agent CLI most writes run Gate: --dry-run flag, per command Skipped: forget the flag Remote MCP all writes run Gate: a setting in the client Skipped: flip to “always allow” This plugin risky writes gated Gate: inside the tool, every write No flag or setting can skip it Only the third holds when the human is tired.
Fig. 1 — Where the gate lives decides whether a tired human can skip it. Source: HubSpot Agent CLI guide.

What a write actually looks like

In my plugin, no tool call mutates the CRM. A write tool returns a preview of the affected records plus an action_id. That is all it can do. The actual mutation happens in a separate step, when the user runs approve <action_id>. Until then, nothing has touched HubSpot.

One complication: HubSpot has no dry-run API. So previews are built from reads. Before proposing an update, the tool fetches the records it would change and shows you their current state next to the proposed change. It is not a simulation of the write. It is a snapshot of what the writer would hit.

That distinction matters, and I will come back to it in the section about weaknesses.

The count re-check

For destructive operations, approval alone is not enough. You have to type the number of records you expect the operation to touch:

approve a1b2c3 3

The tool then re-counts the affected records at execution time and compares. If the preview said 3 and execution would now hit 300, the operation refuses to run.

This exists because of the gap between preview and approval. CRM data changes constantly. A workflow fires, a colleague imports a list, and a form submission creates contacts. A filter that matched 3 records when you previewed it can match 300 by the time you approve. The typed count is the user's statement of intent, and the re-check verifies that reality still matches it at the moment of execution.

It also defends against a lazier failure: approving without reading. Typing a number forces you to have understood the preview, at least enough to know its size.

The lifecycle of one write, from tool call to mutation Four steps left to right. The write tool reads the target records, because HubSpot has no dry-run API, and returns a preview and an action_id without mutating anything. A person runs approve with the action_id; destructive operations also need a typed record count. At execution the tool re-counts the records and compares; if the count changed, for example from 3 to 300, it refuses. Otherwise it writes an undo snapshot, runs the mutation and adds an audit entry. Known gap: if fields change but the count stays the same, the re-check does not catch it, because HubSpot's API offers no locks. PREVIEW APPROVAL RE-CHECK EXECUTION 01 Write tool reads records, returns preview + action_id 02 Approve approve <id>; destructive: type the count 03 Re-count at execution, against the typed count 04 Write lands undo snapshot, mutation, audit entry count changed (3 → 300): refused Known gap: same count, changed fields Field changes between preview and execute are not caught: no API locks.
The lifecycle of one write, from tool call to mutation Four steps top to bottom. The write tool reads the target records and returns a preview and an action_id without mutating anything. A person approves the action_id; destructive operations also need a typed record count. At execution the tool re-counts and refuses if the count changed, for example from 3 to 300. Otherwise it writes an undo snapshot, runs the mutation and adds an audit entry. Known gap: same count with changed fields is not caught, because HubSpot's API has no locks. 01 Write tool preview + action_id 02 Approve id + typed count 03 Re-count 3 → 300: refused 04 Write lands undo + audit log Known gap: same count, changed fields HubSpot’s API has no locks.
Fig. 2 — One write, from tool call to mutation. The 3-to-300 refusal is illustrative.

Undo and audit

Every approved create, update, or delete writes two things: an undo snapshot of the prior state before it executes, and an entry in an audit log once it lands. If a create or update turns out to be wrong, you can reverse it. Deletes and merges are flagged as non-undoable in the preview, before you approve them. If someone asks what the agent did last Tuesday, the log answers.

The audit log turned out to be more useful than I expected. When an agent handles a fuzzy request like "clean up stale deals," the log becomes a record of every judgment call it made. You can review those decisions after the fact and turn the good ones into rules.

One handler set, three call paths

The architecture behind this is deliberately boring. Claude orchestrates. The sub-agents are stateless and call a single CLI over Bash. For speed, most calls route through a warm daemon that reuses one HTTP client and a schema cache. If the daemon is unreachable, the call falls back to an in-process path. There is also a plain synchronous CLI path.

Three call paths, and all three share one handler module. The approve-to-execute contract, meaning the destructive count gate, the undo snapshot, the audit entry, and the clearing of the pending action, is the same code everywhere.

This was the design decision I was most stubborn about. Safety logic that exists in more than one place will diverge. Someone fixes a bug in the daemon path and forgets the fallback path, and now your gate has a hole that only opens when the daemon is down. One handler set makes that class of bug impossible.

One handler set, three call paths Claude orchestrates and hands work to 44 stateless sub-agents, which call a single CLI over Bash. The call takes one of three paths: a warm daemon with a shared HTTP client and schema cache, the default for speed; an in-process fallback when the daemon is unreachable; or a plain synchronous CLI. All three converge on one handler module holding the approve-to-execute contract (the destructive count gate, the undo snapshot, the audit entry and clearing the pending action) before anything reaches the HubSpot API. Claude orchestrates 44 sub-agents stateless One CLI called over Bash Warm daemon default, cached In-process if the daemon is down Sync CLI plain path One handler module count gate · undo snapshot · audit entry · clear pending action HubSpot API Every path runs the same approve-to-execute code.
One handler set, three call paths Claude orchestrates 44 stateless sub-agents, which call a single CLI over Bash. The call takes one of three paths: a warm daemon, the cached default; an in-process fallback when the daemon is down; or a plain synchronous CLI. All three converge on one handler module holding the count gate, undo snapshot, audit entry and clearing of the pending action, before anything reaches the HubSpot API. Claude → 44 sub-agents stateless, one CLI over Bash Warm daemon default, cached In-process daemon down Sync CLI plain path One handler module count gate, undo snapshot, audit entry HubSpot API
Fig. 3 — Three call paths, one copy of the safety code.

Where it still leaks

I said I would come back to the previous weakness. A read-based preview is not a transaction. The count re-check catches size drift between preview and execute. It does not catch content drift. If a record's fields change in that window but the count stays the same, the operation runs against data you did not see. I do not have a fix for this that does not require locking, and HubSpot's API does not offer locks. It is a documented limitation, not a solved problem. Since 0.2.12, pattern approvals (and, since 0.2.13, scheduled batches) re-read each record and skip any that drifted, using compare-and-set rather than a lock; a standard single approval still does not.

Second leak: the install hook that provisions the plugin's Python environment never blocks the session, which means it also fails silently. A broken install looks fine until the first command errors. There is a log file, but you have to know where to check it.

And the whole thing is a beta. The test suite has more than 1,100 passing tests, but I am still finding rough edges.

One more honest thing

Agents wrote most of this code. My job was to define what safe means, review what came back, and refuse to ship anything I could not defend. I have been doing HubSpot implementations for over a decade, and that depth is exactly what made the review worth something. But the leverage has changed. One person who knows a domain deeply can now ship what used to take a team.

Try to break it

The repo is at github.com/pronovahq/hubspot-claude. There is also a sister MCP server, hubspot-mcp, which carries the same approval model into any MCP client.

If you run HubSpot, try it against a sandbox or a free developer test portal, never your live account. You do not even need real data. Ask it to seed the test portal with sample records and let it work against that.

And if you think the gate design is wrong, I want to hear it. Is a typed count real friction or theater? Would you have put the approval in the client instead? Tell me where I'm wrong in the repo's issues.

Topics
Author
Izzy Aly · Co-Founder, Systems and Architecture
Share