What this system is, and its 5 parts
Think of this as the on-call agent that picks up the 3 AM page so a human doesn't have to. But only for the boring, predictable incidents, and only when the action is reversible. The agent reads a structured alert, classifies it into one of four buckets (transient blip vs permission failure vs bad data vs business policy), matches the right runbook, and executes the safe steps. The dangerous steps (rm -rf prod, dropping a production database) are blocked by a deterministic hook before they ever run. The agent literally cannot execute them, no matter how cleverly the alert text is phrased. Anything ambiguous gets a structured escalation block (incident ID, service, severity, root cause, recommended next step) routed to a human in PagerDuty. The whole point is composure under pressure: hooks for safety, structured isError for clarity, audit logs for after-action review.
Monitoring Alert Parser
structured isError, 4 bucketsReceives raw alert payloads (Datadog, Prometheus, Sentry, custom monitors) and projects them into a stable 4-bucket isError contract: {bucket: 'Transient'|'Permission'|'Data'|'Business', service, severity, root_cause_signal, retryable}. The agent reads bucket and retryable and routes; it never parses raw alert text. Without this contract, the agent retries permission errors forever and panics on transient blips.
ConfigurationWebhook receiver → schema validator → bucket classifier (rule-based or Haiku-classified) → enriched alert. Schema requires bucket + retryable; the alert ingestor rejects alerts missing the contract.Concept: structured-outputs →
Runbook Registry (3-5 Safe Playbooks)
small, audited, reversibleA tight registry of 3-5 named runbooks the agent can execute. Each runbook is a sequence of safe, reversible commands with explicit preconditions, timeouts, and rollback steps. Rare or unique incidents NEVER get a runbook. They escalate. The registry stays small on purpose; expanding it past 5 erodes the agent's routing accuracy and increases blast radius.
ConfigurationRegistry: ["restart_service", "drain_region", "rollback_release", "rotate_credentials", "scale_replicas"]. Each has {preconditions: [...], steps: [...], timeout_s, rollback: [...]}. Reviewed in PRs; production-deployed via the same CI/CD as application code.Concept: tool-calling →PreToolUse Hook (Blocklist Gate)
deterministic destructive-command guardSits between the model's tool_use request and Bash/destructive tool execution. Reads the proposed command; matches against an explicit blocklist regex (rm -rf, sudo, drop database, kill -9, chmod 777, >:). On match, exits 2 with a model-readable reason. The agent observes the deny in the next turn as a tool_result with is_error: true and re-plans (typically by escalating). Deterministic. No prompt-injection bypass.
Configurationmatcher: "Bash". Blocklist regex (compiled once): r"\b(rm\s+-rf|sudo\s|drop\s+(database|table)|kill\s+-9|chmod\s+777|>:)\b". Exit 2 with stderr message. Allowlist for known-safe binaries (kubectl, docker, journalctl, curl, jq).Concept: hooks →
Structured Escalation Block
30-second human triageWhen the agent can't (or shouldn't) act. Unknown runbook, hook denied, ambiguous alert, explicit request. The harness writes a STRUCTURED escalation block to PagerDuty / Slack. Six fields, every field required: incident_id, service, severity, root_cause_signal, partial_status (what the agent already did), recommended_action. Humans triage in ~30 seconds vs ~5 minutes reading a raw transcript.
ConfigurationSchema: {incident_id, service, severity: "P1|P2|P3", root_cause_signal: "permission|transient|data|business|unknown", partial_status: "what agent did before stopping", recommended_action: "single sentence"}. Posted to PagerDuty REST + Slack #ops-incidents.Concept: escalation →PostToolUse Audit Log
the post-mortem replay toolFires AFTER every tool call (Bash, runbook, escalate, log query). Writes a canonical row: ts, tool_name, tool_input, tool_result_bucket, latency_ms, stop_reason_context, hook_decisions. Append-only JSONL on durable storage. Indispensable for the 4 AM 'what did the agent do?' post-mortem; without it, trust in the agent collapses on the first incident.
Configurationmatcher: '*'. Append to audit/{YYYY-MM-DD}.jsonl. Retain ≥ 90 days. Searchable by incident_id, service, tool_name. Includes both successful and denied calls.Concept: evaluation →The problem
- Auto-resolve boring incidents. Pod restarts, cache flushes, log rotation. Without paging a human.
- Block destructive commands deterministically. rm -rf /prod must NEVER execute, no matter what the alert text says.
- Hand off ambiguous incidents cleanly. When the agent can't safely act, the on-call human gets a structured block (not a raw transcript) and can decide in 30 seconds.
- Agent runs commands without a hook gate → rm -rf /prod executes because the alert text said 'clear stuck pods'.
- Permission-denied confused with empty result → agent retries forever thinking 'no data yet'; backoff never converges.
- Sentiment-trigger escalation → angry-but-correct alerts wake humans; calm-but-broken alerts get ignored.
- No audit log → post-mortem can't reconstruct what the agent did at 3 AM; trust evaporates.
- ✓ Every monitoring alert classified into the 4-bucket isError contract (Transient · Permission · Data · Business)
- ✓ 3-5 safe runbooks in the registry; rare/unique incidents always escalate (don't add a 6th runbook for an edge case)
- ✓ PreToolUse hook denies destructive commands by regex; exit 2 returns a model-readable reason
- ✓ Structured escalation block for every human handoff: incident_id, service, severity, root_cause, partial_status, recommended_action
- ✓ PostToolUse audit log writes every tool call with input, output, latency, and the bucket the result fell into
- ✓ Sentiment is logged but never gates routing. Escalation triggers are policy gaps, access failures, and explicit user request only
One run, traced end to end
8 steps to production
Parse alerts into the 4-bucket isError contract
The webhook receiver projects raw monitoring payloads (Datadog/Prometheus/Sentry/custom) into a stable shape: {bucket, service, severity, root_cause_signal, retryable, raw}. The bucket is the single most important field. It's what the agent's routing logic branches on. Bucket assignment is rule-based for known patterns; ambiguous alerts get classified by Haiku (cheap, fast) and emit the bucket + a confidence score.
Concept: structured-outputs →from enum import Enum
from typing import TypedDict, Literal
class Bucket(str, Enum):
TRANSIENT = "Transient" # network blip, rate limit. Retry
PERMISSION = "Permission" # 403/401. Escalate, won't fix itself
DATA = "Data" # malformed input. Surface to user
BUSINESS = "Business" # policy violation. Block + log + escalate
class Alert(TypedDict):
bucket: Bucket
service: str
severity: Literal["P1", "P2", "P3"]
root_cause_signal: str
retryable: bool
raw: dict
def classify_alert(raw: dict) -> Alert:
"""Project arbitrary monitoring payload into the 4-bucket contract."""
msg = (raw.get("message") or "").lower()
if any(s in msg for s in ["503", "timeout", "rate limit", "circuit breaker"]):
bucket, retryable = Bucket.TRANSIENT, True
elif any(s in msg for s in ["403", "401", "forbidden", "unauthorized"]):
bucket, retryable = Bucket.PERMISSION, False
elif any(s in msg for s in ["malformed", "schema", "validation", "400"]):
bucket, retryable = Bucket.DATA, False
elif any(s in msg for s in ["policy", "compliance", "limit exceeded"]):
bucket, retryable = Bucket.BUSINESS, False
else:
# Ambiguous. Classify with Haiku (cheap) and trust the bucket it returns
bucket, retryable = haiku_classify_bucket(raw)
return {
"bucket": bucket,
"service": raw.get("service", "unknown"),
"severity": raw.get("severity", "P2"),
"root_cause_signal": raw.get("root_cause") or msg[:100],
"retryable": retryable,
"raw": raw,
}
def haiku_classify_bucket(raw: dict) -> tuple[Bucket, bool]:
"""Cheap fallback classifier for ambiguous alerts."""
resp = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=64,
messages=[{"role": "user", "content":
f"Classify into one bucket (Transient|Permission|Data|Business) "
f"+ retryable (true|false). Alert: {raw}\n"
f"Output JSON only: {{\"bucket\": ..., \"retryable\": ...}}"}],
)
parsed = json.loads(resp.content[0].text)
return Bucket(parsed["bucket"]), parsed["retryable"]Define a small runbook registry (3-5 entries)
Every runbook is a named sequence of safe, reversible commands with explicit preconditions, timeouts, and rollback steps. The registry stays at 3-5 entries. The agent's routing accuracy degrades past 5, and a sixth runbook for an edge case is exactly the wrong move (escalate the edge case instead). Each runbook is reviewed in PRs and deployed through CI like application code.
Concept: tool-calling →Wire the PreToolUse hook with a destructive blocklist
The hook is the deterministic safety gate. It reads tool_name + tool_input.command from stdin JSON, applies a regex blocklist (compiled once), exits 2 with a stderr message on match. The model sees the deny as a tool_result with is_error: true and re-plans. No prompt-injection bypass; the blocklist is in code, not in the prompt.
Concept: hooks →Build the structured escalation block
When the agent can't act safely. Unknown runbook, hook denied, ambiguous alert, explicit escalation request. The harness emits a structured escalation block to PagerDuty / Slack. Six required fields. Humans triage in ~30 seconds vs ~5 minutes reading a raw transcript. The block is the single most important UX artifact in the whole system; tune the wording to be a one-screen read.
Concept: escalation →Wire the PostToolUse audit log
PostToolUse fires AFTER every tool call (Bash, runbook, escalate, log query, hook decisions). Writes a canonical row: ts, tool_name, tool_input, tool_result, latency_ms, hook_decisions, incident_id. Append-only JSONL on durable storage; retain ≥ 90 days. Indexed by incident_id, service, tool_name. The post-mortem replay tool depends on it; without it, trust collapses on the first incident.
Concept: evaluation →Run the agent loop with stop_reason branching
The on-call agent loop is a strict stop_reason FSM. end_turn: success, log + close incident. tool_use: extract the tool call, run hooks, execute, append result, continue. tool_use → hook denied: append the deny as a tool_result with is_error: true, continue (the agent re-plans, usually by escalating). max_tokens: persist partial state, escalate. The loop NEVER branches on response text. The structured stop_reason is the only contract.
Concept: agentic-loops →Pin incident metadata in CASE_FACTS
Multi-turn ops conversations need the incident metadata anchored. Pin incident_id, service, severity, bucket, partial_status, runbook_in_progress at the top of every system prompt iteration. This survives summarization and ensures the agent on turn 8 still knows it's working on PD-4711, not a generic incident. Without case-facts pinning, multi-turn ops conversations regress to single-turn quality.
Concept: case-facts-block →Test incident handling with adversarial scenarios
Build an eval set of 50 incidents across the 4 buckets + a 'sentiment trap' subset (alerts with angry phrasing but a clean runbook should run the runbook, not escalate). Run the agent over the set; measure: bucket-classification accuracy, correct-runbook rate, false-escalation rate, hook-deny-then-recover rate. Re-run on every change to the runbook registry, hooks, or system prompt.
Concept: evaluation →9 decisions the exam turns into distractors
Trust the system prompt to instruct the model never to run rm -rf
PreToolUse hook denies via blocklist regex; agent escalates
Pass the raw alert to the agent and ask it to figure out the bucket
Haiku-classify into one of the 4 buckets + retryable; trust the bucket
Add a 6th, 7th, 8th runbook for edge cases as they appear
3-5 safe, reversible runbooks; rare incidents always escalate
Forward the full transcript to the on-call human
Structured 6-field escalation block to PagerDuty
Agent calls Bash with rm -rf /tmp/stale_pods. Typo'd as rm -rf / in production. The command runs because the only guard was a system-prompt warning. Service goes down; recovery takes hours.
PreToolUse hook with a blocklist regex (rm -rf, sudo, drop database, kill -9, chmod 777, curl | sh). Exit 2 with stderr message on match. The agent observes the deny, escalates instead of retrying. Deterministic, no bypass.
System prompt says 'never run dangerous commands'. Production logs show the agent sometimes runs them anyway (3-5%) because the alert text was very persuasive or used unusual phrasing.
Move the constraint to a deterministic PreToolUse hook. Exit 2 on the blocklist match. The system prompt becomes a soft suggestion that complements the hard hook, not a substitute for it.
Agent reads raw alert text and infers severity / type. Same alert pattern produces different classifications on different days; routing drifts; on-call humans get inconsistent escalations.
Project every alert into the 4-bucket isError contract at the webhook receiver (rule-based + Haiku fallback). The agent reads bucket and retryable; never parses raw alert text. Classification is consistent and auditable.
Agent escalates ambiguous incidents but writes nothing to a durable log. Post-mortem at 9 AM can't reconstruct what the agent saw, what it tried, what it skipped. Trust collapses on the first incident.
PostToolUse audit hook on every tool call: append to audit/{date}.jsonl with ts, tool_name, tool_input, tool_result, latency_ms, hook_decisions, incident_id. Retain ≥ 90 days. Replay tool reconstructs any incident in seconds.
Monitoring API returns 403 forbidden; agent treats empty response as 'no alerts to handle' and goes back to sleep. Real incident festers undetected for hours; eventually a human finds the auth-token expired.
Structured isError bucket (Permission). The monitoring tool wrapper returns {is_error: true, bucket: 'Permission', retryable: false, detail: 'auth token expired'}. Agent reads bucket: Permission and immediately escalates. Permission errors NEVER look like empty results.
Cost & latency
Haiku alert classification (~$0.0001) + Sonnet 4.5 ops loop ~3-5 turns × ~1500 cached input + ~300 output tokens. Total per resolved incident: ~$0.01. PagerDuty integration adds no token cost.
PreToolUse + PostToolUse run as subprocesses reading stdin JSON. No LLM call. Pure local Python/TS. Latency below the noise floor of typical Bash / kubectl execution.
Each row ~5 KB; 1000 incidents × ~5 tool calls each = 5000 rows/month ≈ 25 MB. At $0.023/GB/month object storage, <$0.001/month. Negligible.
50 × $0.006 average per incident. Weekly = $1.20/month. Insurance against runbook registry / hook regression; gates deploys.
A false escalation wakes an on-call human at 3 AM (~30 min wasted at $60/hr fully-loaded ≈ $30) or worse, trains them to ignore alerts (compounding cost). The eval set + sentiment-trap test pays for itself many times over by preventing this.
Check every gate before release
5 exam-pattern questions
Work through one question at a time, check the architecture, then move through the set.
An ops agent processes alerts. In about 8 percent of cases it calls restart_pod when the alert was actually a 403 permission-denied from monitoring. What architectural change distinguishes access-failure from actual pod crash?
Frequently asked
How do I distinguish a permission-denied error from an actual service failure?
Both bucket projection AND structured isError on the monitoring tool. The webhook receiver classifies the alert into one of 4 buckets; the monitoring tool wrapper that fetches additional context returns {is_error: true, bucket: 'Permission', retryable: false, detail: '...'} instead of an empty result. The agent reads bucket and retryable and routes. Permission → escalate, Transient → retry. Don't parse error text.
Can the hook block dangerous commands?
Yes. That's its whole job. PreToolUse hook with a blocklist regex on Bash. Exit 2 with a stderr message on match; the agent sees the message as tool_result: {is_error: true, content: <stderr>} and re-plans. Tested against rm -rf, sudo, drop database, kill -9, chmod 777, curl | sh. Pair with an allowlist of safe binaries (kubectl, docker, journalctl, etc.) so unknown binaries also deny.
Should audit logging be a tool call or a hook?
A PostToolUse hook. Tool-based logging via the agent is easily skipped (the model decides not to call it on a busy turn); hooks run automatically by the SDK on every tool execution. The hook writes a canonical row to append-only JSONL; the replay tool reconstructs any incident later. This is the difference between 'we have logs sometimes' and 'we have logs always'.
What triggers an escalation?
Five structural triggers, in priority order: (1) explicit user request, (2) hook denial of a needed action, (3) unknown runbook (no match in the registry), (4) Permission bucket (access failure won't self-recover), (5) max_tokens or iteration cap. Sentiment is logged but NEVER triggers escalation. The 'sentiment trap' eval subset specifically tests that an angry-but-clean alert gets the runbook, not an escalation.
How does the agent know which runbook to execute?
Runbook registry has explicit preconditions; the agent's match_runbook(alert) function returns the first runbook whose preconditions all match. No match → escalate (don't bend a runbook to fit an edge case). The match logic is deterministic (rule evaluation), not LLM-judged. Adding a runbook means adding a precondition definition + the steps + the rollback; it's a code change, PR-reviewed, deployed through CI.
What if the operator approves an escalation later?
Round-trip via the escalation block + a separate approval flow. The structured block hits PagerDuty / Slack; the operator clicks approve in their tool; an approval webhook fires; the harness picks up the approval, loads the case-facts checkpoint, and resumes the agent loop with the approved action injected. The agent never bypasses the escalation. Humans authorize the next step explicitly.
Can the agent execute arbitrary bash?
No. Two layers: (1) PreToolUse hook with a destructive blocklist regex denies known-dangerous patterns; (2) allowlist of safe binaries (kubectl, docker, journalctl, curl, jq, etc.). Anything else is denied even if not on the blocklist. Both layers are deterministic. The agent's Bash access is narrow enough that a prompt-injection attack can't escalate to repo-wide damage.
