Leads → HubSpot
Two contenox flows chained: Tavily MCP finds fresh leads on the web, then a narrow OpenAPI sub-spec writes them into HubSpot — companies, contacts, descriptions, associations.
End state: 5 real B2B SaaS leads, 5 HubSpot companies, 8 contacts, ~30 seconds for the CRM write step. Your tokens stay on your machine, your LLM is whichever backend you've configured.
The recipe also doubles as a tour of two different contenox tool-integration patterns:
- Part 1 uses Tavily's hosted MCP server (RFC 7591 dynamic OAuth — runtime auths in a browser, no manual app config)
- Part 2 uses a hand-curated OpenAPI sub-spec against HubSpot's REST API (bearer token, no MCP needed)
Prerequisites
- A configured LLM backend with tool calling. Recipe uses
gemini-2.5-flash— fast enough that agent loops complete in seconds rather than minutes. - A free Tavily account (for the lead-generation step) — sign up at tavily.com
- A HubSpot CRM portal you have admin access to (for the write step)
python3onPATH(any modern version — used for the leads-file splitter)
Part 1 — Find leads with Tavily
1.1 Register Tavily's MCP server
contenox mcp add tavily --transport http --url https://mcp.tavily.com/mcp/ --auth-type oauth
contenox mcp auth tavily
The auth command opens your browser to Tavily's authorization page. Approve, and the local CLI catches the redirect, exchanges the code, and persists the tokens. No client_id / client_secret to manage — Tavily's MCP supports RFC 7591 dynamic client registration, so contenox negotiates everything at runtime.
Real output from a fresh run:
MCP server "tavily" added successfully.
Opening browser for contenox authorization...
tavily: authenticated successfully.
1.2 Generate leads.txt with one chat command
contenox session new lead-discovery
contenox chat --model gemini-2.5-flash --provider gemini --timeout 5m \
"Use tavily to find 5 recent news articles or press releases about B2B SaaS startups in London that just raised Seed funding. For each, extract the company name, the CEO/Founder's name, and a 1-sentence summary of what they do. Save the results to leads.txt as blank-line-separated blocks formatted exactly as: '1. Company Name: <name>\n Founder/CEO: <name>[ and <name>]\n Summary: <one sentence>'."
The agent calls tavily.tavily_search (one or more queries), parses results, then calls local_fs.write_file to write leads.txt. The file write triggers contenox's default HITL policy — you'll get an Approve? [y/N] prompt showing the proposed content. Confirm, and you have your leads.
If the agent gets stuck in search-verification loops with a thinking-class model, switch to
gemini-2.5-flash(or any fast tool-calling model). Reasoning models tend to second-guess search results; flash models commit to action faster, which is what you want for batch agentic work.
leads.txt ends up looking like this (real sample, names verified against actual seed announcements):
1. Company Name: Cyb3r Operations
Founder/CEO: Vincent Cook
Summary: A cybersecurity SaaS platform that helps organizations continuously detect, assess, and manage third-party and supply-chain cyber risks in real-time.
2. Company Name: Paygentic
Founder/CEO: Susan O'Neill and Samuel A.
Summary: Provides an agent-first billing and payments infrastructure designed specifically to help AI-native and agent-driven businesses monetize their usage and outcomes.
3. Company Name: Round (Round Treasury)
Founder/CEO: Pac O'Shea and Hayyaan Ahmad
Summary: An AI-powered finance automation platform that streamlines treasury management, accounts payable, payroll, and FX for modern B2B finance teams.
Part 2 — Push leads into HubSpot
2.1 Get a HubSpot credential
You need a bearer token with these CRM scopes:
crm.objects.companies.readcrm.objects.companies.writecrm.objects.contacts.readcrm.objects.contacts.write
Two routes work today, both produce a pat-*-... token that authenticates as Authorization: Bearer <token>:
- Private App — in your CRM portal: gear icon → Integrations → Private Apps → "Create private app". GA, recommended.
- Service Key —
developer.hubspot.com→ Service Keys (Public Beta). Same auth shape, same scopes.
Once you have it:
export HUBSPOT_TOKEN=pat-na1-...
HubSpot also ships an OAuth-only MCP server at
https://mcp.hubspot.com/with curated CRM tools. If you'd rather use that path — particularly for read-heavy workflows where you want HubSpot's full tool surface — see the HubSpot via MCP recipe. The OpenAPI route below is the right pick when you want a hand-curated, narrow tool surface (3 operations vs HubSpot's ~12) and finer write control, and it's the same pattern that works for any vendor shipping an OpenAPI spec without an MCP server.
2.2 Drop the OpenAPI sub-spec
Save to ~/.contenox/hubspot-revops.yaml — a hand-curated 3-operation subset of HubSpot's CRM v3 REST API. Small enough to keep the model focused; covers everything this recipe needs.
openapi: 3.0.3
info:
title: HubSpot RevOps (narrow)
version: "1.0.0"
servers:
- url: https://api.hubapi.com
paths:
/crm/v3/objects/companies/search:
post:
operationId: searchCompany
summary: Search HubSpot companies by name for deduplication.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [filterGroups]
properties:
filterGroups:
type: array
items:
type: object
required: [filters]
properties:
filters:
type: array
items:
type: object
required: [propertyName, operator]
properties:
propertyName: { type: string }
operator:
type: string
enum: [EQ, NEQ, CONTAINS_TOKEN, NOT_CONTAINS_TOKEN, IN, NOT_IN, HAS_PROPERTY, NOT_HAS_PROPERTY]
value: { type: string }
values:
type: array
items: { type: string }
properties:
type: array
items: { type: string }
limit: { type: integer }
query: { type: string }
responses:
"200":
description: Search results.
content:
application/json:
schema:
type: object
properties:
total: { type: integer }
results:
type: array
items: { $ref: "#/components/schemas/SimpleObject" }
/crm/v3/objects/companies:
post:
operationId: createCompany
summary: Create a new HubSpot company.
description: |
Common properties: name, domain, description, industry, city, country, numberofemployees.
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateInput" }
responses:
"201":
description: Company created.
content:
application/json:
schema: { $ref: "#/components/schemas/SimpleObject" }
/crm/v3/objects/contacts:
post:
operationId: createContact
summary: Create a HubSpot contact, optionally associated with a company.
description: |
To link the contact to a company at create time, set associations to
[{"to":{"id":"<company_id>"},"types":[{"associationCategory":"HUBSPOT_DEFINED","associationTypeId":279}]}].
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/CreateInput" }
responses:
"201":
description: Contact created.
content:
application/json:
schema: { $ref: "#/components/schemas/SimpleObject" }
components:
schemas:
CreateInput:
type: object
required: [properties]
properties:
properties:
type: object
additionalProperties: { type: string }
associations:
type: array
items:
type: object
required: [to, types]
properties:
to:
type: object
required: [id]
properties:
id: { type: string }
types:
type: array
items:
type: object
required: [associationCategory, associationTypeId]
properties:
associationCategory:
type: string
enum: [HUBSPOT_DEFINED, INTEGRATOR_DEFINED, USER_DEFINED]
associationTypeId: { type: integer }
SimpleObject:
type: object
required: [id, properties]
properties:
id: { type: string }
properties:
type: object
additionalProperties: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
2.3 Register the tool
contenox tools add hubspot \
--url https://api.hubapi.com \
--spec ~/.contenox/hubspot-revops.yaml \
--header "Authorization: Bearer $HUBSPOT_TOKEN"
Verify the 3 operations are wired:
contenox tools show hubspot
You should see searchCompany, createCompany, and createContact listed.
2.4 Drop the runner script
Save to ~/leads-to-hubspot.sh and chmod +x it:
#!/usr/bin/env bash
# leads-to-hubspot.sh — feed a structured leads file through contenox into HubSpot,
# one lead per agent invocation so each gets a fresh round budget.
set -euo pipefail
: "${HUBSPOT_TOKEN:?set HUBSPOT_TOKEN before running}"
LEADS_FILE="${1:-leads.txt}"
MODEL="${MODEL:-gemini-2.5-flash}"
PROVIDER="${PROVIDER:-gemini}"
TIMEOUT="${TIMEOUT:-3m}"
read -r -d '' PROMPT <<'EOF' || true
You are a RevOps assistant. Process EXACTLY ONE lead below using the hubspot tools provider.
1. Call hubspot.searchCompany once with filterGroups=[{filters:[{propertyName:"name", operator:"EQ", value:<the company name>}]}]. If results is non-empty, output "SKIP <company name>: duplicate" and stop.
2. Otherwise call hubspot.createCompany with properties.name=<the company name> and properties.description=<the summary>. Capture the returned id as company_id.
3. For each Founder/CEO (split on " and "), call hubspot.createContact with properties.firstname, properties.lastname, properties.jobtitle="Founder/CEO", and associations=[{"to":{"id":<company_id>},"types":[{"associationCategory":"HUBSPOT_DEFINED","associationTypeId":279}]}].
Do NOT run extra searches. Be terse.
Final line: "OK <company name> id=<company_id> contacts=<comma-separated ids>"
Lead:
EOF
python3 -c '
import sys, re
text = open(sys.argv[1]).read()
for block in re.split(r"\n\s*\n", text):
block = block.strip()
if block: sys.stdout.write(block + "\0")
' "$LEADS_FILE" |
while IFS= read -r -d '' lead; do
name=$(printf '%s' "$lead" | sed -n 's/.*Company Name:[[:space:]]*\(.*\)/\1/p' | head -1)
printf '▶ %s\n' "${name:-<unknown>}" >&2
contenox session new "lead-$(date +%s%N | tail -c 9)" >/dev/null
contenox chat --timeout "$TIMEOUT" --model "$MODEL" --provider "$PROVIDER" \
"${PROMPT}${lead}" </dev/null 2>/dev/null | tail -3
printf '\n' >&2
done
The
</dev/nullon thecontenox chatline is load-bearing. Without it the loop's NUL-separated input gets consumed by the chat process and only the first lead runs.
2.5 Run the script
./leads-to-hubspot.sh leads.txt
Real output from a live run against an empty HubSpot portal:
▶ Cyb3r Operations
OK Cyb3r Operations id=429788612851 contacts=776337158347
▶ Paygentic
OK Paygentic id=429822814428 contacts=776398192879,776428048595
▶ Round (Round Treasury)
OK Round (Round Treasury) id=429794020561 contacts=776369215708,776422643899
▶ lines go to stderr (status), OK lines to stdout (machine-parseable: company name, id, comma-separated contact ids). Wall-time for 5 leads with 8 founders total: ~30 seconds.
2.6 Verify in HubSpot
Hit the API directly to confirm what landed:
curl -sS -H "Authorization: Bearer $HUBSPOT_TOKEN" \
"https://api.hubapi.com/crm/v3/objects/companies?limit=20&properties=name,description&sort=-createdate" \
| python3 -m json.tool
The new companies appear at the top with descriptions matching the Summary: lines from leads.txt. Same pattern at /crm/v3/objects/contacts for the founders — firstname, lastname, jobtitle="Founder/CEO", associated to their company.
Customize
- Different lead source. Swap Tavily for any other search MCP (Perplexity Sonar, Exa, You.com) — the lead-discovery prompt is provider-agnostic, just reference the matching
<provider>.searchtool. Or skip Part 1 entirely and bring your own pre-built leads.txt from any source. - Add fields. Edit the runner prompt to map additional fields from leads.txt into
propertiesoncreateCompany(e.g.domain,industry,city). No spec change needed —propertiesis an open map. - Add a notes step. Drop a
createNoteoperation into the spec (path/crm/v3/objects/notes, sameCreateInputshape;associationTypeId190 for Note→Company, 202 for Note→Contact). Requirescrm.objects.notes.writescope — present on Private Apps, not on Service Keys at time of writing. - Different LLM. Set
MODEL=...andPROVIDER=...before running. Any tool-calling model works.
Caveats
-
Tavily free tier limits. $5 in monthly credits, ~1k searches/mo at the standard rate. Plenty for occasional lead-discovery; check your usage if you run it in a loop.
-
HubSpot Service Keys are Public Beta at time of writing. The scope picker doesn't expose
crm.objects.notes.*— use a Private App if you need notes. -
HITL on file writes. Part 1's
local_fs.write_filecall to saveleads.txttriggers contenox's default approval prompt. That's by design — you review what the agent extracted before it lands on disk. The HubSpot create operations in Part 2 flow through without prompts becausehubspotisn't in the default HITL policy. To require approval per CRM write, add a rule to~/.contenox/hitl-policy-default.json:{"tools": "hubspot", "tool": "createCompany", "action": "approve"} -
One lead per chat invocation. Each lead gets a fresh agent round budget. Batching all leads into one prompt also works for small batches but hits the chain's default 10-round cap around lead 3.
-
Token storage. Your
HUBSPOT_TOKENends up in contenox's local SQLite (~/.contenox/local.db) inside theremote_toolsrow's headers. Same machine, same posture as any locally-stored credential — rotate as you would any other token. Tavily OAuth tokens are stored the same way under the MCP server's row.