Chapter 11: Integrating with the Tools You Already Run
Learning Objectives
Wire a Splunk alert action or webhook into a service that calls TypeSafe
Create and update ServiceNow incidents and Salesforce cases with typed decisions
Place TypeSafe as a guardrail in front of an LLM or agent workflow
Handle retries, exceptions, rate limits, cost tracking, and logging in production
Pre-Quiz: Splunk, ServiceNow & Salesforce
A Splunk saved search fires and needs to notify an external triage service. Which statement correctly distinguishes a webhook alert action from the HTTP Event Collector (HEC)?
A webhook alert action and HEC both send data out of Splunk, but HEC uses a different payload format
A webhook alert action sends data out from Splunk when a search triggers; HEC is the inbound listener that receives data into Splunk
HEC is only available for cloud-hosted Splunk instances, while webhook alert actions work everywhere
A webhook alert action requires the Splunk Add-on for ServiceNow, while HEC works with any external service
A team enables the [webhook] allow list in alert_actions.conf on Splunk 9.0+ but forgets to add any allowlist.* entries. What happens?
Splunk blocks all outbound webhooks until at least one entry is added
Splunk permits webhooks to any destination URL, because an enabled-but-empty allow list authorizes everything
Splunk falls back to the previous version's behavior and ignores the allow list entirely
Splunk sends an admin alert but still delivers the webhook to its configured URL
A Splunk search that flags forty flapping interfaces triggers a webhook alert. Why might the downstream triage service only ever see one interface per alert, and what is the recommended fix?
Splunk truncates the payload at 1KB, so add pagination to the receiver
The webhook payload's result field carries only the first result row of the search; aggregate with SPL like stats count by host before alerting so the single row is already a summary
The receiver's Pydantic model only accepts one interface per request; loosen the schema to accept a list
Splunk deduplicates identical alerts within a rolling window, so only the newest interface survives
A triage service enriches ServiceNow incidents after a human has already filled in some ticket fields. Why does the chapter recommend PATCH over PUT for this enrichment step?
PATCH is faster than PUT because it uses a smaller HTTP header
PUT requires OAuth while PATCH accepts basic auth
PATCH updates only the fields you send, while PUT replaces the entire record — a PUT from the triage service would blank out fields a human already filled in
PATCH automatically resolves field-name conflicts between the human and the service
A support manager wants the triage pipeline to compute an SLA credit dollar amount directly from Jev's severity Score. What does the chapter say about this approach?
This is the intended use of Score — it was designed to output currency amounts
Don't ask Jev to compute arithmetic like SLA credits; instead ask which rubric band the evidence falls into, then compute the amount in testable Python code
SLA credits should use a Choice question instead of a Score question so the answer is categorical
This is fine as long as the confidence on the Score answer is above 0.85
Splunk Alert Actions and Webhooks
Key Points
Splunk webhook alert actions send data OUT when a saved search fires; HEC receives data IN — opposite directions on the same platform.
Since Splunk 9.0, webhook URLs must match a regex in the [webhook] allow list in alert_actions.conf — an enabled-but-empty allow list permits everything, not nothing.
The webhook payload has six fields (result, sid, results_link, search_name, owner, app), and result carries only the FIRST result row — aggregate with SPL before alerting.
The FastAPI receiver validates the payload with a Pydantic BaseModel, builds a labeled JSON state (not a flattened string), and asks all triage questions in one system_one call (Speculative Fan-Out).
Every typed answer carries confidence and probabilities alongside its headline value — confidence is the second axis routing logic branches on.
Triggering a webhook from a Splunk saved search
An alert action is what Splunk does when a saved search matches its trigger condition. A webhook is the simplest one: Splunk makes an outbound HTTP POST to a URL you specify, configured from Settings > Searches, reports, and alerts. Direction matters here because Splunk has two HTTP features that point opposite ways: webhook alert actions send data out from Splunk with Splunk as the initiator, while the HTTP Event Collector (HEC) is the inbound listener that receives data into Splunk. For a NOC triage service, we want the outbound direction — Splunk detects, our service decides.
Since Splunk Enterprise 9.0, a webhook URL will not fire unless it matches an entry in an allow list — the single most common reason a newly built integration silently does nothing:
There is a trap in the failure mode: if you enable the allow list but specify no URLs, Splunk authorizes webhooks to send to any endpoint. It is a permit-list ACL, and an empty one here permits rather than denies — always define restrictive expressions anchored on https://.
The payload Splunk delivers is small and fixed — six fields, of which result holds only the first result row of the triggering search:
Paste into the ticket so a human can see everything
search_name
Name of the saved search that fired
Selects which question set to ask
owner
Username of the search owner
Fallback routing and ownership
app
Splunk app context
Separates lab from production pipelines
The workaround for the one-row limitation is SPL that aggregates before it alerts — stats count by host with the summary fields you want in result — so the row you receive is already a summary rather than an arbitrary sample.
Key Takeaway: A Splunk webhook alert action is an outbound HTTP POST carrying six fields, of which result holds only the first row of the triggering search. Before anything works on Splunk 9.0+, the destination URL must match a regex in the [webhook] allow list, and an enabled-but-empty allow list permits everything rather than nothing.
A FastAPI receiver that builds state and calls system_one
If you have not used FastAPI before
The receiver below is a small web service, and three lines of it are framework mechanics rather than TypeSafe concepts. app = FastAPI() creates the service. @app.post("/webhooks/splunk") is a decorator that says "run this function when a POST arrives at that path." class SplunkWebhook(BaseModel) is a Pydantic model — a declaration of the JSON shape you expect, which FastAPI validates automatically and rejects with a 422 if the body does not match. Everything else is ordinary Python. If you would rather not run a web framework, the same logic works unchanged inside a Flask route, an AWS Lambda handler, or a plain http.server — only the four framework lines change, and build_state plus the system_one call are the parts that matter.
The receiver's job is narrow: validate the payload, turn it into state, ask a fixed set of typed questions, and hand the typed answers to whatever writes the ticket. Keeping the question set in one module-level constant matters more than it looks — treat that file like a routing policy: reviewed, versioned, diffed in a pull request.
QUESTION_SET_VERSION = "noc-triage-v3"
TRIAGE_QUESTIONS = {
"owning_team": Choice(
instructions="Which NOC team owns the first response to this alert",
criteria={
"routing": "BGP, OSPF, EIGRP, route flaps, next-hop or peering problems",
"switching": "VLANs, spanning tree, port-channels, interface errors",
"wireless": "Aruba AOS-CX or controller-attached APs, RF, client association",
"transport": "Optical, WAN circuits, carrier-facing links, LOS or SD alarms",
"security": "ACL denies, authentication failures, unexpected config change",
},
),
"service_affecting": Noul(instructions="...currently degrading user traffic"),
"redundancy_lost": Noul(instructions="...redundant path or peer is down"),
"severity": Score(
instructions="How severe is this alert for the production network",
criteria=[
"Informational; counters or state noted, no user impact",
"Degraded; a single link or client group is affected",
"Outage; a core path, peering session, or site is down",
],
),
}
class SplunkWebhook(BaseModel):
result: dict
sid: str
results_link: str | None = None
search_name: str | None = None
owner: str | None = None
app: str | None = None
@app.post("/webhooks/splunk")
def receive(payload: SplunkWebhook, authorization: str = Header(default="")):
if authorization != f"Bearer {SHARED_SECRET}":
raise HTTPException(status_code=401, detail="unauthorized")
response = client.system_one(state=build_state(payload), questions=TRIAGE_QUESTIONS)
return {"sid": payload.sid, "model": response.model}
Two design points matter. First, state is passed as a JSON object rather than a flattened string, so Jev sees interface and error_count as distinct facts. Second, all four questions ride in a single call — the Speculative Fan-Out pattern: send many questions, including speculative ones, in one call and let your code decide what's relevant. You pay for one round trip whether you ask one question or four.
Key Takeaway: The receiver parses the six-field webhook, promotes the result fields into a labelled JSON state, and asks its entire question battery in one system_one call. Keeping the questions and thresholds in one reviewable constant turns your triage policy into something a change board can read.
Enriching the event with team, severity, and confidence
A SystemOneResponse gives you answers keyed by question name, plus the model used and token usage. A ChoiceAnswer includes the selected label, a confidence score, and probabilities per label; a ScoreAnswer includes the expected score, confidence, a rubric legend, and probabilities per integer score.
Confidence is "a statistic computed from the probability distribution the answer already gives you," ranging 0 to 1 — a concentrated distribution means high confidence, a spread-out one signals uncertainty. The documentation's own published bands are practical starting points: above 0.9, act automatically even on high-stakes decisions; between 0.5 and 0.9, proceed cautiously and seek confirmation; below 0.5, route to humans. That general banding is Confidence-Gated Routing — one of TypeSafe's architectural patterns.
Important nuance carried through this chapter: those documented bands (above 0.9 / 0.5–0.9 / below 0.5) are the starting point, not the final word. Later in this chapter, the worked example replaces them with narrower cut points — 0.85 and 0.60 — measured from Chapter 8's own confidence-versus-accuracy data for the specific assign/route decision this pipeline makes. Chapter 12 ships that same narrower pair as named constants. Keep the wide documented bands in mind as background, but the operative numbers for this pipeline are 0.85 and 0.60.
Key Takeaway: Enrichment means lifting choice, score, and noul values out of the response along with their confidence and probabilities, and stamping the model and question-set version alongside them. Confidence is not decoration — it is the second axis your routing logic branches on.
ServiceNow and Salesforce
Key Points
POST /api/now/table/incident creates a record and returns its sys_id; PATCH updates only the fields sent; PUT replaces the entire record — use PATCH for enrichment.
sysparm_input_display_value lets you send label values instead of database values; X-no-response-body suppresses the echoed record for bulk operations.
Flow Designer's REST activity step lets ServiceNow pull from an external decision service — the inverse direction of the Splunk webhook push, useful for human-raised tickets.
Push (Splunk webhook) handles machine-generated alert volume; Pull (Flow Designer) handles human-raised tickets — both should call the same versioned question set.
Salesforce case triage reuses the same typed answers: Score drives priority, Choice drives owner queue, confidence decides auto-assign vs. escalation; arithmetic belongs in code, not the model.
Creating incidents via the Table API with typed fields
The ServiceNow Table API is a REST interface over any table; for incidents, POST to /api/now/table/incident with field name-value pairs, and the response returns the new record including its assigned sys_id. Updates use two verbs with two different meanings:
Operation
Method and path
Behavior
Create incident
POST /api/now/table/incident
Inserts a record, returns it with a new sys_id
Partial update
PATCH /api/now/table/incident/{sys_id}
Updates only the fields present in the body
Full replacement
PUT /api/now/table/incident/{sys_id}
Replaces the whole record with the values provided
For an enrichment pipeline you almost always want PATCH; a PUT from a triage service will happily blank out fields a human filled in while you were deciding. The mental model is merge versus overwrite in a configuration push. Two response controls are worth knowing: X-no-response-body: true suppresses the returned record for bulk operations, and sysparm_input_display_value=true lets you send display-value labels and have the API convert them.
Key Takeaway: POST to /api/now/table/incident creates a record and returns its sys_id; PATCH updates only the fields you send while PUT replaces the record wholesale. Typed answers map cleanly onto assignment_group, urgency, impact, and work_notes.
Flow Designer and REST steps that call a decision service
Flow Designer is ServiceNow's low-code automation builder, and it can call outward as well as react inward. A REST activity step lets a flow invoke an external REST service: choose the HTTP method, set the target URL, define authentication, map request headers and body, and map the JSON response into flow variables with conditional logic on HTTP status codes.
Figure 11.1: ServiceNow Flow Designer calling the decision service
flowchart LR
A["Incident Created in ServiceNow"] --> B["Flow Extracts Incident Details"]
B --> C["REST Step Posts to Decision API"]
C --> D["Flow Parses Choice, Score, and Noul Results"]
D --> E["Incident Updated with Recommendation"]
E --> F{"Confidence Score"}
F --> G["Notify Assignment Group"]
Direction
Trigger
Who calls whom
Best for
Push (webhook receiver)
Splunk saved search fires
Splunk → your service → Table API
Machine-generated alerts arriving at volume
Pull (Flow Designer)
Incident created or updated
ServiceNow flow → your decision API
Human-raised tickets and email-to-incident
Key Takeaway: Flow Designer's REST activity turns ServiceNow into a client of your decision service, covering human-raised tickets the way the Splunk webhook covers machine-raised alerts. Both directions should call the same versioned question set.
Salesforce case triage from Score and Choice answers
Customer-facing outages produce Salesforce cases as well as ServiceNow incidents, and the same typed answers drive both. TypeSafe documents its REST API as the integration surface for "any HTTP-capable system" rather than shipping a Salesforce-specific connector, so treat Salesforce as one more REST consumer with field names in your org's own mapping layer.
Typed answer
Case attribute it drives
Rule
severity (Score)
Priority tier
Expected score near the top rubric band → highest priority
owning_team (Choice)
Owner queue
Selected label maps to a queue name in your org
customer_visible (Noul)
Public-facing flag
High yes-probability marks the case for customer communications
severity_confidence
Escalation path
Below the floor threshold, route to a human triage queue rather than auto-assigning
Computing priority from a Score beats a hand-written if ladder over syslog severities because the rubric is explicit and editable. One caution carried forward from earlier chapters: Jev is built for structured decisions, not arithmetic. Do not ask it to compute an SLA credit or total minutes of downtime; ask which rubric band the evidence falls into, then do the arithmetic in Python where it is testable.
Key Takeaway: Salesforce case triage reuses the same Score and Choice answers that drive ServiceNow, with the Score setting priority, the Choice setting the owner queue, and confidence deciding whether the assignment is automatic. Keep arithmetic in code rather than in the model.
Post-Quiz: Splunk, ServiceNow & Salesforce
A Splunk saved search fires and needs to notify an external triage service. Which statement correctly distinguishes a webhook alert action from the HTTP Event Collector (HEC)?
A webhook alert action and HEC both send data out of Splunk, but HEC uses a different payload format
A webhook alert action sends data out from Splunk when a search triggers; HEC is the inbound listener that receives data into Splunk
HEC is only available for cloud-hosted Splunk instances, while webhook alert actions work everywhere
A webhook alert action requires the Splunk Add-on for ServiceNow, while HEC works with any external service
A team enables the [webhook] allow list in alert_actions.conf on Splunk 9.0+ but forgets to add any allowlist.* entries. What happens?
Splunk blocks all outbound webhooks until at least one entry is added
Splunk permits webhooks to any destination URL, because an enabled-but-empty allow list authorizes everything
Splunk falls back to the previous version's behavior and ignores the allow list entirely
Splunk sends an admin alert but still delivers the webhook to its configured URL
A Splunk search that flags forty flapping interfaces triggers a webhook alert. Why might the downstream triage service only ever see one interface per alert, and what is the recommended fix?
Splunk truncates the payload at 1KB, so add pagination to the receiver
The webhook payload's result field carries only the first result row of the search; aggregate with SPL like stats count by host before alerting so the single row is already a summary
The receiver's Pydantic model only accepts one interface per request; loosen the schema to accept a list
Splunk deduplicates identical alerts within a rolling window, so only the newest interface survives
A triage service enriches ServiceNow incidents after a human has already filled in some ticket fields. Why does the chapter recommend PATCH over PUT for this enrichment step?
PATCH is faster than PUT because it uses a smaller HTTP header
PUT requires OAuth while PATCH accepts basic auth
PATCH updates only the fields you send, while PUT replaces the entire record — a PUT from the triage service would blank out fields a human already filled in
PATCH automatically resolves field-name conflicts between the human and the service
A support manager wants the triage pipeline to compute an SLA credit dollar amount directly from Jev's severity Score. What does the chapter say about this approach?
This is the intended use of Score — it was designed to output currency amounts
Don't ask Jev to compute arithmetic like SLA credits; instead ask which rubric band the evidence falls into, then compute the amount in testable Python code
SLA credits should use a Choice question instead of a Score question so the answer is categorical
This is fine as long as the confidence on the Score answer is above 0.85
Pre-Quiz: Guardrails for LLMs and Agents
An agent proposes an NX-OS command. The guardrail battery returns a max flag probability of 0.50 and a blast-radius severity of 1.2. What should happen, per the documented thresholds?
Pass — no threshold was crossed
Block — any flag above 0 should block
Review — the max flag exceeds the 0.35 review threshold but stays below the 0.70 action threshold, and severity is below 2.0
Support — self-harm and jailbreak batteries always route through the support pathway
Why does the guardrail cookbook's design explicitly separate probability assessment from policy application (thresholds)?
So the model can be retrained faster when thresholds change
So thresholds can be tightened or loosened — e.g., stricter during a change freeze — by editing configuration, without touching the questions or retraining anything
Because probabilities and thresholds must be computed on separate servers for latency reasons
So that only security engineers, not network engineers, can see the raw probabilities
The SDE cascade's escalation gate fires when ANY per-field flag exceeds 0.7, rather than escalating based on the average of all flags. Why does the chapter prefer this "any_flag" approach?
Averaging is computationally more expensive than taking a maximum
Averaging would let one badly wrong field hide behind several correct ones — unacceptable when the wrong field is something like an interface name
The any_flag gate is required by the TypeSafe SDK and cannot be configured differently
Averaging only works when there are an even number of fields being extracted
In the SDE cascade, why does it make economic sense to run the Jev verifier on every extraction rather than sending every request straight to the expensive reasoning model?
The verifier is free to run an unlimited number of times per day
The verifier (jev-1.12) costs roughly a small fraction of the mini model's input price and far less than the reasoning model's, so verifying everything and escalating only flagged cases is cheaper than escalating everything
The reasoning model cannot process the same input format as the verifier
Running the verifier improves the mini model's own accuracy retroactively
A team installs the TypeSafe agent skill both as a Claude Code plugin and manually via npx skills add. What does the chapter say results from this?
The two installation methods automatically merge into a single unified skill
Duplicate copies of the skill — described as the agent equivalent of two DHCP servers on the same VLAN
The manual installation silently overrides the plugin installation with no side effects
Claude Code will refuse to start until one installation method is removed
Guardrails for LLMs and Agents
Key Points
A guardrail battery evaluates many hazards in one assessment; thresholds are 0.35 (review), 0.70 (action/block), and a severity score of 2.0+ can escalate a review into a block.
Assessment (probabilities) is decoupled from policy (thresholds) — tightening a gate for a change freeze is a threshold edit, not a retrain or question rewrite.
The SDE cascade verifies each field with Jev and escalates to an expensive reasoning model only when any per-field flag exceeds FIRE_T = 0.7 — a max-style ("any_flag") gate, not an average.
The verifier (jev-1.12) costs a small fraction of the mini model's input price, so verifying every extraction is cheaper than escalating everything.
Install the TypeSafe agent skill via exactly one method — mixing methods creates duplicate copies — and centralize questions/thresholds in one reviewable place.
Checking an agent's proposed action before execution
A guardrail is a check that runs between a proposal and its execution. TypeSafe's guardrails pattern screens LLM inputs and outputs with a battery-based approach: each message receives a single assessment containing multiple hazard evaluations, producing a severity score on a 0–3 scale, and routing to one of four outcomes — pass, review, block, or support. The documented thresholds are a review threshold of 0.35 and an action threshold of 0.70 or above, with severity scores at 2.0 or above able to escalate a review into a block.
Suppose an agent working a Cisco NX-OS device proposes a command. Before anything reaches the device, the proposal plus its context becomes state and a battery of hazard questions becomes the assessment:
GUARDRAIL_QUESTIONS = {
"config_changing": Noul(instructions="...modifies device configuration"),
"service_affecting": Noul(instructions="...would interrupt forwarding for production traffic"),
"irreversible": Noul(instructions="...cannot be undone by one opposite command or rollback"),
"outside_change_window": Noul(instructions="...falls outside the approved change window"),
"scope_mismatch": Noul(instructions="...targets a device not named in the change record"),
"blast_radius": Score(
instructions="How much of the production network could be affected",
criteria=[
"Read-only or single access port on one switch",
"One device or one uplink; redundant path remains",
"Core device, routing process, or an entire site",
],
),
}
def screen(proposal: dict):
response = client.system_one(state=proposal, questions=GUARDRAIL_QUESTIONS)
flags = {name: response.answers[name].noul for name in (
"config_changing", "service_affecting", "irreversible",
"outside_change_window", "scope_mismatch")}
severity = response.answers["blast_radius"].score
if max(flags.values()) >= 0.70 or severity >= 2.0:
return "block", flags, severity, response
if max(flags.values()) >= 0.35:
return "review", flags, severity, response
return "pass", flags, severity, response
show interface Ethernet1/1 counters trips nothing and passes. interface Ethernet1/1 plus shutdown on an access port inside the window trips config_changing and service_affecting but sits in the lower blast-radius band, producing a review. no feature bgp on a spine trips service_affecting and irreversible at high probability with a top-band blast radius, and blocks.
Figure 11.2: Guardrail check on a proposed NX-OS command
flowchart TD
A["Agent Proposes NX-OS Command"] --> B["TypeSafe Guardrail Battery"]
B --> C{"Max Flag or Severity"}
C -->|"Below 0.35"| D["Pass"]
C -->|"0.35 to 0.70"| E["Review"]
C -->|"0.70 or Above, or Severity 2.0 or Above"| F["Block"]
The design principle behind this: it "decouples probability assessment from policy application, making guardrails editable without retraining." The model reports probabilities; your thresholds turn probabilities into verdicts. Named policies such as "strict" and "permissive" can yield different decisions from the same assessment under different thresholds — run strict during a freeze, permissive during a maintenance window, with no change to the questions themselves.
Key Takeaway: A guardrail battery evaluates many hazards in one assessment and routes to pass, review, block, or support using a 0.35 review threshold, a 0.70 action threshold, and a severity escalation at 2.0. Because assessment is separated from policy, you can tighten the gate for a change freeze by editing thresholds rather than questions.
Cascading from Jev to an expensive reasoning model on low confidence
A cascade runs a cheap model first and escalates to an expensive one only when a verifier says the cheap answer is not trustworthy. The SDE (Structured Data Extraction) cascade documents a three-component architecture:
Component
Model
Input price
Output price
Rung 0 (mini extractor)
gpt-5.4-mini
$0.75 / 1M tokens
$4.50 / 1M tokens
Rung 1 (reasoning model)
gpt-5.5
$5.00 / 1M tokens
$30.00 / 1M tokens
Verifier
jev-1.12
$0.042 / 1M tokens
Free
The gate is an "any_flag" gate at threshold FIRE_T = 0.7, triggering escalation when any field flag exceeds 0.7 — a max-style approach rather than an average, applied per field. Averaging would let one badly wrong field hide behind four correct ones, a failure you cannot afford when that field is an interface name. The verifier asks three questions per field:
Flag
Question the verifier asks
hallucinated
Is the extracted field unsupported by, or absent from, the source text?
off_target
Does the source genuinely provide this field?
absence_wrong
For empty fields, was supporting information available?
For the NOC pipeline, a mini model reads a free-text maintenance request and extracts device, interface, proposed_command, window_start, and rollback_step. Jev verifies each field. If any flag exceeds 0.7, the request escalates to the reasoning model; otherwise the cheap extraction stands.
Figure 11.3: SDE cascade from mini model to reasoning model
flowchart TD
A["Free-Text Maintenance Request"] --> B["Mini Model Extracts Fields"]
B --> C["Jev Verifier Checks Each Field"]
C --> D{"Any Flag Above 0.7"}
D -->|"No"| E["Cheap Extraction Stands"]
D -->|"Yes"| F["Escalate to Reasoning Model"]
F --> G["Reasoning Model Re-Extracts Field"]
Visual animation — coming soon
The documented result across 100 prompts is "most of the top model's quality at a fraction of its cost," with a "Pareto frontier sitting up-and-left of every single model." The verifier costs roughly one percent of the mini model's input price and far less of the reasoning model's, so verifying every request to skip the expensive rung on most of them pays for itself immediately.
Key Takeaway: The SDE cascade pairs a cheap extractor with a Jev verifier and escalates to an expensive reasoning model only when any per-field flag exceeds 0.7. The max-style gate prevents one bad field from being averaged away, and the verifier's cost is small enough that verifying everything is cheaper than escalating everything.
The TypeSafe agent skill for coding agents
TypeSafe publishes an agent skill — packaged instructions that teach a coding agent how to use the System One API correctly. Three installation methods are documented: a Claude Code plugin via marketplace commands, installation for other agents using npx skills add, and manual installation by copying the GitHub directory. Installation is project-local by default; adding -g installs globally. In Claude Code the skill is invoked directly with /typesafe:typesafe-ai.
Choose one installation method; mixing them produces duplicate copies of the skill — the agent equivalent of two DHCP servers on the same VLAN. The skill's own guidance is worth adopting whether or not you install it: place constants — questions and thresholds — in a single place so they are easy to review, and validate assumptions rather than accepting an agent's assertions at face value.
Key Takeaway: The agent skill installs as a Claude Code plugin, via npx skills add for other agents, or by copying the directory manually, project-local unless you pass -g. Pick one method, and follow its advice to centralize questions and thresholds and to verify what the agent claims it built.
Post-Quiz: Guardrails for LLMs and Agents
An agent proposes an NX-OS command. The guardrail battery returns a max flag probability of 0.50 and a blast-radius severity of 1.2. What should happen, per the documented thresholds?
Pass — no threshold was crossed
Block — any flag above 0 should block
Review — the max flag exceeds the 0.35 review threshold but stays below the 0.70 action threshold, and severity is below 2.0
Support — self-harm and jailbreak batteries always route through the support pathway
Why does the guardrail cookbook's design explicitly separate probability assessment from policy application (thresholds)?
So the model can be retrained faster when thresholds change
So thresholds can be tightened or loosened — e.g., stricter during a change freeze — by editing configuration, without touching the questions or retraining anything
Because probabilities and thresholds must be computed on separate servers for latency reasons
So that only security engineers, not network engineers, can see the raw probabilities
The SDE cascade's escalation gate fires when ANY per-field flag exceeds 0.7, rather than escalating based on the average of all flags. Why does the chapter prefer this "any_flag" approach?
Averaging is computationally more expensive than taking a maximum
Averaging would let one badly wrong field hide behind several correct ones — unacceptable when the wrong field is something like an interface name
The any_flag gate is required by the TypeSafe SDK and cannot be configured differently
Averaging only works when there are an even number of fields being extracted
In the SDE cascade, why does it make economic sense to run the Jev verifier on every extraction rather than sending every request straight to the expensive reasoning model?
The verifier is free to run an unlimited number of times per day
The verifier (jev-1.12) costs roughly a small fraction of the mini model's input price and far less than the reasoning model's, so verifying everything and escalating only flagged cases is cheaper than escalating everything
The reasoning model cannot process the same input format as the verifier
Running the verifier improves the mini model's own accuracy retroactively
A team installs the TypeSafe agent skill both as a Claude Code plugin and manually via npx skills add. What does the chapter say results from this?
The two installation methods automatically merge into a single unified skill
Duplicate copies of the skill — described as the agent equivalent of two DHCP servers on the same VLAN
The manual installation silently overrides the plugin installation with no side effects
Claude Code will refuse to start until one installation method is removed
Pre-Quiz: Production Concerns & Worked Example
A team raises max_retries from 2 to 6 on the TypeSafe SDK client but leaves timeout at its default of 30.0 seconds. What is the likely practical effect?
All six retries will always execute, since max_retries takes priority over timeout
Nothing changes, because timeout and max_retries are independent settings
Later retry attempts may never happen, because timeout is a total budget covering the initial attempt and all delays
The SDK will automatically extend timeout to accommodate the higher retry count
Why does the SDK's retry policy include backoff_jitter (subtracting a random fraction of each delay) rather than using a plain doubling backoff schedule?
Jitter reduces the total number of retries needed to succeed
Jitter prevents many correlated failures (like forty interface alerts from one search) from retrying on the exact same schedule and re-colliding on every attempt — similar to why OSPF randomizes hello jitter
Jitter is required by ServiceNow's Table API rate limiting
Jitter compresses the backoff schedule so retries happen sooner
Jev 1.13 is priced at $42 per billion input tokens. A triage pipeline consumes 2,500,000 input tokens in a day. What is the correct way to calculate that day's spend?
The worked example defines AUTO_ASSIGN_CONFIDENCE = 0.85 and ASSIGN_CONFIDENCE_FLOOR = 0.60 for the owning_team decision, while the confidence documentation's general bands are wider (above 0.9, 0.5–0.9, below 0.5). How does the chapter explain this difference?
It's a contradiction and the chapter recommends always using the documented 0.9/0.5 bands instead
The documented bands are a starting point; 0.85 and 0.60 are narrower cut points read off Chapter 8's own measured confidence-versus-accuracy data for this specific decision, and Chapter 12 ships the same pair
0.85 and 0.60 only apply to Salesforce cases, while ServiceNow incidents still use 0.9 and 0.5
The narrower values are a typo in the example code and should be corrected to 0.9 and 0.5
In the worked example, TEAM_TO_GROUP and TRIAGE_QUEUE store ServiceNow values like <sys_id of Network Ops - Routing> rather than display names such as "Network Ops - Routing". What is the stated reason for this choice?
sys_ids are shorter and reduce the JSON payload size sent to ServiceNow
Display names cannot be used in the assignment_group field at all
A sys_id survives someone renaming the group, whereas a hardcoded display name would break silently if the group's display name changed
sys_ids are required by the OAuth 2.0 bearer token authentication scheme
Production Concerns
Key Points
The SDK retries twice by default with jittered backoff from 0.5s to 5.0s inside a 30-second total budget; jitter prevents synchronized retry storms across correlated alerts.
timeout is a TOTAL budget covering the initial attempt and all delays — raising max_retries without raising timeout means later attempts never fire.
Catch TypeSafeRateLimitError and TypeSafeAPITimeoutError specifically, TypeSafeError as backstop, and always fail toward an unenriched fallback ticket rather than silence.
Jev 1.13 allows 250,000 tokens/sec and 1,200 requests/min (either ceiling returns 429); pricing is $42 per billion input tokens ($0.042 per million), output free — so batch questions rather than trim them.
Audit logs must capture question-set version, model id and request id FROM THE RESPONSE, every answer with probabilities and confidence, and token usage — the distribution, not just the winning label, is what makes a decision explainable later.
Retry policy, timeouts, and SDK exceptions
A retry policy decides which failures are worth trying again and how long to wait between attempts. The SDK ships documented defaults:
Setting
Default
Documented behavior
max_retries
2
Maximum retries after the initial attempt; 0 disables retries
backoff_initial
0.5s
Initial delay that doubles each attempt up to the maximum
backoff_max
5.0s
Upper limit for backoff delay
backoff_jitter
0.25
Fraction of each delay randomly subtracted, between 0 and 1
http_statuses
408, 429, 500–599
Which HTTP codes trigger retries
respect_retry_after
True
Honors Retry-After response headers
timeout
30.0s
Total retry budget per SDK call, including the initial attempt and delays
The jitter is the part engineers skip and then regret. If forty interface alerts fire from the same correlation search and every receiver retries on exactly the same doubling schedule, they re-collide on every attempt — the synchronized-timer problem that OSPF solves by randomizing its own hello jitter. Note the relationship between max_retries and timeout: the timeout is a total budget covering the initial attempt and all delays, so raising max_retries without raising timeout simply means later attempts never happen.
Exception class
Trigger
Notable properties
TypeSafeRateLimitError
The rate limit was exceeded (429)
retry_after_ms
TypeSafeAPITimeoutError
A request exceeded its configured timeout
timeout duration
TypeSafeAPIConnectionError
A request failed without an HTTP response
—
TypeSafeError
Base exception for SDK failures
—
Figure 11.4: Retry and exception handling path
flowchart TD
A["system_one Request"] --> B{"SDK Exception Raised"}
B -->|"TypeSafeRateLimitError, 429"| C["Log Retry After and Backoff"]
B -->|"TypeSafeAPITimeoutError"| D["Log Timeout"]
B -->|"TypeSafeError"| E["Log SDK Failure"]
C --> F["Create Fallback Incident"]
D --> F
E --> F
The fallback_incident path is not optional. If the decision service cannot decide, the alert still happened, so open an unenriched incident on a default assignment group and let a human triage it. An integration that drops alerts when its enrichment layer is down is strictly worse than no integration.
Key Takeaway: The SDK retries twice by default with 0.5s-to-5.0s jittered backoff inside a 30-second total budget, honoring Retry-After and retrying 408, 429, and 5xx. Catch TypeSafeRateLimitError and TypeSafeAPITimeoutError specifically, catch TypeSafeError as the backstop, and always have a path that creates an unenriched ticket.
Rate limits and pricing per million input tokens
A rate limit caps how fast you may call the service. For Jev 1.13, the documented limits are 250,000 tokens per second of throughput and 1,200 requests per minute, measured as two separate constraints — exceeding either returns 429 Too Many Requests. When you receive a 429 or 529, retry with exponential backoff instead of retrying immediately.
Model
Input price
Output price
Rate limits
Jev 1.13 (jev-latest)
$42 / Btok ($0.042 / Mtok)
Free
250,000 tokens/sec; 1,200 requests/min
jev-1.12 (cascade verifier)
$0.042 / 1M tokens
Free
—
gpt-5.4-mini (Rung 0)
$0.75 / 1M tokens
$4.50 / 1M tokens
—
gpt-5.5 (Rung 1)
$5.00 / 1M tokens
$30.00 / 1M tokens
—
Two units are worth memorizing: a Btok is a billion tokens, and an Mtok is a million tokens. Pricing is "charged per input token; output tokens are free." Free output changes how you design: the usual instinct with a text-generating model is to ask fewer, broader questions because long answers cost money. Here the answer is a probability distribution and costs nothing, so asking many questions in one call (Speculative Fan-Out) is economically rational as well as architecturally tidy.
For NOC work, the request-per-minute limit binds before throughput does. A 1,200-requests-per-minute ceiling is 20 per second, which sounds generous until a spanning-tree event produces a burst of correlated alerts. Batch where you can — one aggregated SPL row rather than forty webhooks — and let the jittered retry policy absorb the rest.
Visual animation — coming soon
Key Takeaway: Jev 1.13 allows 250,000 tokens per second and 1,200 requests per minute, with either ceiling returning 429, and charges $42 per billion input tokens while output is free. Free output is the reason to batch many questions into one call, and the request-rate ceiling is the reason to aggregate alerts before they leave Splunk.
Logging answers, probabilities, and model version for audit
When an incident review asks why the pipeline routed a P1 to the wrong team, "the model said switching" is not an answer. Every triage call should emit a structured log line containing, at minimum: question set version, the model id from the response (not the alias you requested), the request id (from the x-typesafe-request-id header), every answer's choice/score/noul value including ones you ignored, the full probability distribution and legend for Score answers, confidence alongside the threshold in force, input_tokens and output_tokens usage, and correlation keys such as the Splunk sid and ServiceNow sys_id.
Log the distribution, not just the winner. A switching choice at 0.94 confidence and a switching choice at 0.51 with routing close behind are the same field value and completely different events, and only the probabilities distinguish them after the fact.
Cost tracking works from that same usage field. At $42 per billion input tokens, spend is sum(input_tokens) * 42 / 1_000_000_000 — or, equivalently, divide the summed tokens by 1,000,000 and multiply by $0.042. Break it down by search_name to find the noisy saved search that is quietly consuming your budget.
Key Takeaway: Audit logging captures the question-set version, the model id and request id from the response, every answer with its probabilities and confidence, and token usage. The distribution is what makes a past decision explainable, and the usage counts are what make cost attributable to the alert that caused it.
Worked Example: Splunk to Jev to ServiceNow
Key Points
The full loop: Splunk webhook → FastAPI receiver builds state → system_one call → typed answers mapped to incident fields → POST to Table API → sys_id returned; every branch, including SDK failure, ends in a ticket.
Confidence on owning_team drives three tiers: ≥0.85 auto-assign, 0.60–0.85 assign-with-confirm, <0.60 triage queue — narrower cut points than the documentation's wider starting bands, derived from Chapter 8's measured data. Chapter 12 ships the same pair.
TEAM_TO_GROUP and TRIAGE_QUEUE store ServiceNow sys_ids, not display names, so the mapping survives someone renaming a group.
Basic auth in the example is shortened for readability; production sends an OAuth 2.0 bearer token per Chapter 8.
Test with replayed fixture payloads (one per team, one ambiguous case, one malformed body, one duplicate sid) against a sub-production ServiceNow instance — never let production traffic be the first traffic through a new receiver.
End-to-end webhook flow
Putting the pieces together: a saved search named "Network Interface Degradation Alert" runs in the network_ops app, aggregates error counters by host and interface, and fires a webhook to a URL that matches an allow-list entry. The receiver builds state, asks the noc-triage-v3 battery, maps the answers to ServiceNow fields, and POSTs an incident.
Figure 11.5: End-to-end flow from Splunk to Jev to ServiceNow
sequenceDiagram
participant Splunk
participant Receiver as FastAPI Receiver
participant TypeSafe as TypeSafe Jev
participant ServiceNow
Splunk->>Receiver: POST webhook with alert payload
Receiver->>Receiver: Build state from result fields
Receiver->>TypeSafe: system_one with triage questions
TypeSafe-->>Receiver: Choice, Score, and Noul answers
Receiver->>Receiver: Map answers to incident fields
Receiver->>ServiceNow: POST /api/now/table/incident
ServiceNow-->>Receiver: sys_id of created incident
A note on the auth shown in the example
The worked example uses HTTP basic auth (SNOW_AUTH = (user, pass)) against the ServiceNow instance only because it keeps the example to one line. Production sends an OAuth 2.0 bearer token, per Chapter 8 — swap basic auth for {"Authorization": f"Bearer {token}"} before this runs against a real instance.
# Confidence thresholds, matching Chapter 8's assignment ladder.
AUTO_ASSIGN_CONFIDENCE = 0.85 # at or above: write without a review flag
ASSIGN_CONFIDENCE_FLOOR = 0.60 # below: park in the triage queue
# sys_ids from sys_user_group in YOUR instance (Chapter 5). Display names
# also resolve, but a sys_id survives someone renaming the group.
TEAM_TO_GROUP = {
"routing": "<sys_id of Network Ops - Routing>",
"switching": "<sys_id of Network Ops - Campus>",
"wireless": "<sys_id of Network Ops - Wireless>",
"transport": "<sys_id of Network Ops - Transport>",
"security": "<sys_id of Security Operations>",
}
TRIAGE_QUEUE = "<sys_id of Network Ops - Triage>"
def to_incident(payload, response) -> dict:
team = response.answers["owning_team"]
if team.confidence >= AUTO_ASSIGN_CONFIDENCE:
group, note = TEAM_TO_GROUP[team.choice], "auto-assigned"
elif team.confidence >= ASSIGN_CONFIDENCE_FLOOR:
group, note = TEAM_TO_GROUP[team.choice], "assigned, confirm ownership"
else:
group, note = TRIAGE_QUEUE, "low confidence, human triage required"
...
This is the full loop: Splunk detects, Jev decides, ServiceNow records, and every branch — including the failure branch — ends with a ticket a human can work.
Visual animation — coming soon
Mapping confidence tiers to ServiceNow fields
The mapping is deliberately mechanical so that it can be reviewed like a policy document rather than debugged like code.
Confidence on owning_team
Documented guidance
Assignment group
Work note
≥ 0.85
Act automatically on high-stakes decisions
Mapped team group
auto-assigned
0.60 – 0.85
Proceed cautiously; seek confirmation
Mapped team group
assigned, confirm ownership
< 0.60
Route to humans or gather more information
Triage queue
low confidence, human triage required
The published bands in the documentation are wider — above 0.9, 0.5 to 0.9, below 0.5 — and the table above narrows them to the 0.85 and 0.60 cut points Chapter 8 arrived at for this assign/route action class. That is the intended workflow, not a contradiction: the documentation supplies the starting bands, and you replace them with values read off your own confidence-versus-accuracy table. Chapter 12 ships these same two numbers as CONFIDENCE_AUTO and CONFIDENCE_FLOOR.
Typed answer
ServiceNow field
Rule
severity (Score)
urgency
≥ 2.0 → "1"; ≥ 1.0 → "2"; otherwise "3"
service_affecting + redundancy_lost (Noul)
impact
Both ≥ 0.7 → "1"; service-affecting only → "2"; otherwise "3"
owning_team (Choice)
assignment_group
Label lookup, overridden by the confidence tier
All answers, model, version
work_notes
Human-readable audit trail on the ticket itself
Writing the probabilities into work_notes is the cheap version of an audit trail, and it pays off in the first postmortem. An engineer who disagrees with the assignment sees that switching won at 0.52 with transport at 0.41, and understands the alert text was genuinely ambiguous rather than the system broken.
Testing with replayed alerts
Never let production traffic be the first traffic through a new receiver. Capture real webhook bodies with a temporary logging endpoint, save them as JSON files, and replay them against a sub-production ServiceNow instance:
Build the fixture set to cover four categories: one clean example per team so you can see the Choice labels resolve correctly; at least one genuinely ambiguous alert that should land in the triage queue, proving the confidence gate fires; a malformed payload with a missing result field, which should be rejected by the Pydantic model with a 422 rather than crashing; and a payload replayed twice with the same sid, confirming duplicate handling works.
Key Takeaway: The end-to-end flow is Splunk webhook, typed decision, confidence-tiered mapping, Table API POST, with an unenriched fallback ticket on any SDK failure. Replay saved payloads through the receiver against a sub-production ServiceNow instance, covering one alert per team, an ambiguous case, a malformed body, and a duplicate sid.
Post-Quiz: Production Concerns & Worked Example
A team raises max_retries from 2 to 6 on the TypeSafe SDK client but leaves timeout at its default of 30.0 seconds. What is the likely practical effect?
All six retries will always execute, since max_retries takes priority over timeout
Nothing changes, because timeout and max_retries are independent settings
Later retry attempts may never happen, because timeout is a total budget covering the initial attempt and all delays
The SDK will automatically extend timeout to accommodate the higher retry count
Why does the SDK's retry policy include backoff_jitter (subtracting a random fraction of each delay) rather than using a plain doubling backoff schedule?
Jitter reduces the total number of retries needed to succeed
Jitter prevents many correlated failures (like forty interface alerts from one search) from retrying on the exact same schedule and re-colliding on every attempt — similar to why OSPF randomizes hello jitter
Jitter is required by ServiceNow's Table API rate limiting
Jitter compresses the backoff schedule so retries happen sooner
Jev 1.13 is priced at $42 per billion input tokens. A triage pipeline consumes 2,500,000 input tokens in a day. What is the correct way to calculate that day's spend?
The worked example defines AUTO_ASSIGN_CONFIDENCE = 0.85 and ASSIGN_CONFIDENCE_FLOOR = 0.60 for the owning_team decision, while the confidence documentation's general bands are wider (above 0.9, 0.5–0.9, below 0.5). How does the chapter explain this difference?
It's a contradiction and the chapter recommends always using the documented 0.9/0.5 bands instead
The documented bands are a starting point; 0.85 and 0.60 are narrower cut points read off Chapter 8's own measured confidence-versus-accuracy data for this specific decision, and Chapter 12 ships the same pair
0.85 and 0.60 only apply to Salesforce cases, while ServiceNow incidents still use 0.9 and 0.5
The narrower values are a typo in the example code and should be corrected to 0.9 and 0.5
In the worked example, TEAM_TO_GROUP and TRIAGE_QUEUE store ServiceNow values like <sys_id of Network Ops - Routing> rather than display names such as "Network Ops - Routing". What is the stated reason for this choice?
sys_ids are shorter and reduce the JSON payload size sent to ServiceNow
Display names cannot be used in the assignment_group field at all
A sys_id survives someone renaming the group, whereas a hardcoded display name would break silently if the group's display name changed
sys_ids are required by the OAuth 2.0 bearer token authentication scheme