Skip to content

HubSpot AI agents: three patterns to skip the scope nightmare

Your invoice-chaser agent throws a 403 at 2am. The fix takes four minutes. The damage takes three weeks: now one token can write to every contact you own.

Jacob Molkenboer
Jacob Molkenboer
Founder · A Brand New Company
Published
20 May 2026
Reading time
6 min read
Category
Integrations
Brass switchboard panel on ivory paper, one green patch cable fanning from a jack to many sockets, red tag beside it

It is Tuesday. Your support-triage agent has been running quietly for a month, and now it needs to read one more field off the contact record. Four minutes of work: open the Private App in HubSpot settings, tick a scope, save.

Except the scope it actually needs is crm.objects.companies.write, and ticking it tonight is the only way to unblock the agent before morning. So you tick it. The token your agent carries can now rewrite every company record in the portal. Nobody decided that on purpose. It just accreted, one unblock at a time.

HubSpot Private Apps are the fastest way to hand an AI agent a key to your CRM. They are also the fastest way to end up with one over-powered token shared by five agents, no audit trail, and a rate-limit budget that three of them are quietly starving. Here are three patterns we use to avoid that. None of them need a HubSpot Enterprise add-on.

Why Private App scopes drift toward "everything"

A Private App is one static set of credentials tied to a single portal, configured by a super admin under Settings. You pick scopes from a flat checklist, HubSpot mints a bearer token, and any code holding that token can do anything those scopes allow. The Private Apps documentation spells out the model clearly enough. The problems are not in the docs. They are in how the thing ages.

Three of them matter for agents.

First, scopes are coarse. crm.objects.deals.write means write to every deal, in every pipeline. There is no "this agent may only touch the Renewals pipeline" scope. The permission you grant is always wider than the job.

Second, the token never expires. A Private App access token is a long-lived bearer string with no built-in rotation. If it leaks into a log, a prompt, or a screenshot, it stays valid until a human notices and rotates it by hand.

Third, one app means one rate-limit bucket. HubSpot caps a Private App at roughly 100 requests per 10 seconds, plus a daily ceiling, as described in the API usage guidelines. Point five agents at one app and they fight over the same budget. The agent that loses gets a 429 at the worst possible moment.

Warning

Treat a Private App token like a password that can never expire on its own. If one ever appears in a model prompt, an error log, or a chat transcript, rotate it the same day. There is no short-lived-token safety net here.

Developers have spent the month arguing about what to do with AI-written pull requests. The answer they keep landing on is the same one that fixes this: review the diff, and limit what the automated thing can merge on its own. A CRM token deserves the same restraint.

Pattern one: one Private App per agent

The default mistake is a single Private App named something like "AI Integration" that every agent shares. Don't. Create one Private App per agent, and give each one only the scopes that agent's job requires.

An invoice-chaser reads deals and contacts and writes a couple of tracking properties. A support-triage agent reads and writes tickets. A lead-enrichment agent reads companies and contacts and writes nothing. Three jobs, three apps, three tokens.

# .env - one token per agent, never a shared "AI" token
HUBSPOT_TOKEN_INVOICE_CHASER=pat-eu1-...   # deals.read, contacts.read, deals.write
HUBSPOT_TOKEN_SUPPORT_TRIAGE=pat-eu1-...   # tickets.read, tickets.write, contacts.read
HUBSPOT_TOKEN_LEAD_ENRICH=pat-eu1-...      # companies.read, contacts.read

This costs you fifteen minutes of setup and buys three things. The blast radius of a leaked token shrinks to one agent's job. You can rotate one token without taking the other agents down. And each agent gets its own rate-limit bucket, so a chatty enrichment run cannot starve the invoice-chaser.

It also turns the scope checklist into a real review. When the invoice-chaser asks for companies.write, that request stands alone on its own app, and someone has to look at it and ask why. On a shared app, the same scope just disappears into a list of twenty.

Pattern two: a scope gateway in front of HubSpot

The agent runtime is the worst place to keep a CRM token. Tokens leak into prompts, traces, and retry logs. And an agent that holds a token can call any endpoint the scopes allow, including ones you never intended it to reach.

So don't give the agent the token. Give it a gateway: a small service that holds the token and exposes a short, fixed list of operations. The agent's tools map to gateway endpoints, not to HubSpot's API.

// hubspot-gateway.js - the only process that holds the HubSpot token
import express from "express";

const app = express();
app.use(express.json());

const TOKEN = process.env.HUBSPOT_TOKEN_INVOICE_CHASER;

const hub = (path, init = {}) =>
  fetch(`https://api.hubapi.com${path}`, {
    ...init,
    headers: {
      Authorization: `Bearer ${TOKEN}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });

// The agent can call this. It cannot call anything else.
app.post("/tools/overdue-invoices", async (_req, res) => {
  const cutoff = Date.now() - 30 * 864e5; // 30 days ago
  const r = await hub("/crm/v3/objects/deals/search", {
    method: "POST",
    body: JSON.stringify({
      filterGroups: [{ filters: [
        { propertyName: "dealstage", operator: "EQ", value: "invoice_sent" },
        { propertyName: "closedate", operator: "LT", value: cutoff },
      ] }],
      properties: ["dealname", "amount", "closedate"],
      limit: 50,
    }),
  });
  res.status(r.status).json(await r.json());
});

app.listen(8787);

The gateway uses HubSpot's CRM Search API under the hood, but the agent never sees that. It sees one tool called overdue-invoices that returns a list. The token stays in one process. The set of things the agent can do is now a file you can read in thirty seconds, not a scope matrix you have to reason about.

A gateway also gives you somewhere to put the boring, necessary things: an audit log of every call the agent made, an idempotency key so a retried write does not fire twice, and a rate limiter that queues instead of throwing 429s back at the agent.

Pattern three: read freely, write behind a gate

Reads are cheap to get wrong. The worst case of a bad search is an empty list. Writes are where an agent does real damage, and "real damage" with an LLM in the loop means a confidently wrong deal-stage update applied to 400 records before anyone looks.

So split the paths. Let the agent read through the gateway freely. Route every write through a gate that knows exactly which properties are allowed to change.

// Only these properties are ever writable by the agent.
const WRITABLE = new Set(["abn_last_chase_at", "abn_chase_count"]);

app.patch("/tools/mark-chased/:dealId", async (req, res) => {
  const props = req.body.properties ?? {};
  const illegal = Object.keys(props).filter((k) => !WRITABLE.has(k));
  if (illegal.length) {
    return res.status(422).json({ error: `blocked: ${illegal.join(", ")}` });
  }
  const r = await hub(`/crm/v3/objects/deals/${req.params.dealId}`, {
    method: "PATCH",
    body: JSON.stringify({ properties: props }),
  });
  res.status(r.status).json(await r.json());
});

Now the agent's Private App can carry deals.write, but the only deal properties it can actually touch are two tracking fields. If the model decides to "helpfully" update dealstage or amount, the gateway returns a 422 and nothing changes. The scope is wide. The gate is narrow.

For writes that genuinely need a human in the loop, push them onto a queue instead of applying them inline. The agent proposes a change, the gateway records it as pending, and a person (or a second check) approves the batch. HubSpot's custom-coded workflow actions are another good home for high-stakes writes, because they keep the logic and the guardrails inside HubSpot's own audit trail.

Where this leaves you

One app per agent, a gateway that owns the token, and a write path that only knows a handful of properties. Three patterns, none of them clever, all of them boring on purpose. Boring is what you want between an LLM and a CRM full of real customers.

When we built the invoice-chasing email agent for a Rotterdam logistics client, the thing we ran into was exactly this: the first version held a broad token and could, in theory, rewrite any deal in the pipeline. We moved it behind a gateway with a two-property allowlist, and the client's RevOps lead stopped asking nervous questions that same week.

The smallest thing you can do today: open Settings, Integrations, Private Apps, and count the scopes on your busiest token. If it carries .write on an object your agent only ever reads, you have found your first afternoon of cleanup.

Frequently asked

Can I limit a HubSpot Private App to a single pipeline or a subset of records?+
No. Scopes apply to a whole object type, so deals.write covers every deal in every pipeline. Narrow the agent at a gateway with a property allowlist, not at the scope level.
Do I have to regenerate the token when I add a scope?+
No. Adding scopes to an existing Private App leaves the token valid. That convenience is also why scopes quietly accrete, so review them on a schedule rather than per-incident.
Private App or OAuth app for an AI agent?+
A Private App is fine for an internal agent on one portal. Use a public OAuth app only when the agent runs against many customer portals you do not control.
Won't a gateway just become another thing to maintain?+
It is one small service, usually under 100 lines. It pays for itself the first time you rotate a token, add an audit log, or block a bad write without redeploying the agent.

Want to build something similar?

Send us one paragraph about the process that eats the most of your week. We'll reply with an honest plan — within 4h on weekdays.