Announcing CIMD support for MCP Client registration
Learn more

Build a PagerDuty Incident Enrichment Agent with the Claude Agent SDK

Vishal Dhawani
Founding Architect @ Scalekit

TL;DR

  • Every write in the PagerDuty connector carries two identities, not one: the OAuth token the call authenticates with, and from_email, the PagerDuty user the action is attributed to in the incident timeline. A reasoning loop controls the second one, because from_email is just another tool parameter the model fills in.
  • PagerDuty documents a workaround for reassignment that sets the From header to the intended assignee. That pattern is in the model's training data. An agent that reaches for it produces a timeline saying the target reassigned the incident to themselves, and no exception is raised.
  • The fix is structural, not prompt-based: strip from_email out of the input_schema before registering the tool, and inject the server-resolved value inside the handler closure. What the model cannot see, it cannot forge.
  • actions.tools.list_scoped_tools cuts the surface from 42 PagerDuty tools (six of which delete something) to the ten a triage agent needs; execute_tool binds every call to that responder's connected account, and no PagerDuty token enters the process or the model context.
  • allowed_tools is a permission allowlist, not an availability filter, and can_use_tool never fires for allowlisted calls. A PreToolUse hook is the only gate that sees every pagerduty_incident_update before it executes.
  • Four PagerDuty behaviors will silently corrupt a naive triage agent: status sent alongside assignee_id drops the assignment, assignee_id and escalation_policy_id are mutually exclusive, assigned_via: direct_assignment stops escalation, and priority IDs are account-scoped opaque strings.

An SRE pastes one PagerDuty API key into .env, wires it into a Claude Agent SDK tool, and the triage agent works. It pulls the triggered incidents, reads the trigger log, sets P1 and high urgency on the checkout outage, and reassigns it to the payments on-call. Green run. Then the second responder runs it on their shift.

The agent still reassigns correctly. The incident timeline says every one of those changes was made by the first responder, because that is whose email is sitting in the From header of every write. The postmortem asks who bumped the priority at 02:14. The answer in PagerDuty is a person who was asleep.

That is not a reasoning failure. It is a property of how PagerDuty writes work, and of how the Agent SDK registers tools.

Why Every PagerDuty Write Has Two Identity Planes

PagerDuty's REST API separates authentication from attribution on write operations. The token proves the call is allowed. The From header names the user the change is recorded against. Scalekit surfaces this faithfully: from_email is a required parameter on pagerduty_incident_update, pagerduty_incident_note_create, pagerduty_incident_create, and pagerduty_incident_manage.

Plane
Carried by
Enforces
Fails with
Authentication
OAuth access token in the vault
Which account, which scopes
403 and a body naming required_scopes and token_scopes
Authorization
The from_email user's PagerDuty role and team access
Whether that user may touch this incident
illegitimate_requester_error, code 2001
Attribution
from_email
What the incident timeline and log entries record
Nothing; it just writes the wrong name

The third row is the dangerous one. The first two fail loudly. Attribution fails silently, and it is the plane a reasoning loop can reach.

Three concrete traps sit on top of this:

  • Classic User OAuth cannot write reliably. A PagerDuty community thread walks through a Classic User OAuth app that accepts a write scope in the UI but does not honor it programmatically; the guidance is to register a Scoped OAuth app instead. Configure the connection wrong and your agent reads perfectly, reports success, and changes nothing.
  • PagerDuty's own reassignment workaround forges the timeline. A developer sends status: acknowledged and assignments in one PUT; the assignment is ignored and the incident lands on the From user instead. PagerDuty's team confirms the behavior (acknowledging claims ownership and halts escalation) and offers two options: put the assignee in From, or send a second request. Option one is attribution forgery. A model that has read the same public thread will pick it.
  • Direct assignment stops escalation. PagerDuty's API reference notes that incidents with assigned_via: direct_assignment do not escalate up the attached escalation policy. An agent that reassigns by assignee_id has removed the safety net; if that person is unreachable, nobody else gets paged.

None of this is exotic. It is the normal shape of incident-response writes, and it is exactly the shape that an autonomous loop with a shared credential gets wrong.

Where the Claude Agent SDK Stops and Agent Auth Begins

The SDK's tool primitive is create_sdk_mcp_server: define functions with @tool, bundle them, hand the server to the agent. Clean model, single-user defaults.

SDK primitive
What it assumes
What breaks in a multi-responder agent
The Scalekit hinge
create_sdk_mcp_server(tools=[...])
Tools defined once, statically, at startup
Every responder gets the same surface; there is no per-identity surface
list_scoped_tools returns only what this responder's connected account authorizes
@tool handler async def handler(args)
The handler carries its own credential
One PagerDuty token serves the whole rotation; every write is attributed to it
execute_tool resolves the responder's vaulted token server-side, per call
The input_schema you pass to @tool
Every parameter is the model's to fill
from_email becomes model-controlled, and attribution becomes a prompt-following problem
Strip it from the schema; inject the resolved identity in the closure
allowed_tools=[...]
You can enumerate names ahead of time
Names are minted per responder at runtime
Derive the allowlist from what list_scoped_tools returned

The rest of this build is one conversion: take the SDK's static, ambient-credential tool surface and make it per-run and identity-bound on both planes. Resolve who, scope what, bind whose, gate which, contain everything else, then run.

Prerequisites

  • Python 3.10 or newer, and pip install claude-agent-sdk scalekit-sdk-python protobuf python-dotenv. On clean virtualenvs install protobuf explicitly; some images do not pull it in.
  • An ANTHROPIC_API_KEY. The Python SDK drives the Claude Code runtime underneath; install the CLI if the SDK asks for it.
  • A Scalekit account (the free tier covers 10K connected accounts) with a PagerDuty connection created under AgentKit > Connections.
  • In PagerDuty, go to Integrations > App Registration and register a Scoped OAuth app, not Classic User OAuth. Select at minimum incidents.read, incidents.write, services.read, escalation_policies.read, oncalls.read, schedules.read, users.read, and teams.read. The scope picker lists exactly what your account supports; if a priorities read scope appears, add it.
  • The connection_name in your code must match the connection name in the Scalekit dashboard character for character, including any suffix added at creation. This is the single most common integration failure.
  • Each responder's PagerDuty login email must match the email your session resolves for them. A mismatch surfaces as illegitimate_requester_error inside a write, long after the run looked healthy.

PagerDuty OAuth access tokens expire and PagerDuty's guidance is to implement refresh rather than re-prompting users. Scalekit's vault handles that; see token refresh for long-running agents for why doing it yourself is harder than it looks.

# .env SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.cloud SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=sks_... ANTHROPIC_API_KEY=sk-ant-... ANTHROPIC_MODEL=claude-sonnet-4-6 # must match Scalekit Dashboard > AgentKit > Connections exactly PAGERDUTY_CONNECTION=pagerduty # resolved from your authenticated session in production; env vars here for a single run RESPONDER_IDENTIFIER=user_123 RESPONDER_EMAIL=priya@example.com PD_SERVICE_IDS=PXXXXXX,PYYYYYY

Step 1: Resolve the Responder on Both Planes

One run is bound to three identifiers, and all three are resolved server-side before any tool exists. The Scalekit identifier selects the vaulted token. The from_email drives attribution. The PagerDuty user ID is what assignment comparisons run against.

import asyncio import json import os from dataclasses import dataclass from dotenv import load_dotenv from scalekit.client import ScalekitClient load_dotenv() scalekit = ScalekitClient( env_url=os.environ["SCALEKIT_ENVIRONMENT_URL"], client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], ) actions = scalekit.actions # Must match the connection name in the Scalekit dashboard, exactly. PD_CONNECTION = os.environ["PAGERDUTY_CONNECTION"] @dataclass(frozen=True) class Responder: """The three identifiers one run is bound to. None come from the model.""" identifier: str # your user ID; the key for the Scalekit connected account from_email: str # PagerDuty login email; the attribution plane pd_user_id: str # PagerDuty user ID; the assignment plane def ensure_connected(identifier: str) -> bool: """Idempotent. True when this responder's PagerDuty account is ACTIVE.""" account = actions.get_or_create_connected_account( connection_name=PD_CONNECTION, identifier=identifier, ) if account.connected_account.status == "ACTIVE": return True # First run, or the responder revoked access. Mint a consent URL inline. link = actions.get_authorization_link( connection_name=PD_CONNECTION, identifier=identifier, ).link print(f"PagerDuty not authorized for {identifier}. Authorize here:\n {link}") return False def resolve_responder(identifier: str, session_email: str) -> Responder: """Bind the Scalekit identifier to a real PagerDuty user, before any write. session_email comes from your authenticated session (cookie, verified JWT, or a database lookup). PagerDuty's users query is a loose match, so the exact address is verified here rather than trusted. """ result = actions.execute_tool( tool_name="pagerduty_users_list", identifier=identifier, tool_input={"query": session_email, "limit": 25}, connection_name=PD_CONNECTION, ) # result.data carries the provider response body; print one call before # wiring the parse if you are on a connector you have not used before. users = (result.data or {}).get("users", []) match = next( (u for u in users if u.get("email", "").lower() == session_email.lower()), None, ) if match is None: # Almost always an SSO email that differs from the PagerDuty login email. # Fail here, not later inside a write with illegitimate_requester_error. raise RuntimeError(f"No PagerDuty user matches {session_email}") return Responder(identifier, match["email"], match["id"])

The tenant boundary rests entirely on identifier and session_email being trustworthy. Resolve both server-side; never accept either from client input. Access control for multi-tenant AI agents covers what goes wrong when that rule slips.

Step 2: Retrieve the Authorized Surface, Then Narrow It by Role

actions.tools.list_scoped_tools returns the tools this responder's connected account authorizes, already in Anthropic's native format. That is the identity gate. The role gate is yours: a triage agent needs ten of the connector's 42 tools.

The tool bloat argument needs a caveat here, because tool search is on by default in the Agent SDK and defers full schemas until Claude loads one, which softens the token cost. Two problems it does not touch:

  • Selection accuracy. Forty-two similarly prefixed names (pagerduty_incident_update, pagerduty_incident_manage, pagerduty_service_update) is a decision space the model was not designed to handle at that scale. Surface reduction is the lever. Model upgrades help; they are not the lever.
  • Blast radius. Six of those tools delete something, including pagerduty_service_delete and pagerduty_user_delete. A deferred pagerduty_user_delete is still a callable pagerduty_user_delete.

At roughly 200 tokens per schema, 42 tools is about 8,400 tokens of surface; ten is about 2,000.

from google.protobuf.json_format import MessageToDict # Reads gather evidence. The two writes are this agent's entire authority to # change state. Every create_* and delete_* in the connector is absent on purpose. READ_TOOLS = { "pagerduty_incidents_list", "pagerduty_incident_get", "pagerduty_log_entries_list", "pagerduty_service_get", "pagerduty_escalation_policy_get", "pagerduty_oncalls_list", "pagerduty_users_list", "pagerduty_priorities_list", } WRITE_TOOLS = { "pagerduty_incident_update", # priority, urgency, assignee, escalation policy "pagerduty_incident_note_create", # the triage rationale, on the incident } TRIAGE_TOOLS = READ_TOOLS | WRITE_TOOLS # from_email is an identity claim, not a parameter. It never reaches the model. SERVER_INJECTED = {"from_email"} def _strip_server_fields(schema: dict) -> dict: """Delete server-injected params from the JSON Schema Claude receives. This is the load-bearing line of the whole build: a field absent from the schema is a field the model cannot populate, cannot reason about, and cannot borrow from a public workaround it read during training. """ properties = { k: v for k, v in schema.get("properties", {}).items() if k not in SERVER_INJECTED } required = [r for r in schema.get("required", []) if r not in SERVER_INJECTED] return {**schema, "properties": properties, "required": required} def discover_triage_tools(identifier: str) -> list[dict]: """Identity gate (Scalekit), then role gate (this function), in that order.""" scoped_response, _ = actions.tools.list_scoped_tools( identifier=identifier, filter={"connection_names": [PD_CONNECTION]}, page_size=100, ) tools = [] for item in scoped_response.tools: definition = MessageToDict(item.tool).get("definition", {}) name = definition.get("name") if name not in TRIAGE_TOOLS: continue # role gate: 42 authorized tools down to 10 tools.append({ "name": name, "description": definition.get("description", ""), "input_schema": _strip_server_fields(definition.get("input_schema", {})), }) return tools

A third layer applies at execution and is invisible here: because every call runs against the responder's own token, PagerDuty's team and role restrictions still apply. What the responder cannot do in PagerDuty, the agent cannot do.

Recommended Reading: How to Implement Least Privilege for AI Agent Tool Calls

Step 3: Bind the Identity the Model Never Sees

The @tool handler signature is async def handler(args). It receives the model's arguments and nothing else, so the responder has to be captured in a closure when the tool is built, per run. Build the server once at import time with a module global and every shift executes as one person; that is the opening failure, reintroduced one layer up.

from claude_agent_sdk import tool, ToolAnnotations def make_pd_tool(tool_def: dict, responder: Responder): """Wrap one Scalekit tool as an in-process SDK tool bound to one responder. execute_tool resolves that responder's vaulted PagerDuty token server-side; no token enters this process, this handler, or the model context. """ name = tool_def["name"] is_write = name in WRITE_TOOLS @tool( name, tool_def["description"], tool_def["input_schema"], # readOnlyHint lets Claude batch the enrichment reads in parallel and # keeps the two writes sequential. annotations=ToolAnnotations(readOnlyHint=not is_write), ) async def _handler(args: dict) -> dict: payload = dict(args) if is_write: # Attribution plane, set here and nowhere else. This is the value # PagerDuty records in the incident timeline and the log entry. payload["from_email"] = responder.from_email try: # execute_tool is blocking; keep the agent loop responsive. result = await asyncio.to_thread( actions.execute_tool, tool_name=name, identifier=responder.identifier, # the acting responder, per run tool_input=payload, connection_name=PD_CONNECTION, ) return { "content": [ {"type": "text", "text": json.dumps(result.data or {}, default=str)} ] } except Exception as exc: # A revoked connection, a 403 on scopes, or illegitimate_requester_error # should degrade this incident, not kill the run. An uncaught exception # is converted by the SDK anyway; catching it lets you compose the # message Claude reads. return { "content": [{"type": "text", "text": f"{name} failed: {exc}"}], "is_error": True, } return _handler

Tokens live in Scalekit's vault and are injected server-side inside execute_tool. The agent sees results, never credentials; see token vaults for AI agent workflows for why keeping credentials out of the model context matters.

Step 4: Gate the One Tool That Can Corrupt an Incident

pagerduty_incident_update is where four PagerDuty behaviors collide. Prompt instructions are the wrong control surface for them, because a reasoning loop that has read the same public docs has a plausible reason to do each wrong thing.

Two SDK facts decide which control to use. allowed_tools auto-approves, so can_use_tool is never invoked for a tool you allowlisted. The docs are explicit that a PreToolUse hook is what gates every call.

from claude_agent_sdk import HookMatcher SERVER_NAME = "pd_triage" UPDATE_TOOL = f"mcp__{SERVER_NAME}__pagerduty_incident_update" def _deny(reason: str) -> dict: """PreToolUse deny. permissionDecisionReason is read by Claude, so it doubles as the correction that keeps the loop moving instead of retrying blindly.""" return { "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason, } } def make_update_gate(oncall_user_ids: set[str], priority_ids: set[str]): """Deterministic policy on the only tool that changes incident state. Both sets are resolved once per run, before the loop starts, so the model cannot widen its own policy mid-run by calling a read tool again. """ async def gate(input_data, tool_use_id, context): args = input_data.get("tool_input", {}) # 1. This agent triages; it does not own or close. Acknowledging claims # ownership for from_email and halts escalation; resolving closes the # incident. PagerDuty also silently drops assignments sent with a status. if args.get("status"): return _deny( "Do not change incident status. Set priority_id, urgency, and " "assignment only; a human acknowledges." ) # 2. PagerDuty rejects an assignee and an escalation policy in one update. if args.get("assignee_id") and args.get("escalation_policy_id"): return _deny("Send assignee_id or escalation_policy_id, never both.") # 3. Direct assignment sets assigned_via=direct_assignment, which stops # escalation. It must therefore land on someone who is paged right now. assignee = args.get("assignee_id") if assignee and assignee not in oncall_user_ids: return _deny( f"{assignee} is not on call. Direct assignment halts escalation, so " "reassign to the on-call responder or hand off with escalation_policy_id." ) # 4. Priority IDs are opaque and account-scoped. A hardcoded or invented # ID is correct in your account and wrong in every customer tenant. priority = args.get("priority_id") if priority and priority not in priority_ids: return _deny("Use a priority_id returned by pagerduty_priorities_list.") return {} # no opinion; fall through to the allowlist return gate

The tradeoff on check three is real: restricting reassignment to the current on-call set trades recall for containment. If your rotation is thin, or triage routinely pulls in a named subject-matter expert, widen the set to team membership resolved from pagerduty_teams_list rather than dropping the check. What you should not do is let the model choose the boundary. For the cases where a human genuinely has to decide, human-in-the-loop tool calling is the pattern to reach for.

Step 5: Contain Everything Else

from claude_agent_sdk import ClaudeAgentOptions, create_sdk_mcp_server def resolve_run_context(responder: Responder) -> tuple[set[str], set[str]]: """Pre-compute the two sets the PreToolUse gate enforces. Narrow the on-call call with escalation_policy_ids when the agent owns a fixed set of services; earliest=True keeps one entry per policy and level. """ oncalls = actions.execute_tool( tool_name="pagerduty_oncalls_list", identifier=responder.identifier, tool_input={"earliest": True, "limit": 100}, connection_name=PD_CONNECTION, ) oncall_ids = { entry["user"]["id"] for entry in (oncalls.data or {}).get("oncalls", []) if entry.get("user") } priorities = actions.execute_tool( tool_name="pagerduty_priorities_list", identifier=responder.identifier, tool_input={}, connection_name=PD_CONNECTION, ) priority_ids = {p["id"] for p in (priorities.data or {}).get("priorities", [])} return oncall_ids, priority_ids def build_options(scoped_tools, responder, oncall_ids, priority_ids): server = create_sdk_mcp_server( name=SERVER_NAME, version="1.0.0", tools=[make_pd_tool(t, responder) for t in scoped_tools], ) # Derived from what list_scoped_tools returned, never typed by hand. allowed = [f"mcp__{SERVER_NAME}__{t['name']}" for t in scoped_tools] return ClaudeAgentOptions( mcp_servers={SERVER_NAME: server}, strict_mcp_config=True, # ignore .mcp.json, user settings, any other server allowed_tools=allowed, # auto-approve exactly this surface tools=[], # remove every built-in: no Bash, Write, WebFetch setting_sources=[], # no filesystem settings (needs Python SDK > 0.1.59) permission_mode="dontAsk", # unattended: deny, do not prompt and hang hooks={ "PreToolUse": [ HookMatcher( matcher=UPDATE_TOOL, hooks=[make_update_gate(oncall_ids, priority_ids)], ) ] }, max_turns=40, max_budget_usd=2.00, model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-4-6"), system_prompt=TRIAGE_SYSTEM_PROMPT, )

tools=[] is the one people skip. allowed_tools changes permission, not availability; without tools=[] the entire Claude Code built-in set, Bash included, stays in context on a host that has incident-response credentials on it. permission_mode="dontAsk" matters for the same reason: on an unattended run, a prompt is a hang.

Step 6: Run the Loop

The scoring rubric belongs in the system prompt. The identity does not appear there at all, because it is not the model's business.

TRIAGE_SYSTEM_PROMPT = """You triage newly triggered PagerDuty incidents. Enrich first, decide second, write last. Never change incident status. 1. pagerduty_incidents_list with statuses=triggered, the given service_ids, a since timestamp in full ISO 8601, and include=first_trigger_log_entries,services. 2. Per incident: pagerduty_incident_get for current assignment, urgency and priority, and pagerduty_service_get with include=escalation_policies for the owning service. 3. pagerduty_log_entries_list is account-wide, not per incident. Call it once with since set to the start of your window and include=incidents, then correlate entries to incidents yourself. 4. Call pagerduty_priorities_list once. Use only the IDs it returns; priority IDs belong to this PagerDuty account and cannot be constructed. 5. Call pagerduty_oncalls_list once to see who is actually paged right now. 6. Set priority_id and urgency from blast radius: customer-facing or multi-service goes high urgency and the top priority; a single internal service with a known runbook in the trigger log goes low urgency. 7. Route the incident: - Right team, wrong person: pagerduty_incident_update with assignee_id set to the on-call user for that service's escalation policy. Send no status in that call. - Wrong team: pagerduty_incident_update with escalation_policy_id set to the owning team's policy. Never send assignee_id and escalation_policy_id together. 8. Follow every update with pagerduty_incident_note_create: two lines naming the evidence you used and why you routed it there. If a tool fails, note it in your summary and continue to the next incident.""" async def run_triage(identifier: str, session_email: str, service_ids: str) -> None: from claude_agent_sdk import ( ClaudeSDKClient, AssistantMessage, TextBlock, ResultMessage, ) if not ensure_connected(identifier): return # consent link printed; nothing has touched PagerDuty yet responder = resolve_responder(identifier, session_email) scoped_tools = discover_triage_tools(identifier) oncall_ids, priority_ids = resolve_run_context(responder) options = build_options(scoped_tools, responder, oncall_ids, priority_ids) print(f"{len(scoped_tools)} tools registered for {responder.identifier}") prompt = ( f"Triage every triggered incident on services {service_ids} " "from the last 2 hours." ) async with ClaudeSDKClient(options=options) as client: await client.query(prompt) async for message in client.receive_response(): if isinstance(message, AssistantMessage): for block in message.content: if isinstance(block, TextBlock): print(block.text) elif isinstance(message, ResultMessage) and message.subtype == "success": print("[run complete]", message.result) if __name__ == "__main__": # Both values come from your authenticated session in production. asyncio.run(run_triage( identifier=os.environ["RESPONDER_IDENTIFIER"], session_email=os.environ["RESPONDER_EMAIL"], service_ids=os.environ["PD_SERVICE_IDS"], ))

Change RESPONDER_IDENTIFIER and RESPONDER_EMAIL and everything underneath changes with it: a different vaulted token, a different scoped surface, a different set of PagerDuty permissions, a different name in every timeline entry. No code changes. Adding Datadog or Linear to the enrichment step adds a connection name and a few tool names; it adds no auth code, because the vault, the scope checks, and the per-action audit trail are identical for every connector.

What Breaks Without This

Shortcut
Demo result
Production failure
One PagerDuty API key in .env, static @tool handlers
Works for whoever owns the key
Every priority bump and reassignment is attributed to one account; the postmortem cannot name who triaged
from_email left in the input_schema
Model fills it correctly under test
Model reaches for PagerDuty's documented From-as-assignee workaround; the timeline records a reassignment the target made to themselves
Classic User OAuth on the connection
Reads work; the run looks complete
Writes return 403 with required_scopes in the body, and the agent reports a successful triage that changed nothing
allowed_tools set, no tools=[]
Correct tools used in testing
Bash, Write, and WebFetch stay in context on an incident-response host
status and assignee_id in one pagerduty_incident_update
HTTP 200 and the incident is acknowledged
Assignment silently dropped, ownership lands on from_email, escalation halts
Reassign by assignee_id without an on-call check
The right name appears on the incident
assigned_via: direct_assignment stops escalation; if that person is offline nobody else is ever paged
Full 42-tool surface registered
Tool calls resolve
pagerduty_service_delete and pagerduty_user_delete are one confident wrong turn away

FAQs

Why not just instruct the model in the system prompt to set from_email correctly?

Because PagerDuty's own community guidance says to put the assignee in the From header, and that guidance is public, indexed, and plausible. You would be asking the model to prefer your instruction over a documented workaround that appears to solve the exact task, on every call, forever. Removing the field from the schema is one line and has no failure mode.

Should I use can_use_tool or a PreToolUse hook for the write gate?

The hook. can_use_tool fires only when the permission flow falls through to a prompt, and every tool in allowed_tools is auto-approved before it gets there. Drop the tools from the allowlist and you get a prompt on every call, which defeats an unattended run.

The responder's SSO email is not their PagerDuty login email. What happens?

resolve_responder raises before any tool is registered, which is the point of doing the lookup up front. Without it, the run enriches happily and then fails on the first write with illegitimate_requester_error, code 2001, after the agent has already burned turns.

Should the agent acknowledge the incident it just triaged?

No. Acknowledging claims ownership for the from_email user and halts escalation, which is a human decision about who is awake and working the problem. An agent that acknowledges has quietly turned off the paging system for that incident. The gate in Step 4 denies any status for this reason.

Do PagerDuty tokens ever reach the model context?

No. They stay in Scalekit's vault and are resolved server-side inside execute_tool. The handler receives arguments and returns results; it never holds a credential. PagerDuty also rate limits per token, so per-responder connected accounts spread agent traffic across buckets instead of concentrating it in one shared key.

Can one ClaudeSDKClient serve the whole on-call rotation?

No. The responder is closed over in the tool handlers and baked into the server you pass in ClaudeAgentOptions, and the on-call and priority sets in the hook are resolved per run. Reuse the process; mint the surface, the options, and the gate per run, per responder. Single-tenant to multi-tenant tool calling covers the general shape of that boundary.

Next Steps to Start Building Your PagerDuty Triage Agent

  • Create the PagerDuty connection under AgentKit > Connections using a Scoped OAuth app with incidents.write, then copy the exact connection name into PAGERDUTY_CONNECTION.
  • Install the SDKs with pip install claude-agent-sdk scalekit-sdk-python protobuf python-dotenv, set ANTHROPIC_API_KEY, and run ensure_connected for one responder; complete the consent link it prints before anything else.
  • Run resolve_responder next and confirm it returns a PagerDuty user ID. If it raises, fix the email mismatch before writing a line of agent logic.
  • Ship the run with tools=[], strict_mcp_config=True, a derived allowed_tools, and the PreToolUse gate, then open the same incident in PagerDuty and confirm the timeline names the responder who ran the agent, not a service account.
  • Verify the same attribution independently in the Scalekit agent audit logs: who authorized, which agent ran, which tool, what came back.
  • Add a second signal to the enrichment step without new auth code by browsing the AgentKit connector catalog, and check the Anthropic code samples for the runnable versions of these patterns.
  • Working from a related build: the DevOps assistant agent template and the deal-risk agent on the Claude Agent SDK use the same list_scoped_tools and execute_tool hinges against different connectors.
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.