In the previous post we added conversation persistence to the Microsoft Foundry Citadel Platform on Azure. As a result, every agent run now produces a structured document in the spoke’s Cosmos DB conversations container. The agent is fully operational: it routes through the APIM governance hub, executes tool calls, stores its history, and returns grounded responses. However, the question that every enterprise AI architect eventually faces remains: what happens when it needs to stop?
Not a graceful shutdown. Not a redeployment. An immediate, operator-triggered containment the kind you need when an agent is behaving unexpectedly, consuming runaway tokens, or has been flagged by your security team. In a Microsoft Foundry Citadel Platform on Azure deployment, the answer is the Kill Switch: a layered containment system built into the APIM hub that stops agent traffic cold without touching the agent code, the spoke, or the Azure OpenAI deployment.
This post implements three of the five Citadel kill switch layers against the hub we deployed in Sweden Central:
- Layer 1 — Named Value flip: instant global block via a single boolean
- Layer 2 — JWT claim block: identity-based containment via header validation
- Layer 3 — Agent ID blocklist: surgical per-agent blocking
The Scenario
The weather agent (agent_with_memory.py) is running in production. Specifically, it is routing through apim-wpvlimv4ngkns.azure-api.net, storing conversations in the spoke Cosmos DB, and generating token usage events in the hub Cosmos DB. Everything is working. Then your security team flags it. The agent needs to stop immediately while the incident is investigated. You have seconds, not minutes. For example, redeploying the spoke takes too long. Rotating the APIM subscription key is irreversible and affects all consumers. Therefore, the Kill Switch is the right tool.
The Kill Switch is the right tool. The APIM hub has built-in pre-wiring, requires no code changes, and can trigger actions in under 30 seconds.
To ensure reliability, always pre-wire the kill switch as Layer 1 before you need it. Remember, you can’t flip a Named Value that doesn’t exist. In addition, the inbound policy must already be in place, checking the Named Value on every request, before any incident occurs.
Prerequisites
From the previous posts you should have:
- Hub deployed in
rg-ai-hub-gateway-devwith APIM instanceapim-wpvlimv4ngkns agent_with_memory.pyrunning and saving to Cosmos DB- Azure CLI authenticated
Citadel Kill Switch Layer 1 — Named Value Flip
How It Works
A Named Value called kill-switch-enabled is created in APIM and set to false. An inbound policy on the OpenAI API checks this value on every request. When the value is flipped to true, all requests through the gateway immediately return HTTP 403 — no code changes, no redeployment, no spoke involvement.
Step 1.1 — Create the Named Value
az apim nv create ` --resource-group rg-ai-hub-gateway-dev ` --service-name apim-wpvlimv4ngkns ` --named-value-id kill-switch-enabled ` --display-name "kill-switch-enabled" ` --value "false" ` --secret false

Verify it was created:
az apim nv show ` --resource-group rg-ai-hub-gateway-dev ` --service-name apim-wpvlimv4ngkns ` --named-value-id kill-switch-enabled ` --query "value" -o tsv
Should return false.

Step 1.2 — Add the Inbound Policy
In the Azure Portal:
- Navigate to
apim-wpvlimv4ngkns→ APIs → Azure OpenAI Service API → All operations - Click Policies → Inbound processing → Edit
- Add this policy inside the
<inbound>section, before any other policies:
<!-- Kill Switch Layer 1: Named Value flip --><set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" /><choose> <when condition="@((bool)context.Variables["killSwitchActive"])"> <return-response> <set-status code="403" reason="Agent Suspended" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="x-kill-switch-layer" exists-action="override"> <value>1-named-value</value> </set-header> <set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub.", "layer": 1}}</set-body> </return-response> </when></choose>
The dedicated Named Value check policy provides a cleaner approach.
<!-- Kill Switch Layer 1: Named Value flip --><!-- Pre-wire this BEFORE any incident. Set kill-switch-enabled=true to activate. --><set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" /><choose> <when condition="@((bool)context.Variables["killSwitchActive"])"> <return-response> <set-status code="403" reason="Agent Suspended" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="x-kill-switch-layer" exists-action="override"> <value>1-named-value</value> </set-header> <set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub. Contact your administrator.", "layer": 1}}</set-body> </return-response> </when></choose>
Click Save.
Step 1.3 — Confirm Agent Runs Normally
With kill-switch-enabled set to false, the agent should still work:
python agent_with_memory.py
Expected output: normal run, conversation saved, answer returned.
Step 1.4 — Trigger the Kill Switch
az apim nv update ` --resource-group rg-ai-hub-gateway-dev ` --service-name apim-wpvlimv4ngkns ` --named-value-id kill-switch-enabled ` --value "true"

Now run the agent:
python agent_with_memory.py
Expected output:

The agent stops. No spoke changes occur. No code changes happen. One CLI command executes.
Step 1.5 — Reset
az apim nv update ` --resource-group rg-ai-hub-gateway-dev ` --service-name apim-wpvlimv4ngkns ` --named-value-id kill-switch-enabled ` --value "false"
Citadel Kill Switch Layer 2 — Agent Approval Header
How It Works
The agent is required to pass a custom header x-agent-token containing a signed JWT with a specific claim (agt-approved: true). The APIM inbound policy validates this claim. If the claim is absent or the token is invalid, the system blocks the request with a 401 status. This action simulates identity-based containment, revoking the agent’s token or invalidating its claim at the identity provider level.
Step 2.1 — Update the Agent to Send a Header
Add the x-agent-token header to agent_with_memory.py. In this demo, we simulate the token by using a simple header value. IIn a production environment, Entra ID issues a JWT.
Modify the AzureOpenAI client creation in agent_with_memory.py:
client = AzureOpenAI( azure_endpoint=apim_base, api_key=cfg["APIM_SUBSCRIPTION_KEY"], api_version="2024-02-01", default_headers={ "x-agent-id": "citadel-weather-agent-v1" })
Step 2.2 — Add the JWT Claim Check Policy
In the portal, add this policy after the Layer 1 block in the inbound section:
<!-- Kill Switch Layer 2: Agent approval header check --><choose> <when condition="@(context.Request.Headers.GetValueOrDefault("x-agent-approved", "false") != "true")"> <return-response> <set-status code="401" reason="Agent Not Approved" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="x-kill-switch-layer" exists-action="override"> <value>2-agent-approval</value> </set-header> <set-body>{"error": {"code": "AgentNotApproved", "message": "Agent identity could not be verified. Approval header missing or invalid.", "layer": 2}}</set-body> </return-response> </when></choose>
Step 2.3 — Trigger Layer 2
Remove the x-agent-approved header from the agent (or set it to false) and run:
python agent_with_memory.py
Expected output:

Note the response header x-kill-switch-layer: 2-agent-approval this indicates which containment layer fired and is critical for incident triage.
Pitfall: Policy Order Matters
Layer 1 must appear before Layer 2 in the policy document. APIM evaluates inbound policies top to bottom and stops at the first <return-response>. If Layer 2 appears before Layer 1, a globally suspended agent would return a 401 (identity error) instead of a 403 (suspended), obscuring the true containment reason in your incident log.
Citadel Kill Switch Layer 3 — Agent ID Blocklist in APIM
How It Works
A Named Value called blocked-agent-ids holds a comma-separated list of agent IDs. The inbound policy checks the x-agent-id header against this list. When agents match, the system blocks them with a 403 status code. Non-matching agents continue operating normally. This approach allows for surgical containment, stopping one specific agent while allowing all others to function.
Step 3.1 — Create the Blocklist Named Value
az apim nv create ` --resource-group rg-ai-hub-gateway-dev ` --service-name apim-wpvlimv4ngkns ` --named-value-id blocked-agent-ids ` --display-name "blocked-agent-ids" ` --value "none" ` --secret false
Start with an empty value — no agents blocked.
Step 3.2 — Add the Blocklist Policy
Add this policy after Layer 2 in the inbound section:
<!-- Kill Switch Layer 3: Agent ID blocklist --><set-variable name="agentId" value="@(context.Request.Headers.GetValueOrDefault("x-agent-id", ""))" /><set-variable name="blockedIds" value="@("{{blocked-agent-ids}}")" /><choose> <when condition="@{ var agentId = (string)context.Variables["agentId"]; var blockedIds = (string)context.Variables["blockedIds"]; if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(blockedIds)) { return false; } return blockedIds.Split(',').Any(id => id.Trim() == agentId.Trim()); }"> <return-response> <set-status code="403" reason="Agent Blocked" /> <set-header name="Content-Type" exists-action="override"> <value>application/json</value> </set-header> <set-header name="x-kill-switch-layer" exists-action="override"> <value>3-agent-blocklist</value> </set-header> <set-body>{"error": {"code": "AgentBlocked", "message": "Agent has been added to the governance blocklist.", "layer": 3}}</set-body> </return-response> </when></choose>
Step 3.3 — Add the Agent to the Blocklist
az apim nv update ` --resource-group rg-ai-hub-gateway-dev ` --service-name apim-wpvlimv4ngkns ` --named-value-id blocked-agent-ids ` --value "citadel-weather-agent-v1"
Run the agent:
python agent_with_memory.py
Expected output:

Step 3.4 — Surgical Validation
The power of Layer 3 is specificity. If you had a second agent with a different x-agent-id say citadel-docs-agent-v1 it would pass through Layer 3 unaffected while citadel-weather-agent-v1 remains blocked. One agent stopped, all others running. This is the enterprise AI governance pattern: granular control without broad disruption.
Remove the agent from the blocklist:
az apim nv update --resource-group rg-ai-hub-gateway-dev--service-name apim-wpvlimv4ngkns --named-value-id blocked-agent-ids--value "none"
Validating the Citadel Kill Switch in Application Insights
Rather than using the CLI — which has a 5–10 minute Log Analytics ingestion lag — go directly to Application Insights in the portal for immediate results:
- Portal →
appi-apim-wpvlimv4ngknsinrg-ai-hub-gateway-dev - Left sidebar → Logs
- Paste and run this query:
requests| where timestamp > ago(2h)| where resultCode in ("200", "401", "403")| project timestamp, resultCode, duration, name| order by timestamp desc
The results table tells the complete kill switch story in two columns — resultCode and duration:

The duration contrast is the definitive proof that the kill switch works as designed. The 401s and 403s resolve in under 50ms, stopped cold at the APIM inbound policy before a single token is sent to Azure OpenAI. The 200s take 683ms–2010ms because they made the full round trip through the governance hub to Azure OpenAI and back.
Zero tokens consumed on blocked requests, zero cost, and zero Cosmos DB writes in the spoke. The agent is stopped at the perimeter.
For a sharper view that highlights exactly which kill switch layer fired on each blocked request, add the response header to the query. Unfortunately APIM response headers are not automatically projected into the requests table in Application Insights — but you can distinguish the layers by combining result code and timing:
requests| where timestamp > ago(2h)| where resultCode in ("200", "401", "403")| extend killSwitchLayer = case( resultCode == "401", "Layer 2 — agent approval", resultCode == "403" and duration < 10, "Layer 1 or 3 — gateway block", resultCode == "200", "Normal — LLM call completed", "Unknown" )| project timestamp, resultCode, duration, killSwitchLayer| order by timestamp desc

This gives you a readable incident log showing which containment layer was active at each point in time, directly useful for DORA incident post-mortem documentation and EU AI Act Article 17 risk management records.
The Complete Three-Layer Kill Switch Policy
Here is the complete inbound policy block containing all three layers, ready to paste into APIM:
<!-- Kill Switch Layer 1: Named Value flip -->
<set-variable name="killSwitchActive" value="@("{{kill-switch-enabled}}" == "true")" />
<choose>
<when condition="@((bool)context.Variables["killSwitchActive"])">
<return-response>
<set-status code="403" reason="Agent Suspended" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>1-named-value</value>
</set-header>
<set-body>{"error": {"code": "KillSwitchActive", "message": "Agent access has been suspended by the governance hub.", "layer": 1}}</set-body>
</return-response>
</when>
</choose>
<!-- Kill Switch Layer 2: Agent approval header -->
<choose>
<when condition="@(context.Request.Headers.GetValueOrDefault("x-agent-approved", "false") != "true")">
<return-response>
<set-status code="401" reason="Agent Not Approved" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>2-agent-approval</value>
</set-header>
<set-body>{"error": {"code": "AgentNotApproved", "message": "Agent identity could not be verified. Approval header missing or invalid.", "layer": 2}}</set-body>
</return-response>
</when>
</choose>
<!-- Kill Switch Layer 3: Agent ID blocklist -->
<set-variable name="agentId" value="@(context.Request.Headers.GetValueOrDefault("x-agent-id", ""))" />
<set-variable name="blockedIds" value="@("{{blocked-agent-ids}}")" />
<choose>
<when condition="@{
var agentId = (string)context.Variables["agentId"];
var blockedIds = (string)context.Variables["blockedIds"];
if (string.IsNullOrEmpty(agentId) || string.IsNullOrEmpty(blockedIds)) { return false; }
return blockedIds.Split(',').Any(id => id.Trim() == agentId.Trim());
}">
<return-response>
<set-status code="403" reason="Agent Blocked" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-header name="x-kill-switch-layer" exists-action="override">
<value>3-agent-blocklist</value>
</set-header>
<set-body>{"error": {"code": "AgentBlocked", "message": "Agent has been added to the governance blocklist.", "layer": 3}}</set-body>
</return-response>
</when>
</choose>
Pitfalls Summary
| Pitfall | Fix |
|---|---|
| Named Value doesn’t exist at incident time | Pre-wire Layer 1 during normal operations — never during an incident |
Policy evaluation error on {{kill-switch-enabled}} | Named Value must exist before the policy referencing it is saved |
| Layer 2 fires before Layer 1 in policy | Policy order matters — Layer 1 must be first in the inbound block |
| Agent ID header not sent | Add x-agent-id to default_headers in AzureOpenAI client |
| Blocklist with trailing spaces blocks nothing | Use .Trim() in the policy C# expression when splitting |
| Kill switch left active after test | Always reset Named Values after testing — kill-switch-enabled=false, blocked-agent-ids="" |
What the Kill Switch Demonstrates About Citadel
The three layers reveal something important about the Citadel architecture: governance lives in the hub, not the agent. The agent code has no knowledge of the kill switch. The spoke has no kill switch configuration. The Azure OpenAI deployment is untouched. All containment logic is in the APIM hub’s inbound policy — one place, centrally managed, instantly effective.
This is the enterprise AI control plane pattern in practice. When an incident occurs:
- Layer 1 stops everything immediately while you triage
- Layer 2 enforces identity verification once normal operations resume
- Layer 3 surgically targets the offending agent while other agents continue
The x-kill-switch-layer response header ensures your incident log captures exactly which containment mechanism fired, giving you a clean audit trail for post-mortem analysis — directly relevant for DORA incident reporting and EU AI Act Article 17 risk management documentation.
What’s Next
The next post in this series takes the dev setup and hardens it for non-prod: networkIsolation=true, APIM Premium SKU, per-spoke subscription keys with independent quotas, and Azure Policy at the management group level. The kill switch policies we built here carry forward unchanged governance in the hub environment, which is environment-agnostic.