> ## Documentation Index
> Fetch the complete documentation index at: https://ctrlrun.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# A customer-notification agent

> Notify each customer once about an incident: one effect per customer per incident, so a retry after a lost reply cannot send a second message.

An incident agent notifies affected customers. Each customer should hear once, however many
times the batch is retried or however many workers pick it up. A batch beyond a certain size is
a person's decision, because a wrong message to ten thousand customers is not cheap to undo.

## The policy

```yaml runnable theme={null}
schema: ctrlrun.policy/v2

actions:
  notify.customer:
    effect: "notify:{incident_id}:{customer_id}"
    decision: allow
  notify.batch:
    effect: "batch:{incident_id}"
    rules:
      - when: { size_lte: 500 }
        decision: allow
      - decision: approve
```

## The code

```python runnable file=main.py theme={null}
from pathlib import Path

import ctrlrun
from ctrlrun import Control, Policy, SQLiteStateStore

HERE = Path(__file__).resolve().parent
STATE = HERE / ".ctrlrun"
STATE.mkdir(exist_ok=True)
for name in ("state.db", "state.db-wal", "state.db-shm"):
    (STATE / name).unlink(missing_ok=True)

delivered: list[str] = []
store = SQLiteStateStore(STATE / "state.db")
control = Control(Policy.from_file(HERE / "ctrlrun.yaml"), store)


@ctrlrun.protect("notify.batch", effect="batch:{incident_id}", control=control)
def start_batch(incident_id: str, size: int) -> str:
    return "started"


@ctrlrun.protect("notify.customer", effect="notify:{incident_id}:{customer_id}", control=control)
def notify(incident_id: str, customer_id: str) -> str:
    delivered.append(customer_id)
    return "delivered"


customers = ["c_1", "c_2", "c_3"]

with ctrlrun.context(agent="incident-agent"):
    print("batch of 3:", start_batch(incident_id="inc_7", size=3))
    for customer in customers:
        notify(incident_id="inc_7", customer_id=customer)
    print("first pass delivered:", len(delivered))

    # The batch is retried after a crash, and a second worker runs it at the same time.
    skipped = 0
    for customer in customers + customers:
        try:
            notify(incident_id="inc_7", customer_id=customer)
        except ctrlrun.DuplicateEffect:
            skipped += 1
    print("retry and second worker: skipped", skipped, "already-notified customers")

    try:
        start_batch(incident_id="inc_8", size=12000)
    except ctrlrun.ApprovalRequired as pending:
        print("batch of 12,000: a human decides:", pending.request_id)
    else:
        raise SystemExit("a large batch started without a human")

print("messages delivered:", len(delivered))
```

## What the agent sees

```text theme={null}
batch of 3: started
first pass delivered: 3
retry and second worker: skipped 6 already-notified customers
batch of 12,000: a human decides: apr_…
messages delivered: 3
```

Three customers, three messages, after a retry and a racing worker. The effect key names the
customer and the incident, so the same customer in a different incident is a new effect.

## The receipt

```bash runnable theme={null}
ctrlrun receipts --last 3
ctrlrun effects
```

Ten receipts for the notifications: three committed, six blocked as duplicates, and the
batch. `ctrlrun effects` lists one effect per customer.

## When an AMBIGUOUS appears

A notification whose reply was lost may have been delivered. Check the provider's delivery log
for the customer, then `ctrlrun resolve notify:inc_7:c_N --committed` or `--failed`. If the
provider accepts an idempotency key, pass the effect key as it, and the resolution is nearly
always `--committed`.

## Next

* [An outbound-email agent](/cookbook/outbound-email-agent).
* [Effect keys](/concepts/effect-keys) · [Get started](/get-started/quickstart) · [Why](/why).


## Related topics

- [An outbound-email agent with external-recipient approval](/cookbook/outbound-email-agent.md)
- [Cookbook](/cookbook/index.md)
- [A refund agent with amount tiers](/cookbook/refund-agent.md)
- [A CRM-update agent](/cookbook/crm-update-agent.md)
- [A data-deletion agent under a retention rule](/cookbook/data-deletion-agent.md)
