Typed CRM reads: search cannot count
v2.15.0 gives agents three structured HubSpot read tools — one per object type — with exact COUNT(*) totals, discoverable facets, explicit pagination, and honest refusals when a field isn't synced.
Ask an agent "how many open deals do we have?" and, until now, the only place it could look was
semantic search. Semantic search answers "what was said" — it returns a relevance top-k, capped at
15 hits. Any count built on top of that is an inference dressed as a count: the agent sees a page
of the most relevant deals, adds them up, and reports a number that was never a total. v2.15.0
splits the read path. search_knowledge keeps "what was said"; three new typed tools answer
"how many" and "which ones" in SQL over the synced HubSpot mirror.
What's new
One tool per object type, so a model choosing a tool by name chooses correctly — the previous single structured tool spanned contacts, deals, and companies under a contacts name.
| Tool | Answers | Filters and facets |
|---|---|---|
get_hubspot_contacts | People — "how many MQLs?", "is X in the CRM?" | lifecycle stage |
get_hubspot_deals | Pipeline — counts, open vs closed, deal value | stage, pipeline, status: open/closed |
get_hubspot_companies | Accounts — industry, domain, size | industry |
Every response leads with an exact total from COUNT(*), independent of the page. Facets are
computed before value filters, so the real filter values stay discoverable — the agent reads
facets.lifecycleStage to learn that the stage is spelled salesqualifiedlead instead of
guessing. Pagination is explicit, with a next_offset and a reminder to report the total, never
the page size. Deals also return total_amount and per-stage facet_amounts, so pipeline value
by stage is one call — no paging through records adding amounts up.
Two refusal paths matter as much as the answers. A filter value absent from the data returns no
count at all — only the value it couldn't find and the real distribution — because returning 0
for a mistyped stage is how a wrong number gets reported as fact. And any field the mirror doesn't
carry is named in unavailable_fields, so a missing column produces an honest "that field isn't
synced" instead of a plausible number.
Why we built it
There is deliberately no live-API read: the mirror is the single source of truth, because two read paths means two answers to one question. Open vs closed is resolved from HubSpot's pipeline definitions, not stage names — custom pipelines use opaque numeric stage ids, so name-matching heuristics get it wrong. And the tools are source-gated, not grant-gated: they build for any agent whose connected sources include HubSpot, so an agent without CRM access has no CRM tools to hallucinate a call to. The grant gate stays where the privacy guarantee lives, on transcript reading.
Where to find it
Pull v2.15.0 and run migrations 0054–0055 (partial indexes that keep every call under 55ms
on a real mirror). The tools appear automatically for HubSpot-connected agents. And the very next
release, v2.16.0 — already covered in Connect to Vocion over MCP — serves these same typed
reads over MCP, so any MCP client can call them under the same gates.