Announcing CIMD support for MCP Client registration
Learn more

Build a post-call CRM hygiene Outreach agent using Claude Agent SDK

Saif Ali Shaik
Founding Developer Advocate

TL;DR

  • Outreach returns two different 403 responses, and only one of them is knowable before the call. unauthorizedOauthScope means the token lacks the scope; unauthorizedRequest means the rep's governance settings do not let them touch that record. A scoped tool surface catches the first. Nothing about your tool list catches the second.
  • The Claude Agent SDK runs an autonomous loop, so a 403 is not a stop condition; it is context the model reasons around. A hygiene agent that gets denied on outreach_prospects_update can decide outreach_prospects_create is a reasonable alternative and leave you a duplicate prospect.
  • allowed_tools does not restrict anything. It auto-approves. By default the session also carries the full Claude Code toolset including Bash and Write, which is documented behavior. Restriction is disallowed_tools plus permission_mode="dontAsk".
  • can_use_tool fires only when the permission flow falls through to a prompt, so it never runs for tools you pre-approved. The only gate that runs on every call is a PreToolUse hook.
  • Outreach rate-limits per user at 10,000 requests per hour and caps a user/application pair at 100 live tokens. A shared service account funnels every rep's agent traffic into one bucket; per-user connected accounts give each rep their own.
  • Scalekit's Outreach connector carries 80 tools over OAuth 2.0. A Virtual MCP config declares which of them the agent role may reach, list_scoped_tools confirms the rep authorized them, and the hook denies the ones the surface cannot refuse.

A rep finishes a discovery call with Northwind. The prospect's title changed, the account has grown past the employee band on record, and the next step moved out two weeks. Twenty minutes of retyping, or an agent that reads the call activity and writes the deltas back.

The agent is not hard to build. The first version works on the rep who built it. It breaks on the second rep, and it breaks in a way that produces worse data than doing nothing.

The two 403s Outreach returns, and why only one is visible to your tool surface

Outreach OAuth scopes are period-separated pairs of a pluralized resource and an access level: prospects.read, accounts.write, calls.all. The Outreach developer portal is explicit that they are not additive; prospects.write grants no read access at all. A hygiene agent needs read and write on the same resource, so it needs both tokens or .all.

Miss a scope and the API is helpful about it:

{ "errors": [ { "id": "unauthorizedOauthScope", "title": "Unauthorized OAuth Scope", "detail": "Your authorization does not include the required scope 'prospects.read'." } ] }

Then there is the second gate. Outreach describes scopes as the front gate, and notes that holding a scope does not authorize the action on every resource: many customers run governance settings that permit a rep to manage only their own prospects, even when the app holds prospects.all. That failure looks different:

{ "errors": [ { "id": "unauthorizedRequest", "title": "Unauthorized Request", "detail": "You are not authorized to perform that request." } ] }

Two failures, precisely. One is a property of the credential; one is a property of the record.

Failure
Determined by
Knowable before the call
Enforcement plane
unauthorizedOauthScope
The scopes on the rep's connected account
Yes; the tool is absent from their scoped surface
Tool surface
unauthorizedRequest
Outreach governance rules on that specific record
No; identical tool, identical schema, different record
Per-call gate

This is the distinction that decides the architecture. A tool surface is an authentication artifact: it answers "what did this identity authorize." Record scope is an authorization question: it answers "may this identity act on this row." No amount of tool-list filtering answers the second one, because the tool that succeeds on prospect 4417 and the tool that fails on prospect 9302 are the same tool.

Your agent reasons around a 403. That is the failure mode.

In a deterministic pipeline, unauthorizedRequest raises, the job fails, someone reads a log. The Claude Agent SDK does not work that way. It runs the same loop that powers Claude Code: the tool result goes back into context, and the model decides what to do next. A denial is an input, not a halt.

Give that loop the Outreach connector and a goal phrased as "make the prospect record reflect the call," and the recovery path it finds is legitimate-looking and wrong:

  1. outreach_prospects_update on prospect 9302 returns unauthorizedRequest.
  2. The model reads a generic authorization error with no record-level detail.
  3. outreach_prospects_create is in the same connector, needs no existing record, and satisfies the goal as stated.
  4. You now have two Northwind prospects, one of them owned by the wrong rep.

Three SDK properties turn that from a bad afternoon into a structural problem.

The default tool surface is everything. The SDK's own documentation states that Claude has access to the full Claude Code toolset by default. For instance, a developer set allowed_tools=["Read", "Grep", "Ls", "Glob"], and the init SystemMessage reported Task, Bash, Glob, Grep, ExitPlanMode, Read, Edit, Write, NotebookEdit, WebFetch, TodoWrite, BashOutput, KillShell, Skill, SlashCommand. Another reported issue filed the naming itself as a security concern, noting that developers trying to restrict capability may instead grant unrestricted autonomous access.

can_use_tool is not a gate. It is invoked only when permission evaluation resolves to a prompt. Anything approved by allowed_tools, a settings allow rule, or the permission mode skips it entirely. Put your authorization logic there and pre-approve your tools, and the logic never executes. The Claude SDK reference says this directly: to gate every tool call, use a PreToolUse hook.

You cannot force the write. The Messages API exposes tool_choice; ClaudeAgentOptions does not. The loop is free to summarize the call in prose and end the turn without ever writing. A hygiene agent that silently does nothing is indistinguishable from one that worked, unless you check.

Who is the agent when the call ends?

"Post-call" implies a trigger, and the trigger is the part most designs get wrong. outreach_webhooks_create will POST to you on a call resource event, which is the right signal. It is also an unauthenticated-actor event: a webhook carries a payload, not a session. Nobody is logged in when it fires.

So before any tool runs, the orchestrator has to answer one question: on whose behalf is this run executing?

The answer is never the payload. It is a server-side resolution:

  • Read the call's prospect_id, then outreach_prospects_get for owner_id.
  • Map that Outreach owner_id to a user in your own system, and that user to a tenant.
  • Derive the Scalekit identifier from your own records, never from the request body.

Getting this wrong is the classic cross-tenant path. If identifier is a bare email, two reps at two customer organizations who share an address collide on one connected account, and the agent writes into whichever Outreach instance authorized last. Namespace it, and pass the tenant explicitly when the account is created:

# The identifier is the execution key for every later tool call, so it must be # derived server-side and must be unique across tenants. A bare email is not. identifier = f"{tenant_id}:{app_user_id}" scalekit.actions.get_or_create_connected_account( connection_name=OUTREACH_CONNECTION, identifier=identifier, organization_id=tenant_id, # tenant the account belongs to user_id=app_user_id, # your app's user, for audit correlation )

Three planes, three different jobs

Each plane below enforces something the other two structurally cannot. Dropping any one of them reopens a specific failure.

Plane
Mechanism
Enforces
Cannot enforce
Identity binding
Connected account resolved from identifier
Which Outreach user the run acts as; which tenant
Which tools, which records
Tool surface
Virtual MCP config + list_scoped_tools preflight
Which tools exist for this agent role and this rep's scopes
Which records those tools may touch
Per-call gate
PreToolUse hook
Record scope, field policy, idempotency
Anything the model never attempts

Prerequisites

pip install scalekit-sdk-python claude-agent-sdk npm install -g @anthropic-ai/claude-code

Configure an Outreach connection in the Scalekit dashboard with prospects.all, accounts.all, calls.read, tasks.all, and users.read. Request read and write separately or use .all, because scopes are not additive.

SCALEKIT_ENVIRONMENT_URL=... SCALEKIT_CLIENT_ID=... SCALEKIT_CLIENT_SECRET=... ANTHROPIC_API_KEY=...

The connection_name string in every snippet below must match the Connection name in the dashboard character for character. Scalekit's docs flag this on ScopedToolFilter specifically: connection_names takes the dashboard Connection name, not a provider slug. It is the most common integration error in this whole flow.

Plane 1: bind the run to one rep's connected account

Twelve tools are enough for post-call hygiene. Declaring them once as a Virtual MCP config gives the agent role a fixed surface, independent of any single rep.

Purpose
Tools
Read call activity
outreach_calls_list, outreach_calls_get
Read current record state
outreach_prospects_get, outreach_accounts_get, outreach_prospect_notes_list, outreach_tasks_list
Resolve the acting user
outreach_users_list
Write the deltas
outreach_prospects_update, outreach_accounts_update, outreach_prospect_note_create
Close the loop
outreach_tasks_create, outreach_task_reschedule

That leaves 68 of the connector's 80 tools outside the agent's reach, including every _delete tool and outreach_prospects_create, the tool that produced the duplicate.

import os from datetime import timedelta from scalekit import ScalekitClient scalekit = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) # Must match the Connection name in the Scalekit dashboard exactly. OUTREACH_CONNECTION = "outreach-prod" MCP_CONFIG_ID = os.environ["OUTREACH_HYGIENE_MCP_CONFIG_ID"] class ReauthRequired(Exception): ... # rep must reconnect Outreach class ScopeGap(Exception): ... # connected account lacks a write scope class HygieneIncomplete(Exception): ... # loop ended without the required write class PolicyViolation(Exception): ... # hook denied something mid-run READ_TOOLS = [ "outreach_calls_list", "outreach_calls_get", "outreach_prospects_get", "outreach_accounts_get", "outreach_prospect_notes_list", "outreach_tasks_list", "outreach_users_list", ] WRITE_TOOLS = [ "outreach_prospects_update", "outreach_accounts_update", "outreach_prospect_note_create", "outreach_tasks_create", "outreach_task_reschedule", ] def open_session(identifier: str) -> tuple[str, str]: """Confirm the rep's Outreach account is live, then mint a run-scoped token. Returns (mcp_server_url, session_token). Raises ReauthRequired if the rep needs to reconnect, so the run never starts on a dead credential. """ state = scalekit.actions.mcp.list_mcp_connected_accounts( config_id=MCP_CONFIG_ID, identifier=identifier, include_auth_link=True, # returns a re-auth URL when the account is stale ) for account in state.connected_accounts: # Casing differs across Scalekit surfaces; compare case-insensitively. if account.connected_account_status.lower() != "active": raise ReauthRequired(account.connection_name, account.authentication_link) # One config serves every rep. The token is what makes the run this rep's run. # Keep the lifetime near the expected run duration, not the maximum allowed. session = scalekit.actions.mcp.create_session_token( mcp_config_id=MCP_CONFIG_ID, identifier=identifier, expiry=timedelta(minutes=15), ) config = scalekit.actions.mcp.list_configs(filter_id=MCP_CONFIG_ID).configs[0] return config.mcp_server_url, session.token

The endpoint is static. The identity is not. Nothing in that function hands the agent an Outreach access token, and nothing in the agent process ever holds one; Scalekit injects the rep's credential at call time from the token vault and handles the refresh cycle behind it.

Plane 2: cut the surface before the model sees it

The Virtual MCP config says what the agent role may call. It says nothing about whether this particular rep authorized those scopes. list_scoped_tools answers that, and it is worth being precise about what it returns: the tools bound to this identifier's connected accounts. It reflects scope, not governance. A rep whose Outreach role cannot edit another rep's prospect still gets outreach_prospects_update in their scoped surface, and still gets unauthorizedRequest when they aim it at the wrong row.

Used for what it does answer, it converts a mid-loop unauthorizedOauthScope into a pre-run decision:

from google.protobuf.json_format import MessageToDict from scalekit.v1.tools.tools_pb2 import ScopedToolFilter def assert_write_capable(identifier: str) -> set[str]: """Fail closed if the rep's connected account is missing a write scope. Cheaper and clearer than discovering it as a 403 on turn nine. """ response = scalekit.tools.list_scoped_tools( identifier, filter=ScopedToolFilter(connection_names=[OUTREACH_CONNECTION]), page_size=100, ) # The Python SDK returns protobuf messages; convert before treating as dicts. payload = MessageToDict(response, preserving_proto_field_name=True) authorized = {tool["name"] for tool in payload.get("tools", [])} missing = [name for name in WRITE_TOOLS if name not in authorized] if missing: raise ScopeGap(identifier, missing) return authorized

Surface reduction is also the cost lever. Every tool in context is tokens spent before the agent does any work; 40 tools at roughly 200 tokens each burns 8,000 tokens per run. The effect is measurable in the wild: an issue on GitHub reports a session where MCP tool definitions alone consumed about 144,802 tokens, with a single 135-tool server accounting for roughly 125,964 of them. Loading all 80 Outreach tools to use 12 is the same mistake at smaller scale, and it degrades selection accuracy at the same time it inflates the bill. The fix is not better prompting. It is surface reduction.

Recommended Reading: Token-efficient tool calling for AI agents — MCP is up to 32× more expensive than CLI, and surface reduction is the primary lever.

Plane 3: deny the call the surface cannot refuse

Everything the tool surface cannot express lives here. The hook runs first in the permission evaluation order and it runs on every matching call, which is exactly the property can_use_tool lacks.

Three policies, none of which the Outreach tool schema can encode:

  • Record scope. The run was triggered by one call on one prospect. Any write aimed at a different prospect_id or account_id is out of bounds by construction, regardless of what the rep's Outreach role permits.
  • Field policy. outreach_prospects_update accepts 14 parameters, including owner_id. A hygiene agent has no business reassigning ownership. The schema permits it; policy does not.
  • Idempotency. Webhook redelivery is normal, and outreach_prospect_note_create has no idempotency key. Writing the same note twice is a hygiene regression caused by the hygiene agent.
from claude_agent_sdk import HookMatcher MCP_PREFIX = "mcp__outreach__" FORBIDDEN_FIELDS = {"owner_id", "emails", "phones"} def build_gate(prospect_id: int, account_id: int | None): """Return a PreToolUse hook bound to this run's record scope. Closing over the resolved IDs means the policy cannot be argued with by the model: it is Python, evaluated before the tool call leaves the process. """ executed: set[tuple[str, str]] = set() async def gate(input_data, tool_use_id, context): name = input_data["tool_name"] if not name.startswith(MCP_PREFIX): return {} # not an Outreach call; nothing to say tool = name[len(MCP_PREFIX):] args = input_data.get("tool_input", {}) def deny(reason: str) -> dict: # The reason goes back to the model as context, so make it specific # enough to redirect and vague enough not to leak policy internals. return {"hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason, }} # 1. Record scope. Compare as int; the connector types these as integers. if "prospect_id" in args and int(args["prospect_id"]) != prospect_id: return deny(f"This run may only write to prospect {prospect_id}.") if "account_id" in args and account_id is not None \ and int(args["account_id"]) != account_id: return deny(f"This run may only write to account {account_id}.") # 2. Field policy. Reassignment and contact-channel edits are off-limits. if tool in ("outreach_prospects_update", "outreach_accounts_update"): blocked = FORBIDDEN_FIELDS & args.keys() if blocked: return deny(f"Fields not writable by this agent: {sorted(blocked)}.") # 3. Idempotency within the run. Keyed on the tool plus its semantic # target so a retried note or a repeated update is refused once. # Recorded before execution, so a genuinely failed write is also # refused on retry. If you need retry-on-failure, record the key in a # PostToolUse hook on success instead and accept the wider window. if tool in WRITE_TOOLS: key = (tool, str(args.get("prospect_id") or args.get("account_id") or args.get("task_id") or "")) if key in executed: return deny(f"{tool} already succeeded for this target in this run.") executed.add(key) return {} # fall through to the allow rules return HookMatcher(matcher=f"{MCP_PREFIX}.*", hooks=[gate])

Returning {} falls through rather than approving, which matters: a hook allow does not skip deny rules, and deny rules win in every permission mode including bypassPermissions. The hook narrows; it never widens. To rewrite arguments instead of refusing them, return updatedInput inside hookSpecificOutput alongside permissionDecision: "allow"; putting it at the top level silently does nothing.

Durable deduplication belongs upstream of the agent. Before the run starts, list the prospect's notes and refuse to launch if one already carries this call's tag:

def already_processed(identifier: str, prospect_id: int, call_id: int) -> bool: """Guard against webhook redelivery across process restarts.""" result = scalekit.actions.execute_tool( tool_input={"filter_prospect_id": prospect_id, "page_size": 25, "sort": "-createdAt"}, tool_name="outreach_prospect_notes_list", identifier=identifier, ) return f"[hygiene:{call_id}]" in str(result.data)

Wire the agent shut, then run it

allowed_tools auto-approves; it does not restrict. Restriction takes three separate options, and each one closes a different hole.

import asyncio from claude_agent_sdk import ( ClaudeSDKClient, ClaudeAgentOptions, AssistantMessage, ToolUseBlock, TextBlock, ResultMessage, ) # Observed default toolset, per issue #361. allowed_tools does not remove these, # so name them explicitly: a bare deny rule strips the tool from Claude's context. CLAUDE_CODE_BUILTINS = [ "Task", "Bash", "Glob", "Grep", "ExitPlanMode", "Read", "Edit", "Write", "NotebookEdit", "WebFetch", "TodoWrite", "BashOutput", "KillShell", "Skill", "SlashCommand", ] SYSTEM_PROMPT = """You reconcile one Outreach prospect record against one \ logged call. Read the call record, the prospect, the account, and recent notes. Write only \ fields the call evidences. Leave every unevidenced field alone. Required final action: log a prospect note whose message begins with the run \ tag you were given. A run without that note is a failed run. You cannot change record ownership or contact channels. If a write is denied, \ stop and report it. Do not create records, and do not substitute a different \ record for a denied one.""" async def run_hygiene(identifier: str, call_id: int, prospect_id: int, account_id: int | None) -> ResultMessage: mcp_url, token = open_session(identifier) assert_write_capable(identifier) options = ClaudeAgentOptions( model="sonnet", # pin a full model ID in production system_prompt=SYSTEM_PROMPT, # a plain string replaces the preset entirely # Per-run identity. The URL is shared; the bearer token is not. mcp_servers={"outreach": { "type": "http", "url": mcp_url, "headers": {"Authorization": f"Bearer {token}"}, }}, # Ignore .mcp.json, user settings, plugin servers, and claude.ai # connectors. A prod agent must not inherit a developer's local MCP set. strict_mcp_config=True, # Do not read ~/.claude or .claude from disk. Requires SDK > 0.1.59 for # an empty list to take effect rather than being treated as omitted. setting_sources=[], allowed_tools=[f"{MCP_PREFIX}{t}" for t in READ_TOOLS + WRITE_TOOLS], disallowed_tools=CLAUDE_CODE_BUILTINS, # Deny anything not pre-approved instead of waiting on a prompt that # will never be answered in an unattended run. permission_mode="dontAsk", hooks={"PreToolUse": [build_gate(prospect_id, account_id)]}, max_turns=14, # bounds a loop that retries a denial max_budget_usd=0.40, # bounds the cost of one prospect ) task = ( f"Run tag: [hygiene:{call_id}]\n" f"Call {call_id} on prospect {prospect_id}" + (f", account {account_id}" if account_id else "") + ".\nReconcile the record against the call and log the note." ) executed_writes: list[str] = [] final: ResultMessage | None = None async with ClaudeSDKClient(options=options) as client: await client.query(task) async for message in client.receive_response(): if isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, ToolUseBlock): tool = block.name.removeprefix(MCP_PREFIX) if tool in WRITE_TOOLS: executed_writes.append(tool) elif isinstance(block, TextBlock): print(block.text) elif isinstance(message, ResultMessage): final = message # No tool_choice exists on ClaudeAgentOptions (issue #655), so the loop can # end without writing. Verify rather than assume. if final is None or "outreach_prospect_note_create" not in executed_writes: raise HygieneIncomplete(call_id, executed_writes) if final.permission_denials: raise PolicyViolation(call_id, final.permission_denials) return final asyncio.run(run_hygiene( identifier="acme:user_8812", call_id=90144, prospect_id=4417, account_id=2208, ))

ResultMessage.permission_denials is the audit signal worth alerting on. A denial means the model attempted something the hook refused, and a pattern of denials on the same tool is a policy or prompt problem, not noise. Pair it with num_turns and total_cost_usd per run and you have the operational picture; for the compliance picture, Scalekit logs every execute_tool call against the connected account that made it, which is the trail a security questionnaire on agent auth actually asks for.

Two connector behaviors will bite this specific agent. outreach_tasks_create requires both owner_id and prospect_id, and owner_id must resolve to the authorizing rep rather than an admin, which is why outreach_users_list with filter_email is in the read set. And outreach_tasks_complete works only on action_item and in_person tasks; call and email tasks cannot be completed through it, so a hygiene agent reschedules rather than completes. Both are documented on the Outreach connector page.

What breaks at 200 prospects

One prospect works. A nightly sweep is a different system, and the constraints are all upstream.

Constraint
Value
What it does to a batch
Outreach access token
2 hours
A long sweep outlives the token; refresh must be proactive, not on 401
Outreach refresh token
14 days, rotated on every use
Persisting a stale refresh token breaks the chain silently
Tokens per user/app pair
100 live
Parallel runs on one shared identity exhaust the ceiling
Access token issuance
1 per user per 60 seconds
Faster than that returns 429
API rate limit
10,000 requests/hour, per user
Shared credentials concentrate the whole fleet into one bucket
Kaia recordings
3/second, 6,000/day, org-level
Transcript access does not scale per rep

The per-user numbers are the case for connected accounts that has nothing to do with security. Because Outreach computes the rate limit from the user attached to the token, twenty reps on twenty connected accounts have twenty independent 10,000-per-hour budgets and twenty independent 100-token ceilings. Route the same twenty reps through one service account and you have one of each, plus an agent that holds admin-level access no individual rep has. What the user can't do, the agent can't do; that property is only available if the agent runs as the user.

Two more things worth knowing before the sweep goes nightly. The connector exposes call records, not transcripts: outreach_calls_get returns direction, outcome, note, and a recording URL, so the agent reasons over what the rep logged and the disposition they picked. Design for that, not for a transcript you do not have.

Recommended Reading: How to handle token refresh for AI agents and Single vs multi-tenant tool calling.

Where this design costs you

  • Three planes means three places to change a policy. Adding a writable field touches the Virtual MCP config, the hook's field list, and the system prompt. That redundancy is deliberate, and it is still redundancy.
  • A session per prospect costs latency and prompt cache reuse. You trade throughput for a bounded blast radius and clean per-prospect attribution. On a nightly batch that is usually correct. On an interactive assistant it usually is not.
  • The record-scope check assumes single-record runs. An agent reconciling a whole account's contacts needs a set-membership check resolved server-side before the run, not the equality check above.
  • disallowed_tools is an enumeration, and enumerations rot. A new built-in tool ships and your deny list does not cover it. Assert the surface at startup by reading the init SystemMessage rather than trusting the list you wrote.
  • Denial reasons are model context. A specific reason redirects the agent well and tells anyone reading the transcript how your policy works. Write them to be actionable, not descriptive of the rule.

FAQs

Can the agent read the Kaia transcript to extract what changed?

Not through this connector; its 80 tools cover prospects, accounts, calls, sequences, tasks, templates, and webhooks, with no transcript tool. Kaia access is also rate-limited at the org level (3 calls/second, 6,000/day), so it does not scale with rep count the way per-user record access does. The agent works from the call's note, outcome, and disposition.

Why not run the whole org on one service account and skip connected accounts?

Three costs. Every rep gets identical access, usually admin-level, so the agent can reach records no individual rep can. Outreach's per-user rate limit and 100-token ceiling collapse to a single bucket. And every write lands under one identity, so the audit trail cannot tell you which rep's call produced which change. Service accounts have a place for genuine org-level background jobs; a per-rep hygiene agent is not one.

Should the authorization check go in can_use_tool or a PreToolUse hook?

The hook. can_use_tool runs only when permission evaluation falls through to a prompt, and every tool in allowed_tools skips it. In an unattended agent with pre-approved tools, a can_use_tool handler is code that never executes.

What happens when the rep's Outreach role genuinely cannot write the prospect?

Outreach returns 403 unauthorizedRequest. Nothing in the tool surface predicts it, because scope and governance are separate gates. Surface it as a run failure with the record ID attached and route it to the record owner; do not let the loop improvise a workaround.

Does the agent process ever hold an Outreach token?

No. It holds a short-lived Scalekit session token scoped to one config and one identifier. Scalekit resolves the rep's Outreach credential from the vault at call time and manages the 2-hour access token and 14-day rotating refresh token. Credentials never touch the agent runtime.

Two reps at different customers share an email address. What breaks?

The connected account, if identifier is the bare email. Namespace the identifier with the tenant and pass organization_id when creating the account, so resolution is unambiguous and the audit trail names a tenant.

The session token expires mid-run. Then what?

Tool calls start failing on auth and the loop will try to reason around them. Mint the token immediately before the run with a lifetime sized to the expected duration, cap max_turns, and treat an auth failure as terminal rather than retryable. A run that outlives its token should end, not improvise.

Start building

  1. Add the Outreach connection in the Scalekit dashboard with prospects.all, accounts.all, calls.read, tasks.all, and users.read, then confirm the tool names against the Outreach connector docs.
  2. Create the Virtual MCP config with the 12 hygiene tools using actions.mcp.create_config, and keep outreach_prospects_create and every _delete tool out of it.
  3. Wire the webhook-to-identity resolution before anything else; the run cannot start without an identifier derived server-side.
  4. Harden ClaudeAgentOptions first and add the hook second, then verify against the init SystemMessage that the surface is what you declared.
  5. Scaffold it from your editor: /plugin marketplace add scalekit-inc/claude-code-authstack then /plugin install agentkit@scalekit-auth-stack.

Adjacent builds worth reading before you start: the Post-Call CRM Agent guide, the deal intelligence agent, and agent tool calling auth patterns for the capability tradeoff between the two paths.

Start with the AgentKit quickstart, or talk to an engineer about the multi-tenant shape.

No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.