TL;DR
- A technographic filter is free to run and expensive to resolve. zoominfo_search_companies consumes no credits; zoominfo_enrich_technologies and zoominfo_enrich_contacts consume one credit per matched record. A single natural-language prompt against a 1,240-account stack filter can bill 6,200 credits, more than the 5,000 credits a ZoomInfo Professional plan ships for the entire year.
- ZoomInfo's OAuth scope vocabulary cannot express the boundary you need. api:data:company grants "search and enrich" as one grant. The free-versus-metered split has to be enforced in the tool surface, because it cannot be enforced in the token.
- If your agent serves more than one ZoomInfo customer organization, the shared-service-account path is closed by the provider. ZoomInfo Partner applications must use Authorization Code Flow with PKCE and do not support Client Credentials at all.
- Mastra gives you requestContext for per-request identity and dynamic tools resolution, but tool objects are constructed once at startup while identity arrives per request. Read the identifier inside execute, never at module scope.
- The ZoomInfo connector's tool library runs past 85 entries, roughly 17,000 tokens of schema before the agent does any work. This build puts 3 tools in front of the model and keeps the 5 metered tools inside deterministic workflow steps behind a budget gate.
- Scalekit's connected accounts give each rep their own vaulted ZoomInfo credential, so zoominfo_get_usage reports that rep's consumption and every audience the agent writes lands in their workspace under their own identity.
A revenue team asks for one thing: give me every account already running Snowflake and Okta, in the 200 to 2,000 employee band, that is not already on our competitor, with the platform engineering buyers attached. That is a technographic target list, and ZoomInfo can answer it precisely. zoominfo_search_companies accepts techAttributeTagList with AND logic and excludeTechAttributeTagList for displacement motions, and it costs nothing to run.
Then the agent resolves the list. Enrichment is where ZoomInfo bills. One credit per company for the verified technology stack, one credit per contact for a working email. Run that unattended across 1,240 matched accounts with four personas each and the arithmetic is 1,240 plus 4,960, which is 6,200 credits from one prompt. The OpenAI developer forum thread on runaway agent costs describes the same failure with a different meter: the agent loops, the charges fire, and account-level limits do not stop the call before it happens.
The interesting part is not that this can go wrong. It is that the two things you need to control here, who the agent is acting as and how much that action may cost, live in completely different layers, and only one of them fits inside an OAuth token.
The ZoomInfo app you already built cannot serve a second rep
Most teams start with a Standard application in the ZoomInfo Developer Portal and the Client Credentials flow. One client ID, one secret, one process, no browser. It works, and for an internal single-tenant script it is the correct call.
Two properties break it the moment a second person uses the agent.
The first is attribution. zoominfo_get_usage returns the current user's API usage statistics and limits: credits consumed, records returned, request counts. Under a shared server-to-server credential there is exactly one current user, so the tool reports the aggregate and tells you nothing about which rep's prompt burned the month's allocation. Your agent logs will show a run ID. Your ZoomInfo bill will show a number. Nothing joins them.
The second is that ZoomInfo closes the door itself once you distribute. Standard applications are for internal or single-tenant use and can use either flow. Partner applications, the ones built for distribution to customers across multiple organizations, must use Authorization Code Flow with PKCE, and Client Credentials is not available to them. If your agent is a feature inside a product that other companies log into, the multi-tenant shortcut does not exist at the protocol level.
Shared Standard app, Client Credentials
Per-rep connected account, Authorization Code
Who the call is attributed to
What zoominfo_get_usage reports
Whatever the app was granted
Intersection of app scopes and the rep's ZoomInfo entitlements
No effect, app keeps working
Their connected account revokes, agent fails closed
Available to multi-org distribution
No, Partner apps are PKCE-only
Scalekit's ZoomInfo connector is configured as OAuth 2.0 against your ZoomInfo app, and it produces a connected account per identifier. The credential is vaulted, refreshed, and injected at call time; your agent never holds a ZoomInfo token. That fixes attribution completely.
It does nothing at all about the invoice. You now know exactly which rep ran the list. You still have no idea what that run was permitted to spend.
Scopes tell you who. They do not tell you how much
Look at what ZoomInfo's OAuth scope list actually grants:
Cost profile of what it covers
Search and enrich company data
Search free, enrich one credit per matched record
Search and enrich contact data
Search free, enrich one credit per matched record
Search and enrich intent signals
Company credit plus record credits per signal
Create and modify GTM Studio audiences
Async enrichment jobs bill on the enriched rows
Read the acting user's roles and permissions
There is no api:data:company:search and no separate enrich grant. The authorization boundary the provider exposes is coarser than the boundary your budget requires. Consent to search is consent to enrich, and the model holding a api:data:company token is one tool call away from a four-figure invoice with no policy violated anywhere.
This is not a ZoomInfo defect. Scope vocabularies describe capability, not cost, and almost no provider splits them. It is a structural gap you have to close somewhere else, and the only layer that sits between the model's decision and the provider's meter is the tool surface.
So the boundary moves. Identity is enforced in the token. Spend is enforced in what the model is allowed to see.
Two tool surfaces, one identity
The ZoomInfo connector exposes more than 85 tools. At roughly 200 tokens of schema each, handing that catalog to the model burns about 17,000 tokens before it reasons about anything, and it puts zoominfo_enrich_contacts, zoominfo_enrich_companies, and zoominfo_enrich_audience inside the decision space of a system that is optimising for a helpful answer, not for a credit balance. Tool bloat here is an accuracy problem, a token cost problem, and a spend problem at the same time. The fix is not better prompting. It is surface reduction.
Split the connector into two surfaces that never mix.
zoominfo_lookup_data, zoominfo_search_companies, zoominfo_search_contacts
The LLM, inside a Mastra agent
Free, request-limited only
zoominfo_get_usage, zoominfo_enrich_technologies, zoominfo_create_audience, zoominfo_upsert_audience_rows, zoominfo_get_audience_job_status
A deterministic Mastra workflow step, after a budget check
Three tools reach the model instead of eighty-five. The model interprets the ICP, resolves natural language like "Snowflake and Okta" into ZoomInfo tag IDs, and produces a shortlist. It is structurally incapable of spending a credit because the metered tools are not registered on it. Everything downstream is a fixed-sequence pipeline, which is what you want anyway: list construction is not a reasoning task, and running it through a model adds latency, cost, and variance to something that must be reproducible for a revenue team.
Which turns the whole build into one wiring question: how does the right rep's identity reach both surfaces on every single request?
Wiring the rep's identity through Mastra
Mastra answers the platform half. Its auth providers verify the inbound JWT and set the caller's ID in requestContext, a per-request key-value store that travels with the call and is never serialized into the prompt. Scalekit answers the connector half. The bridge is one line of middleware that maps the verified user to a Scalekit identifier.
Three failure modes are worth naming before the code, because each one has produced a real bug report against Mastra.
- Tools are constructed at import time; identity arrives per request. Mastra issue #5198 is exactly this shape: a per-user API key could not be applied because the tool was loaded at startup rather than per query. Read the identifier inside execute, never at module scope.
- requestContext can be silently empty. Mastra issue #4465 reports context arriving empty in tools behind a particular adapter. An empty identifier that falls back to a default is a cross-tenant incident. Declare requestContextSchema so Mastra validates before execute() runs.
- There is no authorization hook in tool execution. Mastra issue #14089 states it directly: when an agent invokes a tool, there is no lifecycle point to enforce resource-scoped permission checks. Mastra's own Agent Builder docs carry the matching warning, that a shared connection means all callers share one set of credentials and one tenant's connected account can be exposed to another. Authorization has to be a property of the surface you hand the model, not a check you hope runs.
Start with the shared client and the identity contract.
// src/lib/scalekit.ts
import { ScalekitClient } from '@scalekit-sdk/node'
import { z } from 'zod'
// One client per process. Credentials come from the environment; never inline them.
export const scalekit = new ScalekitClient(
process.env.SCALEKIT_ENVIRONMENT_URL!,
process.env.SCALEKIT_CLIENT_ID!,
process.env.SCALEKIT_CLIENT_SECRET!,
)
// Must match the Connection name in the Scalekit dashboard exactly.
// This value is case-sensitive. A mismatch returns an empty tool list with no
// error, which is the most common first-run failure.
export const ZOOMINFO = 'zoominfo'
// The contract between server middleware and every agent, tool, step and workflow.
// enrichCreditBudget is the per-run ceiling, not the account balance.
export const runContext = z.object({
userId: z.string(),
scalekitIdentifier: z.string(),
enrichCreditBudget: z.number().int().positive().max(500),
})
// Typed auth outcomes the workflow can branch on. A thrown exception gives a
// workflow nothing to act on; a named variant does.
export const authError = z.object({
status: z.literal('auth_error'),
reason: z.enum(['not_connected', 'token_expired', 'scope_insufficient', 'account_revoked']),
})
/**
* ZoomInfo's GTM API returns JSON:API-shaped envelopes (application/vnd.api+json)
* and Scalekit passes the provider payload straight through on `response.data`.
* Normalise the two envelope shapes seen in practice instead of indexing blindly.
*/
export function records<T = Record<string, unknown>>(payload: unknown): T[] {
const body = (payload as { data?: unknown })?.data ?? payload
if (Array.isArray(body)) return body as T[]
const inner = (body as { data?: unknown })?.data
return Array.isArray(inner) ? (inner as T[]) : []
}
/** Map Scalekit's error codes onto the typed variants above. */
export function toAuthError(err: unknown) {
const code = (err as { code?: string })?.code
if (code === 'TOKEN_EXPIRED') return { status: 'auth_error' as const, reason: 'token_expired' as const }
if (code === 'SCOPE_INSUFFICIENT') return { status: 'auth_error' as const, reason: 'scope_insufficient' as const }
return null
}
Now the middleware that joins platform identity to connector identity. This is the only place the two systems touch.
// src/mastra/index.ts
import { Mastra } from '@mastra/core'
import { MASTRA_RESOURCE_ID_KEY } from '@mastra/core/request-context'
import { technographicAgent } from './agents/technographic-agent.ts'
import { targetListWorkflow } from './workflows/target-list.ts'
export const mastra = new Mastra({
agents: { technographicAgent },
workflows: { targetListWorkflow },
server: {
middleware: [
{
path: '/api/*',
handler: async (c, next) => {
// Your auth provider has already verified the JWT and populated the user.
const user = c.get('user') as { id: string; plan: 'starter' | 'scale' } | undefined
if (!user) return c.json({ error: 'Unauthorized' }, 401)
const requestContext = c.get('requestContext')
// Platform identity: reserved key, enforces Mastra resource ownership.
requestContext.set(MASTRA_RESOURCE_ID_KEY, user.id)
// Connector identity: the string Scalekit resolves to a vaulted credential.
requestContext.set('userId', user.id)
requestContext.set('scalekitIdentifier', `user_${user.id}`)
// Spend ceiling for this run. Derived server-side from the plan, never
// from the request body, and never from anything the model can influence.
requestContext.set('enrichCreditBudget', user.plan === 'scale' ? 250 : 60)
return next()
},
},
],
},
})
The model-facing tools read the identifier from that context and nothing else. Notice what is absent from every inputSchema: no identifier, no tenant, no credential. The model is never asked to supply identity, so it can never supply the wrong one.
// src/mastra/tools/zoominfo-free-tier.ts
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'
import { scalekit, ZOOMINFO, runContext, authError, records, toAuthError } from '../../lib/scalekit.ts'
/** Resolve natural-language vendor names into ZoomInfo tech product tag IDs. */
export const resolveTechTags = createTool({
id: 'zoominfo-resolve-tech-tags',
description:
'Resolve technology vendor or product names into ZoomInfo tech product tag IDs. ' +
'Always call this before filtering accounts by technology. Consumes no credits.',
inputSchema: z.object({
// 'tech-products' is one of the accepted fieldName enum values on ZoomInfo's
// lookup endpoint, alongside tech-vendors, tech-categories and tech-skills.
vendor: z.string().describe('Vendor name, lower case, e.g. "snowflake inc."'),
}),
outputSchema: z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
tags: z.array(z.object({ id: z.string(), name: z.string() })),
}),
authError,
z.object({ status: z.literal('error'), message: z.string() }),
]),
requestContextSchema: runContext,
execute: async ({ vendor }, context) => {
// Read identity at execute time. The tool object was built at import time;
// the identifier only exists per request.
const { scalekitIdentifier } = context.requestContext?.all ?? {}
try {
const res = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_lookup_data',
toolInput: { fieldName: 'tech-products', filter_vendor: vendor },
})
const tags = records<{ id?: string; name?: string }>(res.data)
.map(t => ({ id: String(t.id ?? ''), name: String(t.name ?? '') }))
.filter(t => t.id !== '')
return { status: 'success' as const, tags }
} catch (err) {
return toAuthError(err) ?? { status: 'error' as const, message: String(err) }
}
},
})
/** Filter the ZoomInfo company graph by installed stack plus firmographics. */
export const findAccountsByStack = createTool({
id: 'zoominfo-find-accounts-by-stack',
description:
'Find companies running a specific technology stack. requiredTagIds uses AND logic: ' +
'a company must run every listed product to match. Consumes no credits.',
inputSchema: z.object({
requiredTagIds: z.array(z.string()).min(1).describe('Tech product tag IDs the account must run'),
excludedTagIds: z.array(z.string()).default([]).describe('Tech product tag IDs that disqualify an account'),
employeeRangeMin: z.number().int().positive().default(200),
employeeRangeMax: z.number().int().positive().default(2000),
country: z.string().default('United States'),
pageSize: z.number().int().min(1).max(100).default(100),
pageNumber: z.number().int().min(1).default(1),
}),
outputSchema: z.discriminatedUnion('status', [
z.object({
status: z.literal('success'),
accounts: z.array(z.object({ companyId: z.number(), name: z.string(), website: z.string() })),
}),
authError,
z.object({ status: z.literal('error'), message: z.string() }),
]),
requestContextSchema: runContext,
execute: async (input, context) => {
const { scalekitIdentifier } = context.requestContext?.all ?? {}
try {
const res = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_search_companies',
toolInput: {
// Comma-separated tag IDs. ZoomInfo applies AND logic to this field,
// so three IDs means "runs all three", not "runs any of the three".
techAttributeTagList: input.requiredTagIds.join(','),
excludeTechAttributeTagList: input.excludedTagIds.join(','),
employeeRangeMin: String(input.employeeRangeMin),
employeeRangeMax: String(input.employeeRangeMax),
country: input.country,
excludeDefunctCompanies: true,
pageSize: input.pageSize,
pageNumber: input.pageNumber,
},
})
const accounts = records<{ id?: unknown; companyId?: unknown; name?: string; website?: string }>(res.data)
.map(c => ({
companyId: Number(c.companyId ?? c.id),
name: String(c.name ?? ''),
website: String(c.website ?? ''),
}))
.filter(c => Number.isFinite(c.companyId))
return { status: 'success' as const, accounts }
} catch (err) {
return toAuthError(err) ?? { status: 'error' as const, message: String(err) }
}
},
})
The agent registers those tools through a function rather than a static object, so the surface itself is resolved per request against what that rep's connected account is actually authorized to call.
// src/mastra/agents/technographic-agent.ts
import { Agent } from '@mastra/core/agent'
import { scalekit, ZOOMINFO, runContext } from '../../lib/scalekit.ts'
import { resolveTechTags, findAccountsByStack } from '../tools/zoominfo-free-tier.ts'
// Curated allowlist. Every entry is free to call. No enrichment tool appears here.
const FREE_TIER = {
'zoominfo_lookup_data': resolveTechTags,
'zoominfo_search_companies': findAccountsByStack,
} as const
export const technographicAgent = new Agent({
id: 'technographic-agent',
name: 'Technographic Targeting Agent',
model: 'openai/gpt-5.6-sol',
requestContextSchema: runContext,
instructions: `You translate an ideal customer profile into a technographic account shortlist.
Resolve every vendor name to tag IDs with zoominfo-resolve-tech-tags before filtering.
Required products use AND logic, so only list a product if the account must run it.
Return the shortlist as structured accounts. Do not attempt to verify, enrich or
contact anything: the pipeline handles resolution after you hand back the shortlist.`,
tools: async ({ requestContext }) => {
const identifier = requestContext.get('scalekitIdentifier') as string
// Retrieve the authorized surface for this rep's connected account. This is
// not tool discovery; it is the authorization boundary. A rep whose ZoomInfo
// subscription lacks a scope never sees the matching tool.
const scoped = await scalekit.tools.listScopedTools(identifier, {
filter: { connectionNames: [ZOOMINFO] },
pageSize: 100,
})
const authorized = new Set(
scoped.tools
.map((t: { name?: string; definition?: { name?: string } }) => t.name ?? t.definition?.name)
.filter((n): n is string => Boolean(n)),
)
// Intersect the curated free tier with what this rep is actually allowed to call.
return Object.fromEntries(
Object.entries(FREE_TIER)
.filter(([zoominfoTool]) => authorized.has(zoominfoTool))
.map(([, tool]) => [tool.id, tool]),
)
},
})
Identity is now correct at every call, and the model physically cannot spend a credit. The list still has to be resolved, and that is where the budget gate lives.
Recommended Reading: Access Control for Multi-Tenant AI Agents and How Tool Calling Auth Changes When You Move from Single-Tenant to Multi-Tenant
The deterministic pipeline that resolves the list
Five steps, fixed order, no model in the loop. Mastra workflows give each step a typed inputSchema and outputSchema, shared state that survives suspend and resume, and access to the same requestContext the tools read.
Step one verifies the connection and the budget before a single credit is at risk.
// src/mastra/workflows/target-list.ts
import { createWorkflow, createStep } from '@mastra/core/workflows'
import { z } from 'zod'
import { scalekit, ZOOMINFO, runContext, records } from '../../lib/scalekit.ts'
const runState = z.object({
creditsSpent: z.number(),
creditCeiling: z.number(),
connectionVerified: z.boolean(),
})
const preflight = createStep({
id: 'preflight',
inputSchema: z.object({
icp: z.object({
requiredVendors: z.array(z.string()).min(1),
excludedVendors: z.array(z.string()).default([]),
employeeRangeMin: z.number().int().positive(),
employeeRangeMax: z.number().int().positive(),
country: z.string(),
maxAccountsToVerify: z.number().int().positive(),
}),
}),
outputSchema: z.discriminatedUnion('status', [
z.object({ status: z.literal('ready'), icp: z.any() }),
z.object({ status: z.literal('connect_required'), authorizationLink: z.string() }),
z.object({ status: z.literal('budget_exhausted'), creditsRemaining: z.number() }),
]),
stateSchema: runState,
requestContextSchema: runContext,
execute: async ({ inputData, requestContext, state, setState }) => {
const { scalekitIdentifier, enrichCreditBudget } = requestContext.all
// 1. Does this rep have a live ZoomInfo connected account?
const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({
connectionName: ZOOMINFO,
identifier: scalekitIdentifier,
})
// Status '1' maps to ACTIVE in the protobuf enum. Anything else means the
// rep has never authorized, or their grant was revoked. Fail closed: there
// is deliberately no service-account fallback path here.
if (connectedAccount?.status?.toString() !== '1') {
const { link } = await scalekit.actions.getAuthorizationLink({
connectionName: ZOOMINFO,
identifier: scalekitIdentifier,
userVerifyUrl: `${process.env.APP_URL}/connect/zoominfo/callback`,
})
return { status: 'connect_required' as const, authorizationLink: link }
}
// 2. Does the tenant have credits left? get_usage reports the acting user's
// consumption, so with per-rep connected accounts this is attributable.
const usage = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_get_usage',
toolInput: {},
})
// Field names differ by ZoomInfo package. Read the live payload once, pin the
// path for your tenant, and treat anything unrecognised as unknown headroom.
const [row] = records<Record<string, unknown>>(usage.data)
const remaining = Number(row?.creditsRemaining ?? row?.recordCreditsRemaining ?? NaN)
// Unknown headroom fails closed. Guessing here is how you discover the
// overage on the invoice rather than in the logs.
if (!Number.isFinite(remaining) || remaining <= 0) {
return { status: 'budget_exhausted' as const, creditsRemaining: Number.isFinite(remaining) ? remaining : 0 }
}
// The run ceiling is the smaller of the plan budget and the account balance.
await setState({
...state,
creditsSpent: 0,
creditCeiling: Math.min(enrichCreditBudget, remaining),
connectionVerified: true,
})
return { status: 'ready' as const, icp: inputData.icp }
},
})
Steps two and three resolve the stack and build the shortlist. Both are free, so they run at full width.
const resolveAndShortlist = createStep({
id: 'resolve-and-shortlist',
inputSchema: z.discriminatedUnion('status', [
z.object({ status: z.literal('ready'), icp: z.any() }),
z.object({ status: z.literal('connect_required'), authorizationLink: z.string() }),
z.object({ status: z.literal('budget_exhausted'), creditsRemaining: z.number() }),
]),
outputSchema: z.object({
accounts: z.array(z.object({ companyId: z.number(), name: z.string(), website: z.string() })),
maxAccountsToVerify: z.number(),
halted: z.boolean(),
}),
stateSchema: runState,
requestContextSchema: runContext,
execute: async ({ inputData, requestContext }) => {
if (inputData.status !== 'ready') {
return { accounts: [], maxAccountsToVerify: 0, halted: true }
}
const { scalekitIdentifier } = requestContext.all
const icp = inputData.icp as {
requiredVendors: string[]
excludedVendors: string[]
employeeRangeMin: number
employeeRangeMax: number
country: string
maxAccountsToVerify: number
}
// Resolve vendor names to tag IDs through this rep's own connected account.
// Tag IDs are resolved per connected account rather than cached globally:
// lookup output is filtered by the subscription behind the token, so a value
// cached from one ZoomInfo tenant is not safe to reuse in another.
const toTagIds = async (vendors: string[]) => {
const ids: string[] = []
for (const vendor of vendors) {
const res = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_lookup_data',
toolInput: { fieldName: 'tech-products', filter_vendor: vendor },
})
const [first] = records<{ id?: unknown }>(res.data)
if (first?.id) ids.push(String(first.id))
}
return ids
}
const requiredTagIds = await toTagIds(icp.requiredVendors)
const excludedTagIds = await toTagIds(icp.excludedVendors)
// Free search. techAttributeTagList applies AND logic across the tag IDs.
const search = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_search_companies',
toolInput: {
techAttributeTagList: requiredTagIds.join(','),
excludeTechAttributeTagList: excludedTagIds.join(','),
employeeRangeMin: String(icp.employeeRangeMin),
employeeRangeMax: String(icp.employeeRangeMax),
country: icp.country,
excludeDefunctCompanies: true,
pageSize: 100,
sort: '-employeeCount',
},
})
const accounts = records<{ id?: unknown; companyId?: unknown; name?: string; website?: string }>(search.data)
.map(c => ({
companyId: Number(c.companyId ?? c.id),
name: String(c.name ?? ''),
website: String(c.website ?? ''),
}))
.filter(c => Number.isFinite(c.companyId))
return { accounts, maxAccountsToVerify: icp.maxAccountsToVerify, halted: false }
},
})
Step four is the only place in the build that spends money, and it checks the ledger before every single call.
const verifyStack = createStep({
id: 'verify-stack',
inputSchema: z.object({
accounts: z.array(z.object({ companyId: z.number(), name: z.string(), website: z.string() })),
maxAccountsToVerify: z.number(),
halted: z.boolean(),
}),
outputSchema: z.object({
verified: z.array(
z.object({
companyId: z.number(),
name: z.string(),
website: z.string(),
technologies: z.array(z.string()),
}),
),
creditsSpent: z.number(),
stoppedOnBudget: z.boolean(),
}),
stateSchema: runState,
requestContextSchema: runContext,
execute: async ({ inputData, requestContext, state, setState }) => {
if (inputData.halted) return { verified: [], creditsSpent: 0, stoppedOnBudget: false }
const { scalekitIdentifier } = requestContext.all
const candidates = inputData.accounts.slice(0, inputData.maxAccountsToVerify)
const verified: Array<{ companyId: number; name: string; website: string; technologies: string[] }> = []
let spent = state.creditsSpent
let stoppedOnBudget = false
for (const account of candidates) {
// The gate. zoominfo_enrich_technologies charges one credit per enriched
// company, so the check happens before the call, not after the response.
if (spent + 1 > state.creditCeiling) {
stoppedOnBudget = true
break
}
try {
const res = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_enrich_technologies',
toolInput: { companyId: account.companyId },
})
spent += 1
const technologies = records<{ name?: string; product?: string }>(res.data)
.map(t => String(t.product ?? t.name ?? ''))
.filter(Boolean)
verified.push({ ...account, technologies })
} catch (err) {
// A 429 means a rate window is exhausted, not that the account is broken.
// ZoomInfo evaluates per-second, per-hour and per-day windows at once and
// returns Retry-After plus X-RateLimit-Rejected-Bucket. Rejected requests
// do not consume quota, so pausing is safe; retrying blindly is not.
const status = (err as { status?: number })?.status
if (status === 429) {
stoppedOnBudget = false
break
}
// Anything else: skip this account, keep the run alive, keep the ledger honest.
continue
}
}
await setState({ ...state, creditsSpent: spent })
return { verified, creditsSpent: spent - state.creditsSpent, stoppedOnBudget }
},
})
Step five attaches the buying committee. Contact search is free, so persona selection happens at full width and only the shortlist that survives verification is carried forward.
const attachPersonas = createStep({
id: 'attach-personas',
inputSchema: z.object({
verified: z.array(
z.object({
companyId: z.number(),
name: z.string(),
website: z.string(),
technologies: z.array(z.string()),
}),
),
creditsSpent: z.number(),
stoppedOnBudget: z.boolean(),
}),
outputSchema: z.object({
rows: z.array(
z.object({
companyId: z.number(),
companyName: z.string(),
website: z.string(),
stack: z.string(),
contactName: z.string(),
contactTitle: z.string(),
}),
),
creditsSpent: z.number(),
stoppedOnBudget: z.boolean(),
}),
requestContextSchema: runContext,
execute: async ({ inputData, requestContext }) => {
const { scalekitIdentifier } = requestContext.all
const rows: Array<{
companyId: number
companyName: string
website: string
stack: string
contactName: string
contactTitle: string
}> = []
for (const account of inputData.verified) {
// Free. Returns contact previews with accuracy scores and personIds.
// No email or phone is returned here; that would require enrich_contacts,
// which is metered and deliberately not called in this pipeline.
const res = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_search_contacts',
toolInput: {
companyId: String(account.companyId),
department: 'Engineering,Information Technology',
managementLevel: 'C-Level,VP Level Executives,Director',
contactAccuracyScoreMin: '90',
pageSize: 4,
},
})
for (const contact of records<{ firstName?: string; lastName?: string; jobTitle?: string }>(res.data)) {
rows.push({
companyId: account.companyId,
companyName: account.name,
website: account.website,
stack: account.technologies.join('; '),
contactName: `${contact.firstName ?? ''} ${contact.lastName ?? ''}`.trim(),
contactTitle: String(contact.jobTitle ?? ''),
})
}
}
return { rows, creditsSpent: inputData.creditsSpent, stoppedOnBudget: inputData.stoppedOnBudget }
},
})
The list now exists in memory, attributed to one rep, built inside a spend ceiling. It has to land somewhere they can act on it.
Writing the list back under the rep's own name
A target list that returns as JSON is a demo. A target list that appears in the rep's own GTM Studio workspace is a deliverable, and it crosses a different authorization boundary: api:audience:manage rather than api:data:company. The write also behaves differently from every read so far, because zoominfo_enrich_audience returns a job ID and completes asynchronously.
const publishAudience = createStep({
id: 'publish-audience',
inputSchema: z.object({
rows: z.array(
z.object({
companyId: z.number(),
companyName: z.string(),
website: z.string(),
stack: z.string(),
contactName: z.string(),
contactTitle: z.string(),
}),
),
creditsSpent: z.number(),
stoppedOnBudget: z.boolean(),
}),
outputSchema: z.object({
audienceId: z.string(),
rowCount: z.number(),
creditsSpent: z.number(),
stoppedOnBudget: z.boolean(),
}),
requestContextSchema: runContext,
execute: async ({ inputData, requestContext }) => {
const { scalekitIdentifier, userId } = requestContext.all
// Created through this rep's connected account, so the audience is owned by
// them in GTM Studio rather than by a shared integration user.
const created = await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_create_audience',
toolInput: {
name: `Technographic target list ${new Date().toISOString().slice(0, 10)}`,
type: 'COMPANY',
description: `Generated by the technographic targeting agent for ${userId}.`,
autoMatchCriteria: true,
},
})
const [audience] = records<{ id?: unknown }>(created.data)
const audienceId = String(audience?.id ?? '')
if (!audienceId) throw new Error('create_audience returned no audience id')
// Bulk upsert caps at 500 rows per call. runEnrichment stays false: the
// pipeline already spent its verification budget, and letting GTM Studio
// re-enrich here would bill a second time outside the gate.
for (let i = 0; i < inputData.rows.length; i += 500) {
await scalekit.actions.executeTool({
connector: ZOOMINFO,
identifier: scalekitIdentifier,
toolName: 'zoominfo_upsert_audience_rows',
toolInput: {
audienceId,
runEnrichment: false,
rows: inputData.rows.slice(i, i + 500).map(r => ({
'Company Name': r.companyName,
'Website': r.website,
'Verified Stack': r.stack,
'Contact': r.contactName,
'Title': r.contactTitle,
})),
},
})
}
return {
audienceId,
rowCount: inputData.rows.length,
creditsSpent: inputData.creditsSpent,
stoppedOnBudget: inputData.stoppedOnBudget,
}
},
})
export const targetListWorkflow = createWorkflow({
id: 'technographic-target-list',
inputSchema: z.object({
icp: z.object({
requiredVendors: z.array(z.string()).min(1),
excludedVendors: z.array(z.string()).default([]),
employeeRangeMin: z.number().int().positive(),
employeeRangeMax: z.number().int().positive(),
country: z.string(),
maxAccountsToVerify: z.number().int().positive(),
}),
}),
outputSchema: z.object({
audienceId: z.string(),
rowCount: z.number(),
creditsSpent: z.number(),
stoppedOnBudget: z.boolean(),
}),
stateSchema: runState,
requestContextSchema: runContext,
})
.then(preflight)
.then(resolveAndShortlist)
.then(verifyStack)
.then(attachPersonas)
.then(publishAudience)
.commit()
Triggering it passes the same requestContext the middleware populated, plus the initial ledger state.
// src/routes/target-list.ts
import { mastra } from '../mastra/index.ts'
export async function POST(req: Request) {
const requestContext = (req as unknown as { requestContext: any }).requestContext
if (!requestContext?.get('scalekitIdentifier')) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { icp } = await req.json()
const workflow = mastra.getWorkflow('targetListWorkflow')
const run = await workflow.createRun()
const result = await run.start({
inputData: { icp },
initialState: { creditsSpent: 0, creditCeiling: 0, connectionVerified: false },
requestContext,
})
if (result.status === 'success') return Response.json(result.result)
if (result.status === 'failed') return Response.json({ error: result.error.message }, { status: 500 })
return Response.json({ status: result.status }, { status: 202 })
}
The list is in the rep's workspace, under the rep's identity, built from the rep's credential. Which means it survives exactly as long as that rep's access does.
When the rep's seat goes away
ZoomInfo is seat-licensed. Reps leave, territories move, and seats get reassigned. When that happens the refresh token behind the connected account is revoked rather than expired, and those are different failures with different recoveries. An expired access token is refreshed inline by Scalekit during executeTool and the run continues. A revoked grant cannot be recovered by retry logic at any layer; it requires the new owner to authorize again.
Three properties make that survivable:
- Fail closed by default. The preflight step returns connect_required with an authorization link. There is no service-account fallback in the code, which is the point. A fallback path converts a revocation into a silent privilege escalation where a departed rep's list keeps generating under a shared identity.
- Typed reasons, not exceptions. account_revoked and token_expired are named variants in the output schema, so the workflow branches instead of crashing and the surviving rows still publish.
- Event-driven cleanup. Scalekit's agent webhooks fire on connected account state changes, so a scheduled run can be paused the moment the grant disappears rather than at 3am when the cron fires. No polling. No stale state.
Understanding how token refresh works for AI agents is essential context here — the distinction between a token that can be refreshed and a grant that has been revoked determines whether your agent fails gracefully or silently escalates privilege.
What this architecture costs you
Nothing here is free, and two of the costs are structural rather than fixable.
What it means in practice
Every rep completes a ZoomInfo OAuth consent once before the agent works for them
Generate the link in the preflight step and surface it in the product, not in a runbook
Budgets stay tenant-pooled
Per-rep connected accounts give per-rep attribution, but credits and rate limits are enforced against the ZoomInfo tenant. One rep can still starve another
Enforce a per-run ceiling in requestContext and read X-RateLimit-Remaining-* to throttle before you see a 429
Lookup values cannot be cached across tenants
Tag IDs and intent topics are filtered by the subscription behind the token, so a cache keyed on vendor name leaks one customer's taxonomy into another's search
Key any lookup cache on the connected account identifier, never on the vendor string alone
executeTool resolves the credential server-side, adding a network hop over a direct API call with an in-process token
Real, and the trade is that no token ever enters your process, your logs, or the model context
Start building your technographic targeting agent
- Create a ZoomInfo OAuth app at developer.zoominfo.com and set the redirect URI to your Scalekit redirect URI. Choose a Standard app for internal use, a Partner app if you are distributing to other organizations.
- Add the ZoomInfo connection in the Scalekit dashboard under AgentKit then Connections, and note the connection name exactly as written. It is the connector value in every executeTool call and it is case-sensitive.
- Install the dependencies: npm install @mastra/core @scalekit-sdk/node zod, then set SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, SCALEKIT_CLIENT_SECRET, and OPENAI_API_KEY.
- Wire the middleware first and verify that requestContext.all carries scalekitIdentifier inside a tool before you write any ZoomInfo logic. An empty context that silently falls back is the bug that costs you a tenant.
- Call zoominfo_get_usage once by hand and pin the credit field names for your package before you trust the budget gate.
- Set maxAccountsToVerify to 25 on the first run, confirm the credit delta in ZoomInfo matches the number the workflow reports, then raise it.
The full connector reference is in the ZoomInfo connector docs, the tool-calling surface is documented under Tool calling and Connected accounts, and working framework examples live in the agent-auth-examples repo. If you want the same identity model applied to a different motion, the outbound prospecting agent template and the CRM agent template start from the same connected-account primitives. The patterns here are also explored in our guide to agent tool calling auth production problems and patterns. Start for free or talk to an engineer if you are wiring this into a multi-tenant product.
FAQ
Why not point Mastra at the ZoomInfo MCP server directly?
You still need a per-user token to hand it, and the MCP server exposes its full tool set, which puts the metered enrichment tools back inside the model's decision space. The gap MCP does not close is the one this build is about: minting and refreshing a credential per rep, and deciding which subset of tools that rep's agent may see. If you prefer the MCP transport, generate a per-user endpoint from Scalekit and keep the free-versus-metered split in your own surface definition rather than accepting whatever the server advertises.
Can I run this on a ZoomInfo Client Credentials app instead?
For a single-tenant internal agent, yes, and the pipeline code is unchanged apart from the identifier. You lose per-rep attribution in zoominfo_get_usage, you lose per-rep revocation, and audiences are created under the integration user rather than the rep. If you are distributing the agent to other organizations, the option does not exist: ZoomInfo Partner applications must use Authorization Code Flow with PKCE.
Does listScopedTools reflect ZoomInfo's scope grants or only Scalekit configuration?
Treat it as the authorization surface for that connected account, and still handle a provider-side 403 at call time. Entitlements can change between the moment you list the surface and the moment the agent calls a tool, so the list is a filter, not a guarantee.
Why resolve tech tag IDs on every run instead of caching them?
Lookup output is filtered by the subscription behind the token. Two of your customers can send the same vendor string and get different accepted values back, so a global cache keyed on the vendor name will hand one tenant another tenant's taxonomy. Cache on the connected account identifier or do not cache.
What stops the model from asking a human to run enrichment for it?
Nothing, and that is the correct outcome. The model can recommend that 400 accounts be verified; a person or a policy decides whether the ceiling moves. Making that request visible is the point of separating the surfaces. For approval flows that pause execution rather than reject it, see human-in-the-loop tool calling.
The enrichment job is still running when the workflow ends. What then?
zoominfo_enrich_audience returns a job ID and reports through zoominfo_get_audience_job_status with states including RUNNING, PARTIALLY_SUCCEEDED and FAILED. Suspend the workflow on the poll rather than blocking the run, and let Mastra's shared state carry creditsSpent across the resume so the ledger stays accurate through the pause.
Does the agent ever see a ZoomInfo token?
No. Scalekit resolves the credential from the vault inside executeTool, makes the call, and returns the result. The token does not enter your process, your logs, or the model context. Further detail is in Token Vault: Why It's Critical for AI Agent Workflows.