HubSpot MCP Agent with Slack Approval
by rubianaseem
README.md
# HubSpot MCP Agent with Slack Approval
A Claude agent that researches accounts through an MCP server, drafts HubSpot updates, and cannot write a single field until a named person clicks Approve in Slack on the exact change they were shown.
Built by Rubia Naseem, GTM Engineer (12+ yrs CRM/RevOps, HubSpot & Salesforce).
## The problem
Connecting Claude to a CRM through MCP is easy. Making it safe is the part most demos skip. "The agent asks before it writes" usually means a confirmation dialog in the chat client, and the MCP spec says clients "MUST consider tool annotations to be untrusted unless they come from trusted servers", and there "SHOULD always be a human in the loop with the ability to deny tool invocations" ([MCP tools spec](https://modelcontextprotocol.io/specification/2025-06-18/server/tools)).
In RevOps the failures are specific. A scraped web page tells the agent to change the owner. Someone approves Tuesday's draft and Wednesday's regenerated version goes in. A rep edits the record between approval and write. A 429 retry creates the same note twice. I've built signal-triggered routing in production, and these details decide whether anyone trusts the automation. So here the approval lives on the server, not in the chat window.
## What it does
- Exposes seven MCP tools for reading, drafting, requesting approval and applying. There is no approve tool, so the model cannot approve its own work.
- Scores buying signals, picks a play and drafts a property update, note and owner task. Optional Claude classifies messy signal text, and every quote it uses is checked against the source.
- Quarantines signal text that reads like instructions, redacts emails, and refuses any property outside a five-field allowlist.
- Posts a Slack Block Kit message whose button carries the proposal's SHA-256 content hash. Clicks are checked against Slack's signing secret, a 5-minute replay window and per-approver permissions.
- Releases approved changes with idempotency keys, Retry-After handling, backoff, stale-record checks and crash resume, all logged to a hash-chained audit file.
## Where each step runs
1. GitHub stores the code. You run it from Terminal, Mac Terminal or the Cursor terminal.
2. `agent.py draft` starts the agent, which talks to the MCP server (`mcp_server.py`). With `ANTHROPIC_API_KEY` set, Claude drives that tool loop. Without it, a scripted loop calls the same tools.
3. Approval requests go to a local Slack outbox by default, or to your Slack channel with `--live-slack`.
4. `slack_server.py` receives the button click and records the decision. It never writes to the CRM.
5. A person runs `agent.py apply --proposal ...` to release one approved proposal. Today that write goes to a mock HubSpot file (`state/crm.json`), not a real portal.
## Guardrails and human in the loop
- Planning, approval and execution are three separate steps, and only the last one writes.
- An approval binds to the content hash and record version. If anything changes, the old button stops working.
- Demo approvals are simulated, signed clicks on synthetic data. They are not permission to touch a real CRM or send anything.
## Quick start
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python run_demo.py # full scenario, writes outputs/
python run_evals.py # evaluation report, writes evals/report.md
python -m unittest discover -s tests -t .
```
No API key needed. See [SETUP.md](SETUP.md) for Claude, Slack and Claude Code setup.
## Example output
From `python run_demo.py` (synthetic accounts):
```
Approval clicks
sales manager approves a lifecycle change C-1001 denied approver_lacks_permission:lifecyclestage
RevOps admin approves C-1001 approved recorded
forged signature C-1003 denied bad_signature
button from an older draft (hash mismatch) C-1003 denied hash_mismatch
Writes
crash after property write P-C-1001-3f5cfea6 crashed left APPLYING
resume after crash P-C-1001-3f5cfea6 applied ok
record edited after approval P-C-1005-c6bae183 stale stale_record
```
## Evaluation (measured locally, synthetic data)
`python run_evals.py` scores 24 hand-labelled accounts (34 signals) and an 18-scenario attack and failure suite, against the same keyword rules without guardrails:
| Measure | Baseline | This agent |
|---|---|---|
| Play accuracy | 0.75 | 0.875 |
| Injection signals caught | 0 of 6 | 5 of 6 |
| Injected text reaching a proposal | 6 | 1 |
| Emails reaching a proposal | 1 | 0 |
| Unsafe writes, 13 attack scenarios | 11 (gate that only checks for a click) | 0 |
The injection that gets through is a paraphrase held out on purpose. This is my own small labelled set, not production data. Full report: [evals/report.md](evals/report.md).
## How this runs today (and what production would add)
Implemented and tested locally: MCP server (in-memory and stdio), approval gate, Slack signature handling, executor, audit log and evals. Implemented but not tested against a live workspace: Slack posting with `--live-slack`. Not built: a real HubSpot client, queue workers and hosting. See [docs/DETAILS.md](docs/DETAILS.md).
## More
- [Architecture and data contracts](docs/ARCHITECTURE.md)
- [Walk through it line by line](docs/WALKTHROUGH.md)
- [Sources and inspiration](docs/SOURCES.md)
- Related: [n8n-error-handling-orchestration](https://github.com/rubianaseem/n8n-error-handling-orchestration) (retry patterns), [forecast-commit-hygiene-agent](https://github.com/rubianaseem/forecast-commit-hygiene-agent), [gtm-engineering-q1-showcase](https://github.com/rubianaseem/gtm-engineering-q1-showcase)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo recent community issues