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

How it works

MONITORING PROCESSMr Le Chonk€9,800 transferto a new accounton_alert("4711")4711 again: same sessionCASE MANAGEMENT APPon_analyst_button( session_id, turn_id, callback_id, approve=True)ApproveDenyids storedSESSIONkey alert:4711TURNRunning01messagereview alert 471102tool callget_alert03tool callget_account_history04tool callget_kyc_profile05tool callscreen_sanctions06tool callsearch_adverse_media07approvalopen_case waits08approvalallowed by the analyst09tool resultCASE-471110agent messagereview done
Step 1 of 5. The monitoring process calls on_alert("4711"). It starts a session with the key alert:4711, and a review turn with the key alert:4711:review.

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.

FileWhat it holds
payment_review.pyThe fake systems, the policy, the agent and the two entry points
run_review.pyA run of alert 4711: the review, then the analyst's decision
ask_about.pyAn analyst's conversation about the alert, in several turns (section 6)
test_payment_review.pyThe tests, with a scripted model
1. Fake the bank's systems

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

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

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

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 reads

turn.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

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 over

agents.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):

Output
Waiting for an analyst: Allow tools.client.open_case?
summary: EUR 9,800.00 transfer (2026-10-29) from account FR76 3000 4000 0312 3456 7890 143 (Mr Le Chonk, Lyon) to new beneficiary Northwind Trading FZE (AE07 0331 2345 6789 0123 456, UAE), reference 'INV 2210 consulting'. [...]
Review: Completed: [...]
✅ Case file opened: `CASE-4711` with the full evidence package (alert details, account history anomaly, KYC inconsistency, sanctions match, adverse media). [...]
Case files: ['CASE-4711']

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:

Output
Waiting for an analyst: Allow tools.client.open_case?
summary: Alert 4711: 9,800.00 EUR transfer on 2026-10-29 from Mr Le Chonk (FR76 3000 4000 0312 3456 7890 143) to a NEW beneficiary, Northwind Trading FZE (AE07 0331 2345 6789 0123 456, UAE - high-risk country), reference "INV 2210 consulting". [...]
Review: Completed: The case-file submission was denied at analyst approval, so per procedure I will not re-open it. Here is my final review. [...]
Case files: []
6. Ask follow-ups about the alert

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:

Output
Here's the summary of alert 4711 (no case opened, per your instruction):
## Who paid whom
• Payer: Mr Le Chonk, Lyon — French account FR76 3000 4000 0312 3456 7890 143 (opened 2014, KYC: individual, salaried, low risk rating, last reviewed 2025-11-14)
• Beneficiary: Northwind Trading FZE — UAE account AE07 0331 2345 6789 0123 456
• Payment: 9,800.00 EUR on 2026-10-29, reference "INV 2210 consulting"
[...]
[approval: Allow tools.client.get_account_history?]
[turn ended: Completed]
The account history only returns aggregate stats for the last 12 months (no per-transaction listing is available), so here's the comparison of the flagged payment against that baseline:
[...]
Still holding off on opening a case, as requested.

The 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

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 out

use_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.py
Common issues

Common issues

The review never waits for the analyst

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

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

Next steps

  • Approvals: handlers, KeepCallbackOpen(), and answering from another client.