- Updated
- Reading
- 9 min
- By
- Anlyon Team
Let an AI sales assistant update deals in each rep's own HubSpot
Each rep connects their own HubSpot. Your AI assistant searches and moves their deals, with 20 CRM changes a day per rep, and closing a deal waits for the rep.
- end users
- hubspot
- crm
- oauth
- per-user limits
- approvals
- typescript
To let an AI sales assistant update deals in each rep's own HubSpot, have each rep connect HubSpot once, then make every call with anlyon.forUser(rep.id). Anlyon makes the call with that rep's token, counts it against that rep's daily limit, asks the rep before a deal is closed, and reads the deal back from HubSpot to grade the receipt.
A rep types "Acme signed, move it to contract sent" and the deal moves in their HubSpot, under their name. A rep types "mark Globex as lost" and a card asks them to confirm before anything changes. This tutorial builds that with Anlyon's HubSpot pack and TypeScript. The same pattern for any provider is in how to let your AI agent act in your users' accounts.
What you will build
- A connect flow where each rep signs in to their own HubSpot with OAuth.
- A sales assistant that searches a rep's deals and moves a deal to a stage of the default pipeline.
- A rule that closing a deal (
closedwonorclosedlost) waits for the rep's own approval in your app. - A cap of 20 CRM changes a day for each rep, enforced before the call.
- A receipt for every change, graded
confirmedwhen the read-back from HubSpot matches, and a per-rep receipt list.
Prerequisites
- A Node.js server with signed-in users. The code calls
auth()for the session. - An Anlyon workspace with two keys: an operator key (
actions:write,impact-limits:write) for setup, and an app key from the Standard preset plusend-users:writeandend-users:approvals, with Require an end user on. - A HubSpot account with a few test deals in the default pipeline.
npm install @anlyonhq/sdk
Production note. Anlyon's HubSpot app is unlisted, and HubSpot caps an unlisted app at 25 installs until it reviews and lists it. That is plenty for a pilot. For production, add your own HubSpot app to the environment (Integrations → HubSpot → Sign-in). New HubSpot apps are developer-platform projects created with HubSpot's CLI. Your reps then sign in through your app, and nothing else in this tutorial changes.
1. Install the HubSpot pack
ANLYON_API_KEY=$ANLYON_OPERATOR_KEY npx @anlyonhq/cli packs install hubspot --yes
The pack adds HubSpot's reads and writes as named actions with the hubspot- prefix: hubspot-search-deals, hubspot-get-deal, hubspot-list-deal-pipelines, hubspot-update-deal-stage, hubspot-add-deal-note and more. Every write declares one resource_mutations and a read-back. Each request sends Authorization: Bearer {{connection:hubspot}}, which resolves to the calling rep's own HubSpot token.
The pack also brings six suggested approval policies, installed disabled, and suggested daily limits, printed but not created. Step 4 and step 5 use them. See the packs docs.
2. Let each rep connect their HubSpot
// lib/anlyon.ts
import { Client } from '@anlyonhq/sdk';
export const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
// app/connect/hubspot/route.ts
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
export async function GET(request: Request) {
const session = await auth();
if (!session) return Response.redirect(new URL('/login', request.url));
const { data } = await anlyon.endUsers.createConnectLink({
endUserId: session.user.id,
integration: 'hubspot',
redirectUrl: 'https://app.example.com/connections/callback',
});
return Response.redirect(data!.url); // works once, for 10 minutes
}
// app/connections/callback/route.ts
import { parseConnectRedirect } from '@anlyonhq/sdk';
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
export async function GET(request: Request) {
const session = await auth();
if (!session) return Response.redirect(new URL('/login', request.url));
const result = parseConnectRedirect(request.url);
if (result?.status === 'pending_confirmation' && result.flowId) {
// Confirm for the rep in your session, never a user read from the URL.
await anlyon.endUsers.confirmConnection(session.user.id, result.flowId);
}
return Response.redirect(new URL('/assistant', request.url));
}
The rep sees Anlyon's consent page, which names your workspace, then HubSpot's own consent for contacts, companies and deals. The connection exists once your server confirms it. A link forwarded to someone else cannot attach their HubSpot to this rep: the confirm answers 403 END_USER_MISMATCH and connects nothing.
3. Search a rep's deals and move one
Every call goes through a client bound to the rep. The model never names the rep.
// lib/deals.ts
import { EndUserNotConnectedError, EndUserReconnectRequiredError } from '@anlyonhq/sdk';
import { anlyon } from '@/lib/anlyon';
export type DealStage =
| 'appointmentscheduled' | 'qualifiedtobuy' | 'presentationscheduled'
| 'decisionmakerboughtin' | 'contractsent' | 'closedwon' | 'closedlost';
export async function findDeals(repId: string, query: string) {
const { data } = await anlyon.forUser(repId).actions.invoke('hubspot-search-deals', { query });
return JSON.parse(data!.body ?? '{}').results ?? []; // up to 20 deals, most recently changed at the top
}
export async function moveDeal(repId: string, dealId: string, dealstage: DealStage) {
try {
const { data } = await anlyon.forUser(repId).actions.invoke('hubspot-update-deal-stage', { dealId, dealstage });
return {
status: data!.pendingApproval ? 'pending_approval' : data!.status,
approvalId: data!.approvalId,
grade: data!.grade ?? null,
effectId: data!.effectId ?? null,
};
} catch (error) {
if (error instanceof EndUserNotConnectedError || error instanceof EndUserReconnectRequiredError) {
return { status: 'connect_required', connectUrl: error.connectUrl };
}
throw error;
}
}
The dealstage input is an enum of the seven stages of HubSpot's default pipeline: appointmentscheduled, qualifiedtobuy, presentationscheduled, decisionmakerboughtin, contractsent, closedwon, closedlost. Anything else is refused before the call. dealId is HubSpot's numeric deal id, which hubspot-search-deals returns.
On the Vercel AI SDK, the same actions become tools in one line, with the connect and approval results built in. The Vercel AI SDK per-user tutorial has the full chat UI:
const tools = await anlyonTools({
client: anlyon.forUser(session.user.id),
actions: ['hubspot-search-deals', 'hubspot-get-deal', 'hubspot-update-deal-stage'],
});
4. Make closing a deal the rep's call
The pack ships a suggested policy, hubspot: approve closing a deal, matching hubspot-update-deal-stage with this CEL condition:
input.dealstage == 'closedwon' || input.dealstage == 'closedlost'
It installs disabled and decided by your team. Turn it on and hand the decision to the rep the call is for:
// scripts/setup-anlyon.ts
import { Client, SignOffRequiredError } from '@anlyonhq/sdk';
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
const { data: policies } = await ops.approvalPolicies.list({ actionName: 'hubspot-update-deal-stage' });
const closing = policies?.find((p) => p.name === 'hubspot: approve closing a deal');
try {
await ops.approvalPolicies.update(closing!.id, { decidedBy: 'end_user', enabled: true });
} catch (error) {
if (!(error instanceof SignOffRequiredError)) throw error;
console.log('A workspace admin approves this change at', error.signOffUrl);
}
Handing a decision from your team to your users counts as loosening a guardrail, so a change made with an API key waits for a workspace admin's sign-off in the console. Made by an admin in the console under Controls → Policies, it applies at once.
Now a move to contractsent goes straight through, and a move to closedwon returns pending_approval. The approval stays out of your team's inbox and waits for the rep. Show it and record their answer from your server:
const { data: pending } = await anlyon.endUsers.approvals.list(rep.id, { status: 'pending' });
// pending[i].payload.input is { dealId, dealstage }: show it to the rep.
const { data } = await anlyon.endUsers.approvals.decide(rep.id, approvalId, { decision: 'approve' });
const effectId = data?.invocation?.effectId; // the receipt of the move that just ran
The approval is bound to a digest of the exact request. The rep approves "deal 18342 to closedwon", and that exact request is what runs. The end-user approval guide has a full approval card.
5. Cap each rep at 20 CRM changes a day
await ops.impactLimits.create({
name: 'CRM changes per rep per day',
dimension: 'resource_mutations',
period: 'day',
amount: 20,
scope: 'end_user',
});
scope: 'end_user' gives every rep their own 20, counted per UTC day. Each HubSpot write counts one resource_mutations, reserved before the call. A rep's 21st change is refused before anything reaches HubSpot, and the rest of the team is unaffected.
With no actionName, the limit counts every action in the environment that declares resource_mutations. In an environment with the HubSpot pack alone, that is every CRM write: stage moves, notes, new contacts. Add actionName: 'hubspot-update-deal-stage' to cap stage moves alone. The pack's own suggested limits, such as hubspot: deal stage changes per day at 100, are environment-wide; create them with --with-limits if you also want a ceiling for the whole team. More in per-user limits for AI agents.
6. Know what "confirmed" means
hubspot-update-deal-stage declares this read-back in the pack:
"verify": {
"url": "https://api.hubapi.com/crm/v3/objects/deals/{{input.dealId}}?properties=dealstage",
"status": 200,
"match": [{ "path": "properties.dealstage", "equals": "{{input.dealstage}}" }]
}
After HubSpot accepts the change, Anlyon fetches the deal with the rep's token and checks that dealstage equals the stage it asked for. A match grades the receipt confirmed. A HubSpot 2xx with no match stays acknowledged.
The pack states the limitation of this check: the read-back can match a deal that was already at that stage, so confirmed means the deal is at the stage now, not that this call moved it. And stage ids differ in a portal with custom pipelines. This action takes the default pipeline's stages; call hubspot-list-deal-pipelines to see a portal's own.
7. List one rep's receipts
const page = await anlyon.actions.effects({
endUserId: rep.id,
action: 'hubspot-update-deal-stage',
limit: 50,
});
for (const receipt of page.data ?? []) {
console.log(receipt.createdAt, receipt.id, receipt.outcome, receipt.grade);
}
Each receipt carries its outcome, its grade and the approval it ran under, if any. Filter with grade: 'unknown' to find the moves you should check in HubSpot, or drop action to see everything the assistant did for that rep. Use the workspace client for this: a forUser client is for acting. The guide to querying one user's actions covers invocations, pagination and the console views.
Test it
- Before connecting:
moveDeal(rep.id, '18342', 'contractsent')returnsstatus: 'connect_required'with aconnectUrl. Nothing was sent to HubSpot. - Connect through
/connect/hubspotand return to/assistant.anlyon.endUsers.listConnections(rep.id)showshubspot,active, with the deal scopes. - Find the deal:
findDeals(rep.id, 'Acme')returns the rep's matching deals withdealstageandamount. - Move it:
moveDeal(rep.id, '18342', 'contractsent')returnsstatus: 'succeeded',grade: 'confirmed'. The deal shows the new stage in HubSpot, changed by the rep's own account. - Close it:
moveDeal(rep.id, '18342', 'closedwon')returnsstatus: 'pending_approval'with anapprovalId. Approve as the rep: the receipt issucceeded,confirmed. Deny: the invocation isdeniedand the deal does not move. - Hit the cap: the 21st change of the day for this rep returns
status: 'failed'with the errorDispatch refused (impact_limit_exceeded), and its receipt is gradedrefused. Another rep's call still succeeds.
Limits
hubspot-update-deal-stagecovers the default pipeline's seven stages. A portal with custom pipelines needs its own action with those stage ids.- A
confirmedgrade can match a deal that was already at that stage. HubSpot has no idempotency key here, and Anlyon never resends anunknownwrite. - Anlyon's HubSpot app is capped at 25 installs until HubSpot lists it. Bring your own app for production.
- Calls your code makes to HubSpot with its own token are outside Anlyon: no limit, no approval, no receipt.
Next steps
- Let your AI agent act in your users' accounts: the full model, including erase.
- Per-user limits for AI agents: scopes, dimensions and what happens at the limit.
- End-user approval in your app: the approval card and the prebuilt React components.
- Vercel AI SDK tools that act in each user's account.
- Docs: Act for your users · Packs
Frequently asked questions
- How do I let an AI agent update deals in HubSpot?
- Install Anlyon's HubSpot pack, have each rep connect their own HubSpot through a connect link, then call hubspot-update-deal-stage with a client bound to that rep: anlyon.forUser(rep.id).actions.invoke('hubspot-update-deal-stage', { dealId, dealstage }). Anlyon makes the call with the rep's own token and reads the deal back to grade the receipt.
- Can an AI assistant use each sales rep's own HubSpot login instead of one shared key?
- Yes. Each rep signs in to HubSpot through a connect link, and Anlyon keeps their token encrypted and refreshes it. Calls from anlyon.forUser(rep.id) use that rep's own account, so HubSpot records the change as theirs.
- How do I stop an AI agent from closing HubSpot deals on its own?
- Turn on the HubSpot pack's suggested policy 'hubspot: approve closing a deal' and set decidedBy to end_user. A move to closedwon or closedlost then waits for the rep to approve it in your app, and nothing reaches HubSpot until they do.
- How many HubSpot installs does Anlyon's HubSpot app allow?
- Anlyon's HubSpot app is unlisted and capped at 25 installs until HubSpot reviews and lists it. For production, add your own HubSpot app to the environment, and your reps sign in through it.
