Review a suspicious payment with an agent and an analyst
A bank's transaction monitoring raises an alert on a payment. An agent reviews it: it reads the alert, the account history, the customer's KYC profile, sanctions lists and adverse media, then decides whether the payment is suspicious. If it is, it opens a case file, and an analyst approves that case file first.
The program has the tools, one policy that answers the approvals code can decide, and two entry points: the monitoring process that starts a review, and the analyst's button that answers the rest.
Demonstrates: tools with annotations, approvals answered by a policy and by a person, idempotency keys, answering a turn from another client, the local runtime and testing with a scripted model.
Run: from the example's folder, run python run_review.py, or python run_review.py --deny for
an analyst who denies the case file (setup).
How it works
Your code answers what it can decide. The reads are safe, so the policy allows them. Messaging the
customer would tip them off, so the policy denies it. Opening a case file needs a person: the policy
returns agents.KeepCallbackOpen(), the approval stays open, and the turn comes back Blocked with the open approval.
The analyst answers it later, with only the ids.
The bank's systems are faked by tools that return fixed data, so the example runs with only a Mistral API key. In production, each tool calls the bank's real system.
| File | What it holds |
|---|---|
payment_review.py | The fake systems, the policy, the agent and the two entry points |
run_review.py | A run of alert 4711: the review, then the analyst's decision |
ask_about.py | An analyst's conversation about the alert, in several turns (section 6) |
test_payment_review.py | The tests, with a scripted model |
1. Fake the bank's systems
Each system is a tool. A read is marked read-only, so the policy can allow it without a list of names:
READ_ONLY = agents.ToolAnnotations(read_only_hint=True, destructive_hint=False)@agents.tool(annotations=READ_ONLY)
def get_alert(alert_id: str) -> dict[str, object]:
"""Transaction monitoring: the alert and the payment that raised it."""
if alert_id not in _ALERTS:
raise agents.ToolError(f"No alert {alert_id}.")
return _ALERTS[alert_id]get_account_history, get_kyc_profile, screen_sanctions and search_adverse_media are
read-only too, with the same annotation. The customer messaging tool writes, so it has no annotation:
@agents.tool
def message_customer(account: str, text: str) -> str:
"""Customer messaging: send a message to the account holder."""
MESSAGES.append(text)
return "sent"2. Answer the approvals your code can decide
open_case stores a case file. review_policy answers each approval the agent asks for:
@agents.tool
async def open_case(alert_id: str, summary: str, evidence: list[str]) -> str:
"""Open a case file on a suspicious payment, with the evidence. Returns the case id."""
case_id = f"CASE-{alert_id}"
CASES[case_id] = {"alert_id": alert_id, "summary": summary, "evidence": evidence}
return case_id
def review_policy(
ctx: agents.ApprovalCallbackContext, request: agents.ApprovalRequest
) -> agents.AllowApproval | agents.DenyApproval | agents.KeepCallbackOpen:
if request.tool.annotations.read_only_hint: # request.tool: every approval has one
return agents.AllowApproval()
if request.tool.id == "client.open_case":
return agents.KeepCallbackOpen() # not mine: the approval stays open for an analyst
return agents.DenyApproval()The policy keys on the tool, never on the text of its arguments. request.tool.annotations and
request.tool.id say which tool asks; your own tools have ids of the form client.<name>.
3. Define the agent
payment_reviewer = agents.Agent(
model="mistral-large-4",
name="payment-reviewer",
instructions=(
"Review the alert. Collect the context: the alert, the account history, the KYC profile, "
"sanctions screening and adverse media for the beneficiary. Do not contact the customer. "
"If the payment is suspicious, open a case file with the evidence. An analyst approves each "
"case file: if one is denied, do not open it again; end with your review."
),
tools=[
get_alert,
get_account_history,
get_kyc_profile,
screen_sanctions,
search_adverse_media,
message_customer,
open_case,
],
permission_mode="ask", # the default "auto" asks nothing, so no handler runs
)permission_mode="ask" makes every tool call ask for an approval, so review_policy sees each one.
4. Write the two entry points
The monitoring process calls on_alert once per alert. Both idempotency keys come from the alert
id, so an alert delivered twice opens one session and starts one review. The case management app calls
on_analyst_button when the analyst clicks, with the ids it stored:
async def on_alert(alert_id: str) -> agents.TurnState:
"""The transaction monitoring process: one review per alert."""
session = await payment_reviewer.start_session(on_approval=review_policy, idempotency_key=f"alert:{alert_id}")
return await session.run(
f"Review transaction monitoring alert {alert_id}.", idempotency_key=f"alert:{alert_id}:review"
)
# Blocked when open_case waits: the case management app lists state.callbacks
async def on_analyst_button(session_id: str, turn_id: str, callback_id: str, approve: bool) -> agents.TurnState:
"""The case management app, another service: an analyst allows or denies the case file."""
turn = payment_reviewer.session(session_id, on_approval=review_policy).turn(turn_id)
answer = agents.AllowApproval() if approve else agents.DenyApproval()
await turn.answer(callback_id, answer) # Answered | AlreadySettled | NotFound
return await turn.result() # drives the rest; review_policy answers the readsturn.answer applies the analyst's decision; it returns Answered, AlreadySettled when someone
answered first, or NotFound. turn.result() waits until the turn ends or is blocked again, and the session handle's
on_approval=review_policy answers the reads the agent makes after the case file.
5. Run a review
run_review.py plays both sides in one program. The analyst's decision comes from the command line:
async def main(alert_id: str, approve: bool) -> agents.TurnState:
async with agents.local_runtime(): # at exit: every local session of the event loop stops
state = await on_alert(alert_id)
try:
while isinstance(state, agents.Blocked):
# The case management app shows the open approval to an analyst.
callback = state.callbacks[0]
print(f"Waiting for an analyst: {callback.title}")
if isinstance(callback, agents.OpenApproval) and isinstance(callback.request.input, dict):
print(f" summary: {callback.request.input['summary']}")
# The analyst clicks a button; the app passes on the ids it stored.
state = await on_analyst_button(state.session_id, state.turn_id, callback.id, approve)
print(f"Review: {type(state).__name__}: {state.text}")
print(f"Case files: {list(CASES)}")
return state
finally:
await payment_reviewer.session(state.session_id).cancel() # the review is overagents.local_runtime() runs the agent in this process; at exit, every local session of the event
loop stops. Here both sides run in one process. To answer from another service, deploy the agent with
Studio, self-hosted or
Mistral-managed; the button code does not change.
The program prints (the model's text is shortened):
The agent read all five systems without asking anyone, and waited once: for the case file. With
--deny, the analyst denies it, and the agent ends with its review and no case file:
6. Ask follow-ups about the alert
An analyst asks the reviewer about an alert, then asks a follow-up. The second question builds on
the first answer, so both turns run in one session, which keeps the conversation. The program ends
the session when it is done: returning from a function does not end it. A session that Mistral
holds, for an agent started with agents serve or agents deploy, also ends on its own at its
idle limit.
Run python ask_about.py from the example's folder:
import asyncio
from payment_review import payment_reviewer, review_policy
from mistralai import agents
def show(event: agents.Event) -> None:
match event:
case agents.CallbackOpened(callback=callback):
print(f" [approval: {callback.title}]")
case agents.TurnEnded(state=state):
print(f" [turn ended: {type(state).__name__}]")
async def ask_about(alert_id: str) -> None:
session = await payment_reviewer.start_session(on_approval=review_policy)
try:
state = await session.run(
f"Summarise alert {alert_id}: who paid whom, and why it was flagged. Don't open a case yet."
)
print(state.text)
state = await session.run(
"Compare it with the customer's payments of the last 12 months.",
on_event=show, # each event as it comes
)
print(state.text)
finally:
await session.cancel() # returning does not end the session
if __name__ == "__main__":
asyncio.run(ask_about("4711"))The first question tells the agent not to open a case yet: this conversation only asks questions.
review_policy answers each read the agent makes, and show prints the events of the second turn:
the approval for the read it needed, and the end of the turn. The model's answers are shortened:
FR76 3000 4000 0312 3456 7890 143 (opened 2014, KYC: individual, salaried, low risk rating, last reviewed 2025-11-14)AE07 0331 2345 6789 0123 456The second answer compares the payment with the 12-month history from the same session. The test file below checks this conversation with a scripted model.
7. Test it without a model
A scripted model replaces the real one, so the tests need no API key and give the same steps each
time. Each scripted tool call is a line of code in run_typescript, as a real model writes it
(testing.run_typescript writes that line). The first test runs the same main and checks
that the review waited for the analyst, and that a denied case file was never written:
"""The tests of the suspicious payment review: a scripted model, no API key.
Run them beside payment_review.py: pytest -o asyncio_mode=auto test_payment_review.py
"""
import ask_about
import payment_review
import pytest
import run_review
from mistralai import agents
def use_model(monkeypatch: pytest.MonkeyPatch, model: agents.testing.ScriptedModel) -> None:
"""Give the agent the scripted model, in each module that imported it."""
agent = payment_review.payment_reviewer.override(model=model)
for module in (payment_review, run_review, ask_about):
monkeypatch.setattr(module, "payment_reviewer", agent)
@pytest.mark.parametrize("approve", [True, False])
async def test_the_review_waits_for_the_analyst(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], approve: bool
) -> None:
payment_review.CASES.clear()
model = agents.testing.ScriptedModel(
[
# The model calls each tool from code, as a real model does.
agents.testing.run_typescript('return await tools.client.get_alert({"alert_id": "4711"});'),
agents.testing.run_typescript(
'return await tools.client.screen_sanctions({"name": "Northwind Trading FZE"});'
),
agents.testing.run_typescript(
'return await tools.client.open_case({"alert_id": "4711", '
'"summary": "9,800 EUR to a beneficiary with a 0.82 sanctions match.", '
'"evidence": ["sanctions match 0.82"]});'
),
agents.testing.reply("Case CASE-4711 opened." if approve else "The analyst denied the case file."),
]
)
use_model(monkeypatch, model)
state = await run_review.main("4711", approve=approve)
model.assert_consumed()
assert isinstance(state, agents.Completed)
assert list(payment_review.CASES) == (["CASE-4711"] if approve else []) # a denied case is never written
out = capsys.readouterr().out
assert "Waiting for an analyst: " in out
assert "summary: 9,800 EUR to a beneficiary" in out
async def test_the_policy_allows_reads_and_denies_the_customer_message(monkeypatch: pytest.MonkeyPatch) -> None:
payment_review.MESSAGES.clear()
model = agents.testing.ScriptedModel(
[
agents.testing.run_typescript('return await tools.client.get_kyc_profile({"account": "FR76"});'),
agents.testing.run_typescript(
'return await tools.client.message_customer({"account": "FR76", "text": "Please explain."});'
),
agents.testing.reply("Reviewed."),
]
)
use_model(monkeypatch, model)
state = await payment_review.on_alert("4711")
assert isinstance(state, agents.Completed)
assert [c.name for c in state.tool_calls] == ["client.get_kyc_profile", "client.message_customer"]
assert payment_review.MESSAGES == [] # the policy denied it, so the tool never ran
async def test_the_analysts_conversation_takes_two_turns(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
model = agents.testing.ScriptedModel(
[
agents.testing.reply("Mr Le Chonk paid 9,800 EUR to Northwind Trading FZE."),
agents.testing.run_typescript('return await tools.client.get_account_history({"account": "FR76"});'),
agents.testing.reply("Its largest payment in 12 months was 1,150 EUR."),
]
)
use_model(monkeypatch, model)
await ask_about.ask_about("4711")
model.assert_consumed()
out = capsys.readouterr().out
assert "Mr Le Chonk paid 9,800 EUR" in out
assert "[approval: " in out # the read asked, and the policy answered
assert "[turn ended: Completed]" in outuse_model replaces the agent in each module that imported it. With pytest and pytest-asyncio
installed, run the tests from the example's folder:
pytest -o asyncio_mode=auto test_payment_review.pyCommon issues
The review never waits for the analyst
Check permission_mode="ask" on the agent. With the default, "auto", the agent asks for nothing,
so open_case runs at once and no handler is called.
A read is denied
review_policy denies every tool without read_only_hint=True, except open_case. Mark each
read-only tool with READ_ONLY, or allow it by its id.
Next steps
- Approvals: handlers,
KeepCallbackOpen(), and answering from another client.