papayya.signal()
Tell Papayya that the world judged one of your items — a thumbs-down, a refund, a support ticket, a human editing your agent's output.
import papayya
def on_thumbs_down(order):
papayya.signal(order.id, agent="triage")It is keyed on your id, because that is the only id the handler holds. Papayya resolves it to whichever item answered for that order. See Recovery for what you then do with the flags, and the API for the wire contract.
Signature
papayya.signal(
item_id, # your id for the item, as declared on submit
*,
agent, # the agent that produced it
verdict="bad", # good | bad | corrected
source="thumbs", # thumbs | ticket | edit | refund | rejection | dashboard
reason=None, # free text — what the person knew
external_id=None, # your id for the SIGNAL (a webhook event id)
occurred_at=None, # when the world judged; defaults to now
partition_key=None, # required if you submitted under one
api_key=None, base_url=None,
)The defaults describe a thumbs-down, which is the common case, so the minimal call is one line.
What it will and will not do to your handler
It never blocks. The request is dispatched to a background thread and the call returns immediately. One attempt, a 0.75-second timeout, no retries — a signal is evidence, not a transaction, and a lost one is better than a hung checkout. (For comparison: the same call through Papayya's ordinary API client, which retries because a run submission should, blocks for 1.5 seconds against a dead port and much longer against a host that accepts and never answers.)
A delivery failure is a log line, never an exception. If the control plane is unreachable, your handler still returns.
Two things raise, on your thread, before anything is sent:
- no API key —
PapayyaAPIError. A deployment that signals into the void would otherwise look identical to one that works, forever. - a
verdictorsourceoutside the allowed set —ValueError.
A rejected signal is logged at ERROR. An agent slug that does not exist, for
instance. It is your bug and it will fail the same way every time, but there is
nobody to raise it to from a background thread — so watch the papayya.signal
logger in your app.
It returns a concurrent.futures.Future. Ignore it in production; call
.result() in a test when you need the send to have finished.
Duplicates are yours to decide
Pass external_id — your id for the signal, like a webhook event id — and a
retried webhook stores one row. Without it, two calls store two rows, on
purpose: an item accumulates signals, and two people complaining about one
order is two pieces of evidence, not a duplicate.
Do not put the order id in external_id. A second, genuine complaint about
that order would be taken for the first one arriving twice, and silently
dropped.
Which item it names
The most recent one that had already produced output when the world judged. If your order was re-driven — one of your ids legitimately maps to several of our items over time — the complaint lands on the item that actually answered by then, not on the first attempt.
That is decided every time someone asks, not once when you post. A signal that arrives before we have finished recording the item is stored anyway and starts counting the moment it can.
papayya.signal(
order.id,
agent="triage",
partition_key=order.tenant, # not guessed — see below
verdict="corrected",
source="edit",
reason="agent named the wrong policy",
external_id=event.id,
)If you submitted under a partition_key, pass it here. A signal without one
names only items submitted without one: Papayya will not pick between your
tenants, and the API response tells you when your order id exists under
partitions you did not name.