# Runbook — the agent keeps messaging someone who already converted

> **Symptom.** *"el agente le sigue escribiendo a alguien que ya compró"* · *"ya agendó y le sigue
> mandando mensajes"* · *"the agent keeps messaging leads who already bought"* · *"le sigue
> insistiendo a alguien que pidió que paremos"*.
>
> Stopping is a **platform** capability, not a prompt behavior. The agent cannot see a payment, a
> booking, or a refund request — it only sees the conversation. Every hypothesis below is about the
> chain that carries "this person converted" from the outside world to a contact limit. A prompt
> rule saying "don't message people who bought" is inert, and writing one is the most common wrong
> turn on this symptom.

## The chain, in order

Four links, and the symptom looks identical whichever one is broken:

```text
real-world event  →  custom property flips  →  contact limit consumes it  →  agent muted
```

Walk it from the end, because the last link is the cheapest to check.

---

### H1 — Nothing consumes the property

**The most common cause.** The property exists, the evaluator fills it correctly, and nothing is
gated on it. A custom property is inert on its own: defining it is half the job, wiring automation
off it is the other half.

- **Test:** `list_contact_limits` for the agent and check whether any limit has a condition on the
  property.
- **Stop condition:** no limit references it → **you're done**.
- **Fix:** `upsert_contact_limit` gated on that property, plus `upsert_custom_notification` so the
  operator is told. Both, not one — without the notification they go back to watching DMs by hand.

*Evidence: the single most common gap when auditing agents built through the MCP by partner
agencies — good prompts, incomplete systems. One agent had detection (properties) and stopping
(limits) built but the alerting layer never wired, because the Slack webhook needed an
authorization only the account owner could give.*

### H2 — The property never flips

If the value never becomes true, the limit is correct and simply never fires. Four description
failures we have hit, all fixable in the description itself:

- **Biased to false.** A description containing "if unsure, leave it false" makes the evaluator
  default to false forever.
- **Compound.** Role *and* intent *and* an exclusion in one sentence — the model satisfies part of
  it and fails the whole.
- **Missing the anchor phrases.** The description does not contain the wording leads actually use,
  so nothing matches.
- **Not latching.** With check-until-match off, the property is re-evaluated on every inbound and
  gets overwritten back to false as soon as a later message stops mentioning the trigger. The lead
  converted, the flag flipped, and then it silently un-flipped.

- **Test:** `list_custom_properties` and read the description as if you were the evaluator. Then
  check the property's value on a contact you know converted.
- **Stop condition:** the value is false on a converted contact → **you're done**.
- **Fix:** `upsert_custom_property`. Any property gating a shutdown must be check-until-match, and
  must be boolean or number — string "true"/"false" compares unreliably in conditions.

*Evidence: one agent's booking property described only as "whether a booking or calendar link was
sent" — the evaluator counted any URL as qualifying. A separate case combined role, intent and an
injury exclusion into one composite question and the model failed the composite whenever it
detected the role.*

### H3 — The property is fed by something that is broken upstream

A terminal conversion flag has to come from an integration, never from an evaluator reading the
transcript. If the integration is dead, the property is permanently false and every automation
gated on it is dead with it — silently, because nothing errors.

- **Test:** confirm the integration behind the property is live and actually receiving events.
  For bookings, `list_booking_calendars`: an authorized account with **no calendars selected**
  tracks not one single booking. OAuth alone leaves the integration connected and dead.
- **Stop condition:** the integration is not delivering → **you're done**.
- **Fix:** reconnect and select the calendars. Do not paper over it by having an evaluator infer
  conversion from the transcript — a lead saying "ya agendé" is not a booking.

*Evidence: one agent's `call_booked` property was broken for months because the Calendly webhook
was inactive; every follow-up gated on it was ungated the whole time.*

### H4 — A partial write wiped the conditions

Configuration writes in this stack are frequently **replace**, not merge. A partial update — from
the UI toggle, from a workflow upsert without the full field set, from a rename — writes the fields
it does not carry as null. The limit or workflow survives with its conditions silently emptied, so
it now fires on everyone or on no one.

- **Test:** read the limit and its conditions directly after any recent edit. Do not trust the tool
  response; read the stored row back.
- **Stop condition:** conditions are empty on something you know was configured → **you're done**.
- **Fix:** re-apply the full condition set, then re-read to confirm.

*Evidence: a UI toggle sending a partial upsert that nulled the fields absent from the form. Same
family as a workflow upsert dropping `workflow_property_conditions` when called without the complete
set, and a rename that regenerated a workflow with no conditions at all.*

### H5 — The limit fires, but the first turn escapes

The evaluator flips the property **after** the agent has already answered the inbound that revealed
the conversion. The limit then correctly mutes everything afterwards, but that reply is already out
— and on a refund or complaint it is exactly the one that must not exist. Note that "one turn" is
not "one message": an agent that splits its replies can send a burst before the gate engages.

- **Test:** read the conversation. If the agent's answer to the lead's signal went out — a single
  message or a burst of them — and everything after it is silent, this is it.
- **Stop condition:** the first turn escaped, then silence → **you're done**, the limit is working.
- **Fix:** a contact limit cannot prevent that first turn; it is a post-hoc gate. If nothing may
  go out, the block has to happen before generation — a trigger-level guard or an evaluator on the
  trigger, not a limit and not a prompt rule.

*Evidence: an agent handling refund complaints had the property and the silent contact limit built
correctly, and still fired roughly sixteen messages within a minute on the first complaint, because
the gate only engages after the first turn.*

### H6 — It stopped for this contact, and the operator saw a different one

Contact limits are per contact. "It's still messaging people who bought" sometimes means one
specific person for whom the property never flipped, while the mechanism works for everyone else.

- **Test:** check the property on the specific contact the operator is looking at, not on the agent
  in general.
- **Fix:** if only this contact is wrong, go back to H2 — the description does not cover how *this*
  lead phrased it.

---

## Before you close it

A limit that fires is **permanent** for that contact: it sets the agent inactive and nothing brings
it back on its own. That is correct for a purchase and wrong for a "not right now". Check that the
property you are gating on means what its name suggests — a soft refusal merged into a hard
disqualification turns a polite "mejor no" into a permanent mute, and the lead never returns.

Never promise reversibility in a property description. Under check-until-match the value does not
come back.

## When to stop

If the property flips, a limit consumes it, the conditions are intact, and the agent still messages
a converted lead, capture it: the contact, the property and its value with a timestamp, the limit
id, and the message that should not have been sent. Send that with `report_feedback`.
