Het is dinsdag. Je support-triage-agent draait al een maand rustig mee, en nu moet hij nog één veld van het contactrecord lezen. Vier minuten werk: open de Private App in de HubSpot-instellingen, vink een scope aan, opslaan.
Alleen is de scope die hij echt nodig heeft crm.objects.companies.write, en die vanavond aanvinken is de enige manier om de agent voor de ochtend weer vlot te trekken. Dus je vinkt 'm aan. Het token van je agent kan nu elk bedrijfsrecord in het portal overschrijven. Niemand heeft dat bewust besloten. Het is gewoon aangegroeid, blokkade voor blokkade.
HubSpot Private Apps zijn de snelste manier om een AI-agent een sleutel tot je CRM te geven. Ze zijn ook de snelste manier om te eindigen met één veel te machtig token dat vijf agents delen, zonder audit trail, met een rate-limit-budget dat drie van hen stilletjes opdrogen. Hier zijn drie patronen waarmee we dat voorkomen. Geen ervan vraagt om een HubSpot Enterprise-add-on.
Waarom Private App-scopes richting 'alles' kruipen
Een Private App is één statische set credentials, vastgeklonken aan één portal, ingesteld door een super admin onder Settings. Je kiest scopes uit een platte checklist, HubSpot maakt een bearer token aan, en elke code die dat token vasthoudt kan alles wat die scopes toestaan. De Private Apps-documentatie legt het model duidelijk genoeg uit. De problemen staan niet in de docs. Ze zitten in hoe het ding veroudert.
Drie ervan tellen voor agents.
Ten eerste zijn scopes grofmazig. crm.objects.deals.write betekent schrijven naar elke deal, in elke pipeline. Er is geen scope die zegt 'deze agent mag alleen aan de Renewals-pipeline komen'. De permissie die je geeft is altijd breder dan de taak.
Ten tweede verloopt het token nooit. Een Private App-accesstoken is een langlevende bearer string zonder ingebouwde rotatie. Lekt het in een log, een prompt of een screenshot, dan blijft het geldig tot een mens het opmerkt en met de hand rouleert.
Ten derde betekent één app één rate-limit-bucket. HubSpot beperkt een Private App tot ongeveer 100 requests per 10 seconden, plus een dagplafond, zoals beschreven in de API usage guidelines. Richt vijf agents op één app en ze vechten om hetzelfde budget. De agent die verliest, krijgt een 429 op het slechtst denkbare moment.
Behandel een Private App-token als een wachtwoord dat uit zichzelf nooit verloopt. Duikt er ooit eentje op in een modelprompt, een errorlog of een chattranscript, rouleer 'm dan dezelfde dag nog. Een vangnet van kortlevende tokens is er hier niet.
Ontwikkelaars hebben de afgelopen maand gediscussieerd over wat je moet doen met door AI geschreven pull requests. Het antwoord waar ze steeds op uitkomen, is hetzelfde dat dit oplost: bekijk de diff, en beperk wat het geautomatiseerde ding op eigen houtje mag mergen. Een CRM-token verdient dezelfde terughoudendheid.
Patroon één: één Private App per agent
De standaardfout is één enkele Private App met een naam als 'AI Integration' die elke agent deelt. Niet doen. Maak één Private App per agent, en geef elke app alleen de scopes die de taak van die agent vereist.
Een facturatie-agent leest deals en contacten en schrijft een paar tracking-properties. Een support-triage-agent leest en schrijft tickets. Een lead-enrichment-agent leest bedrijven en contacten en schrijft niets. Drie taken, drie apps, drie 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
Dit kost je vijftien minuten setup en levert drie dingen op. De blast radius van een gelekt token krimpt tot de taak van één agent. Je kunt één token rouleren zonder de andere agents plat te leggen. En elke agent krijgt zijn eigen rate-limit-bucket, zodat een spraakzame enrichment-run de facturatie-agent niet kan uithongeren.
Het maakt van de scope-checklist ook een echte review. Vraagt de facturatie-agent om companies.write, dan staat dat verzoek alleen op zijn eigen app, en moet iemand ernaar kijken en vragen waarom. Op een gedeelde app verdwijnt diezelfde scope gewoon in een lijst van twintig.
Patroon twee: een scope-gateway vóór HubSpot
De agent-runtime is de slechtste plek voor een CRM-token. Tokens lekken in prompts, traces en retry-logs. En een agent die een token vasthoudt, kan elk endpoint aanroepen dat de scopes toestaan, ook endpoints waar je hem nooit wilde laten komen.
Geef de agent dus niet het token. Geef hem een gateway: een kleine service die het token vasthoudt en een korte, vaste lijst operaties aanbiedt. De tools van de agent wijzen naar gateway-endpoints, niet naar de API van HubSpot.
// 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);
De gateway gebruikt onder de motorkap de CRM Search API van HubSpot, maar de agent ziet daar niets van. Hij ziet één tool met de naam overdue-invoices die een lijst teruggeeft. Het token blijft in één proces. De set dingen die de agent kan doen is nu een bestand dat je in dertig seconden leest, geen scope-matrix waar je over moet nadenken.
Een gateway geeft je ook een plek voor de saaie, noodzakelijke dingen: een audit log van elke call die de agent deed, een idempotency key zodat een herhaalde schrijfactie niet twee keer afgaat, en een rate limiter die in de wachtrij zet in plaats van 429's terug naar de agent te gooien.
Patroon drie: lees vrij, schrijf achter een poort
Een leesactie fout doen is goedkoop. Het ergste geval van een slechte zoekopdracht is een lege lijst. Bij schrijfacties richt een agent echte schade aan, en 'echte schade' met een LLM in de loop betekent een zelfverzekerd verkeerde deal-stage-update die op 400 records is toegepast voordat iemand kijkt.
Splits de paden dus. Laat de agent vrij lezen via de gateway. Stuur elke schrijfactie door een poort die precies weet welke properties mogen veranderen.
// 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());
});
Nu mag de Private App van de agent deals.write dragen, maar de enige deal-properties die hij echt kan aanraken zijn twee tracking-velden. Besluit het model om dealstage of amount 'behulpzaam' bij te werken, dan geeft de gateway een 422 terug en verandert er niets. De scope is breed. De poort is smal.
Voor schrijfacties die echt een mens in de loop nodig hebben, zet je ze op een wachtrij in plaats van ze meteen toe te passen. De agent stelt een wijziging voor, de gateway noteert die als pending, en een mens (of een tweede controle) keurt de batch goed. HubSpots custom-coded workflow actions zijn een andere goede plek voor schrijfacties met veel op het spel, omdat ze de logica en de guardrails binnen HubSpots eigen audit trail houden.
Waar dit je brengt
Eén app per agent, een gateway die het token bezit, en een schrijfpad dat maar een handvol properties kent. Drie patronen, geen ervan slim, allemaal saai met opzet. Saai is wat je wilt tussen een LLM en een CRM vol echte klanten.
Toen we de factuur-najagende e-mailagent bouwden voor een Rotterdamse logistieke klant, liepen we precies hiertegen aan: de eerste versie hield een brede token vast en kon, in theorie, elke deal in de pipeline overschrijven. We zetten hem achter een gateway met een allowlist van twee properties, en de RevOps-lead van de klant stopte diezelfde week met nerveuze vragen stellen.
Het kleinste wat je vandaag kunt doen: open Settings, Integrations, Private Apps, en tel de scopes op je drukste token. Draagt het .write op een object dat je agent alleen ooit leest, dan heb je je eerste middag opruimwerk gevonden.




