Your Teams agent's policy refusal is showing up as "the agent hit an error"
A Microsoft 365 Agents SDK custom engine agent holds its own model client, so it is ordinary library work — except that the guardrail call RAISES on a block rather than returning one, three separate exception types reach a governed handler, and on the TypeScript port the session cap silently never accumulates. A step-by-step journey with the real Agents Playground on screen, a mid-stream break, a verifiable audit chain, and the whole agent replayed for $0. No tenant, no tunnel, no key. Plus the governance Adaptive Card that makes all of it visible to the person in the chat, because `channelData` is not.
Here is a failure worth more than the feature that produced it.
You put a guardrail in front of your Teams agent. A prompt-injection attempt arrives, the guardrail does its job, and the user reads:
the agent hit an error
Not your refusal. Not “I can’t process that message.” An error — indistinguishable, from the other side of the channel, from an agent that fell over.
The cause is one call shape. evaluate_async raises GuardrailTripped on a block; it does not
hand you back a decision list with action="block" in it. So a handler that reads only the return
value never sees the block, and the exception escapes as an unhandled turn error. Everything about
that code looks right, and it will pass every offline test you write against a clean prompt.
What you’ll build
A Microsoft 365 Agents SDK custom engine agent — the Agents Toolkit tile whose own description is “you manage orchestration and provide your own LLM” — with budgets, gates, a tamper-evident audit chain, a mid-stream budget breaker, an end-to-end test that costs nothing, and a governance card that tells the person in the chat what every library just did.
flowchart TB A["Teams / Playground<br/>Activity"] --> B["your AgentApplication<br/>POST /api/messages"] B --> C["gate: guardrails<br/>block or redact"] C --> D["budget: one fuse per turn<br/>session cap in TurnState"] D --> E["instrument(client)<br/>your model, your bill"] E --> F["reply + channelData.cendor"] F --> I["governance card<br/>what the user sees"] D --> G["acttrace chain<br/>verify() offline"] E --> H["cassette<br/>replay the whole agent, $0"]
That description is also the boundary, and it is why this is ordinary library work: your process hosts
AgentApplication behind POST /api/messages, and the model call inside your activity("message")
handler is an ordinary provider-SDK call. Your call, your tokens, your bill. A Declarative Agent
is the opposite topology — Microsoft holds the model and bills in Copilot Credits — so there is nothing
for a token library to govern there. Good tools, different jobs.
Step 0 — where to start
No key. No tenant. No tunnel. No bot registration. The provider client is the only stand-in; the
AgentApplication, the CloudAdapter, the JWT middleware, the TurnState and the socket are all real.
git clone https://github.com/cendorhq/cendor-cookbook && cd cendor-cookbook && uv sync
uv run --group agents-m365 python recipes/agents/m365-custom-engine-py/main.py
git clone https://github.com/cendorhq/cendor-cookbook-js && cd cendor-cookbook-js
cd recipes/agents/m365-custom-engine-js && npm install && node index.mjs
agent.py / agent.mts is the file you copy into your own project. main.py / index.mts is the
narrated run that drives it and prints what follows.
Step 1 — catch the refusal so it is yours to word
from cendor.guardrails import GuardrailTripped, evaluate_async
async def gate(guardrails, stage, payload, *, conversation_id):
ctx = Context(stage=stage, agent="m365-custom-engine",
metadata={"conversation": conversation_id})
try:
return await evaluate_async(guardrails, stage, payload, ctx)
except GuardrailTripped as tripped:
return payload, list(tripped.decisions) # now YOU decide what the user reads
import { GuardrailTripped, evaluateAsync } from '@cendor/guardrails';
async function gate(guardrails, stage, payload, conversationId) {
const ctx = { stage, agent: 'm365-custom-engine', metadata: { conversation: conversationId } };
try {
return await evaluateAsync(guardrails, stage, payload, ctx);
} catch (tripped) {
if (!(tripped instanceof GuardrailTripped)) throw tripped;
return [payload, [...tripped.decisions]]; // now YOU decide what the user reads
}
}
The redact path is why this is evaluate_async and not a boolean check: what comes back is the
rewritten payload, so the model never sees the e-mail address in the first place.
Step 2 — one wrap, and one fuse per turn
The client is wrapped once, at startup. Everything above it is your handler:
from cendor.core import instrument
from openai import AsyncOpenAI
client = instrument(AsyncOpenAI()) # or AsyncAnthropic(...), or an Azure v1 GA client
import { instrument } from '@cendor/core';
import OpenAI from 'openai';
const client = instrument(new OpenAI()); // or new Anthropic(), or an Azure v1 GA client
instrument() detection is structural rather than name-based, so pointing that same client at a
Microsoft Foundry deployment (base_url=<endpoint>/openai/v1/, no api-version) changes nothing else.
The budget wraps the whole handler body, not each call — so a tool loop’s five calls share one fuse, which is the only arrangement that matches what a turn actually costs you:
from cendor.tokenguard import budget, track
with track(conversation=conversation_id), budget(usd=remaining, on_exceed="block"):
... # every model call in this turn shares the allowance
import { track, withBudget } from '@cendor/tokenguard';
await withBudget({ usd: remaining, onExceed: 'block' }, async () => {
// every model call in this turn shares the allowance
});
Note withBudget(cfg, cb) on the TypeScript side — budget(cfg) is curried there, and the two ports
differ on purpose rather than by accident.
Step 3 — handle three exception types, not one
This is the part that makes a governed handler different from an ungoverned one. Three separate exceptions reach it, from three different places:
| exception | thrown by | thrown from |
|---|---|---|
GuardrailTripped | guardrails | your gate() call, before the model |
BudgetExceeded | tokenguard | the budget scope, pre-flight or mid-stream |
PolicyViolation | acttrace guard() | inside the provider call |
The third one surprises people. The guard() installed at startup sits at the client seam, so it
raises from inside await client.chat.completions.create(...) — a line that does not mention
governance at all. Handle it, and report the finding’s categories, never the matched value: the
whole point of a redaction is not to move the secret somewhere else.
Step 4 — run one turn and read the numbers
Six governed turns run offline against that real host stack:
--- one governed turn ------------------------------------------
reply : Your refund is on its way.
tokens : 41 in / 8 out (gpt-4o-mini)
cost : $0.0000109500 Decimal, priced from the snapshot
session : $0.0000109500 of $5.00 (in TurnState)
trace_id : cookbook-m365:089f1241-5536-44aa-8635-69d1eb74c6cf
envelope : channelData.cendor = {"governance": "ok", "trace_id": "cookbook-m365:089f1241-5536-44...
Decimal, to the seventh decimal place, because provider costs live there and a float accumulator
drifts in a number somebody eventually reconciles against an invoice. The session total lives in the
host’s own TurnState, which is what makes it survive the turn — and the trace_id is a fresh uuid on
every run, so yours will differ.
Step 5 — the governance moment
--- governance that fired --------------------------------------
input gate : input_blocked -> "I can't process that message."
redaction : ['email_redact:redact']
mid-stream : broke_on_budget after 2 channel activities
session cap : session_cap_reached -> "This conversation has used its budget, so I didn't call the model."
pre-flight : preflight_refused -> "That request would exceed what's left of this conversation's budget."
audit chain : verify=True — ok: 14 entries, head 038dce3fb364…
Six things happened there and only one of them is a model call being blocked. The injection attempt was
refused before the model saw it. An e-mail address was rewritten before the request left the process. A
streamed answer was cut mid-flight when the allowance died, two channel activities in. A later turn
was refused with zero provider calls because the conversation’s cumulative cap was spent. And fourteen
entries went into a hash-chained file that verify() re-walks offline — edit one byte of it and
verification fails at that exact entry. (Entry count and verify() reproduce on your machine; the head
hash does not, because entries carry timestamps.)
Read the last two refusal sentences again, because they are deliberately different, and this is the
subtlest thing on the page. A pre-flight estimate reserves the full max_output_tokens. Measured on
one turn: $0.0000333 estimated against $0.00001095 actually spent — a 3.04× over-reservation. So
a pre-flight check can refuse while the ledger still shows headroom. Word that refusal as “you reached
your cap” and you have told the user something false. Say the request would exceed what is left.
For the same reason, a pre-flight estimate and a mid-stream breaker are mutually exclusive on a streamed turn: any allowance small enough for the breaker to fire is already smaller than the estimate, so the turn would be refused before a chunk existed. A streamed turn’s fuse is the breaker.
Step 6 — drive it in the real Playground
Everything so far is a scripted run. To use the agent the way a person would, Microsoft’s own M365 Agents Playground talks to the same endpoint — still no tenant, no tunnel, no registration. Two terminals:
# terminal 1 — the agent on :3978
uv run --group agents-m365 python recipes/agents/m365-custom-engine-py/serve.py
# terminal 2 — Microsoft's Playground UI, on :56150
npm i -g @microsoft/m365agentsplayground
agentsplayground -e "http://localhost:3978/api/messages" -c emulator
# terminal 1 — the agent on :3979
cd recipes/agents/m365-custom-engine-js && npm install && node serve.mjs
# terminal 2 — Microsoft's Playground UI, on :56150
npm i -g @microsoft/m365agentsplayground
agentsplayground -e "http://localhost:3979/api/messages" -c emulator
A normal turn looks like a normal turn — which is the point. The governance is not in the user’s way:
Now the injection attempt. This is the failure at the top of the post, fixed — the user reads the policy’s refusal, worded by you, instead of “the agent hit an error”:
These two frames are single-theme on purpose. The Playground ships one appearance: measured against
@microsoft/m365agentsplayground0.2.28, its Fluent provider renders the same white surface under bothprefers-color-schemevalues whilematchMediareports the flip correctly, and it exposes no theme control. Every other screenshot on this blog is a dark/light pair.
Where the governance envelope actually is
The reply Activity carries a channelData.cendor envelope — trace id, cost, usage, session spend
against the cap. The chat pane does not render it, and it is easy to conclude it is simply not visible
here. It is: click the outbound activity in the Log Panel (message 201, the one going to the
Playground) and the Request tab shows the whole Activity, envelope included.
"channelData": {
"cendor": {
"governance": "ok",
"trace_id": "f256ee1b-92dd-46ac-b153-acf15b0adc34:024e473c-bedc-4533-9c24-05062557a4a2",
"cost_usd": "0.0000109500",
"model": "gpt-4o-mini",
"input_tokens": 41,
"output_tokens": 8,
"session_spent_usd": "0.0000876000",
"session_cap_usd": "5.00"
}
}
That is the per-turn cost, and the running session total against its cap, on the wire, in a tool Microsoft ships. Note the session figure is larger than this turn’s cost — it is the accumulation working, which is exactly what the TypeScript trap further down destroys.
If you would rather not click, smoke.py / smoke.mjs drives one Playground-shaped turn end to end
and prints the envelope keys, so CI can assert on it.
Step 7 — make the governance visible in the chat
The envelope is correct, complete, and invisible to the person in the conversation. That is not
a Cendor decision — it is measured: Playground 0.2.28’s UI projection (convertMessage()) forwards a
fixed field set and reads channelData only for feedbackLoopEnabled. Teams and WebChat behave the
same way. Your governance is on the wire and nobody in the chat can see it.
attachments, by contrast, are forwarded and rendered. So the recipe attaches a governance
Adaptive Card — opt-in, off by default:
from microsoft_agents.activity import Activity, ActivityTypes, Attachment
activity = Activity(type=ActivityTypes.message, text=text)
activity.channel_data = {**(activity.channel_data or {}), **envelope.as_channel_data()}
if cards:
activity.attachments = [
Attachment(
content_type="application/vnd.microsoft.card.adaptive",
content=governance_card(facts), # ~60 lines, one row per library
)
]
await context.send_activity(activity)
const activity = Activity.fromObject({ type: ActivityTypes.Message, text });
activity.channelData = { ...(activity.channelData ?? {}), ...channelDataFor(envelope) };
if (cards) {
activity.attachments = [
{ contentType: 'application/vnd.microsoft.card.adaptive', content: governanceCard(facts) },
];
}
await context.sendActivity(activity);
Type /cards on in the chat (or start with M365_CARDS=1) and the same governed turn looks like
this — the real Playground, the real agent, nothing staged:
The shape is deliberate, and it is the one cendor.ai/try uses: one row per
library, saying what that library did, in words. A FactSet of raw keys is a JSON dump with better
spacing. What a reviewer needs to read is “tokenguard refused this before any call, and here is the
number it refused on” — so the card leads with a sentence and then attributes every fact to the tool
that produced it.
Which matters most when governance says no. This is the failure from the top of this post, closed:
no model call was made — nothing reached the provider is the line worth stealing. A refusal that
does not say what it cost you is only marginally better than “the agent hit an error”.
Four decisions in that card worth copying
-
A pre-flight refusal must NOT say “you reached your cap.” The estimate reserves the full output allowance — measured 3.04× over-reservation on one real turn — so it can refuse while the ledger still shows headroom. The session-cap refusal is a different event and does say exactly that. Both recipes assert both directions, and the second is the negative control for the first. Without it, “the card must not say cap reached” would pass on a card that had quietly stopped explaining anything at all.
-
The money carries its provenance.
rate from azure as of 2026-07-01comes fromprices.explain(MODEL)(cendor-core1.19 /@cendor/core3.6). A USD cap is only as good as the rate underneath it — so when a model is unpriced the card says UNPRICED — every USD guard on this turn is a silent no-op rather than printing a comfortable-looking$0. -
The card is styling. Governance is not. Plain text stays the canonical reply and the card is off by default, so nothing that is enforced depends on a channel rendering an attachment.
/cards offis asserted to remove the attachment entirely. -
The numbers are the same turn’s, not a re-computation. The test compares the card against that reply’s own
channelData.cendor, keyed on the per-turntrace_id. Comparing against a different turn would have passed for the wrong reason — the offline fake is deterministic, so two turns cost exactly the same. The trace id is the only field that catches it.
⚠️ One trap the screenshots above nearly hid. Driving the Playground from a script,
locator.fill()on its composer does nothing and says nothing: the box is acontenteditabledivwithrole="textbox", not a<textarea>.Enterthen sends an empty message, the agent receives nomessageActivity at all, and the page still renders a perfectly good app shell — so an automated screenshot run produces a picture of an empty chat and reports success. Type through the keyboard, and assert on a string only the card can produce.
Step 8 — watch it, if you want to
The agent needs no server, and nothing above this line depends on one. But a custom engine agent is a long-running process, and the same governed turns export to any OTLP backend with no Cendor-specific telemetry code — Azure Monitor, your existing collector, or the self-hosted Cendor Monitor container if you just want to watch a conversation while you build it:
docker run --rm -p 3000:3000 -p 4318:4318 -v cendor-monitor-data:/data \
ghcr.io/cendorhq/cendor-monitor:0.15.0
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
⚠️ The environment variable alone does nothing, and it fails silently. Cendor’s telemetry is
mode=auto: it attaches once your app has configured a global OpenTelemetry provider, and emits nothing before that. Measured — with only the variable set, the agent runs green, every turn reports its cost, and the console stays at 0 runs.CENDOR_DEBUG_TELEMETRY=1prints which state you are in:armed (mode=auto); waiting for a providerversusprovider=detected, emitter=attached.The recipe deliberately ships no OTel bootstrap — a hosted agent’s telemetry belongs to the host application, not to a sample. Add the standard one at startup, beside
instrument():
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider) # the ONE global setup — your app owns it
import { NodeSDK } from '@opentelemetry/sdk-node';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
new NodeSDK({ traceExporter: new OTLPTraceExporter() }).start(); // the ONE global setup
With that in place the recipe’s six governed turns land as 10 calls and 45 governance events on
gpt-4o-mini, budget blocks and guardrail decisions included:
Optional dev tooling, never in the data path, and an operational copy — verify() runs on the
audit file on your host, never on this screen.
Step 9 — replay the entire agent for nothing
--- $0 whole-agent CI ------------------------------------------
recorded : ['Your refund is on its way.', 'Your refund is on its way.'] (2135 bytes)
replayed : ['Your refund is on its way.', 'Your refund is on its way.'] no key, no network
identical : True
Not the model call — the agent. HTTP → middleware → adapter → governed handler → channel, replayed
from a 2 KB cassette. Same replies, byte for byte. No shim is needed in either language: since
cendor-core 1.14.1 a replayed call on an async client is awaitable, so the handler’s ordinary
await client.chat.completions.create(...) is all it takes.
One placement rule, because it fails silently otherwise: the cassette scope must wrap the listener
start, not your driver. Replay matches calls by a session id stamped from a context variable, and an
aiohttp request-handler task inherits the context that was active when TCPSite.start() ran. A scope
around your client-side driver never reaches the handler, and every call goes to the network while your
test still passes.
The one that only bites TypeScript
This is the measurement I would most want to know before shipping, and it is not in either language’s docs by accident — it is a real difference between the two hosting SDKs.
Python’s AgentApplication.run() awaits turn_state.save() unconditionally. The TypeScript port
saves only inside if (this._afterTurn.length > 0), and the official Node quickstart registers no
after-turn handler. So a cumulative ledger kept in TurnState never accumulates, and a session cap
built on it never binds — while every per-turn number stays perfectly correct, which is what makes it
hard to notice.
One line fixes it:
app.onTurn('afterTurn', async () => true); // without this, TurnState is never persisted
The TypeScript recipe proves it rather than asserting it, with a negative control in its own output:
--- the afterTurn trap, measured -------------------------------
with app.onTurn('afterTurn') : $0.0000219 cumulative after 2 turns
without it (the quickstart) : $0.00001095 — one turn's worth, every turn
Two turns, and the second reading never moves. On the Python side the proof runs the other way: the
recipe registers nothing, and its session_cap_reached assertion can only fire if turn 1’s spend
survived into turn 2.
Two Microsoft integrations, and the wrong one raises nothing
If you have read about Cendor’s FoundryAdapter, do not reach for it here. It belongs to a separate
Microsoft Foundry integration in which Cendor is the endpoint and owns the Activity request/reply
shape. A custom engine agent already owns that — AgentApplication, its own TurnContext/TurnState,
streaming, auth — so the envelope is three lines in your handler instead.
The reason this is worth a section rather than a footnote: picking the wrong one produces no signal.
Measured — on_activity() called from inside an M365 handler returns a valid-looking reply Activity,
complete with a channelData.cendor envelope, and raises nothing. You end up with two Activity layers
and the envelope on the wrong object. Pick by who owns the HTTP surface: Cendor (Foundry), or your
process (M365 Agents SDK).
While you are in the neighbourhood, one more shape that reads like a bug in your code and is not:
TurnState paths are scoped by the state class name. state.get_value("conversation.spent_usd")
raises ValueError: Scope 'conversation' not found. It is "ConversationState.spent_usd".
Honest limits
- ⚠️ The recipe’s
/api/messagesruns in anonymous mode, which is the supported local posture — the Playground relies on it — and an open relay in production: anyone who can reach the port can drive your agent and spend your tokens. A deployed agent configures a real service connection against your Entra app registration so the JWT middleware actually validates the channel’s token. tokenguardgoverns the model meter, which is this agent’s whole AI bill — and nothing else. Azure Bot Service messages, Copilot Credits and your hosting bill are three other meters, none of them a token meter. A self-hosted-RAG custom engine agent never triggers Copilot Credits at all.- A break stops spend at the chunk boundary; the channel keeps whatever it was already sent. Queued chunks cannot be unsent, so never claim the visible text is cut at the exact budget token.
- A streamed break needs a streaming channel to be visible, and the ports disagree about which
those are. Python’s
StreamingResponsetreats onlymsteams,webchat/directlineanddeliveryMode='stream'as streaming —emulatoris not one — while the JS port groupsemulatorwith webchat. Validate a streamed break withagentsplayground -c msteamson Python. channelData.cendoris for the channel or your back end; whether a client surfaces it is client-specific. The Playground’s chat pane does not render it — but, as above, its Log Panel does. Assert it in a test rather than depending on any particular client to show it.MemoryStorageloses the session cap on restart. PointAgentApplication(storage=…)at Blob or Cosmos storage and the cumulative cap survives a redeploy, because it lives in the host’s own state.- An Azure deployment name is unpriced, so a USD budget silently cannot bind to it. Name the model
it serves —
prices.register_deployment(MODEL, like="gpt-4o")— or captokens=instead, which needs no rate at all. Token counts and the audit chain are exact either way; only the money is unknown. - .NET / C# is an explicit non-goal. There is no Cendor .NET port. Never assume coverage.
acttraceproduces evidence to support a compliance case — not a guarantee, and not legal advice.
Expect two OpenTelemetry span families from a governed agent — the hosting SDK’s own
microsoft_agents spans alongside cendor.core / cendor.acttrace. That is additive, not a conflict.
Run it yourself
# Python — six governed turns, then a keyless replay of the whole agent
uv run --group agents-m365 python recipes/agents/m365-custom-engine-py/main.py
uv run --group agents-m365 pytest recipes/agents/m365-custom-engine-py
# TypeScript — the twin, including the afterTurn negative control
cd recipes/agents/m365-custom-engine-js && npm install && node index.mjs
The recipe:
agents/m365-custom-engine-py (opens in a new tab) (Python) ·
agents/m365-custom-engine-js (opens in a new tab) (TypeScript)
The provider underneath it. The agent’s default is plain OpenAI — MODEL is gpt-4o-mini, and
the live swap is instrument(AsyncOpenAI()). Nothing in this post is Azure-specific:
- Staying on OpenAI:
providers/openai-chat(opens in a new tab) (TypeScript (opens in a new tab)) - Pointing it at a Microsoft Foundry deployment instead — the v1 GA endpoint, and the
register_deploymentline that makes a USD cap bind on a deployment name:providers/azure-foundry(opens in a new tab) (TypeScript (opens in a new tab))
Docs: cendor.ai/docs/providers — this is a libraries
integration, with no cendor-sdk involved ·
cendor.ai/docs/observability ·
cendor.ai/cookbook