Agent Events & Notifications¶
Emit structured notifications when significant things happen during agent execution. Route events to webhooks, callbacks, logs, or the runtime's event bus.
from promptise import build_agent, EventNotifier, WebhookSink, CallbackSink
notifier = EventNotifier(sinks=[
WebhookSink(
url="https://hooks.slack.com/services/...",
events=["invocation.error", "budget.exceeded", "guardrail.blocked"],
),
CallbackSink(lambda event: print(f"[{event.severity}] {event.event_type}")),
])
agent = await build_agent(
servers=servers, model="openai:gpt-5-mini", events=notifier,
)
Event Taxonomy¶
25 event types across 9 categories. Events marked runtime come from an AgentProcess; the rest come from any agent built with events=.
| Category | Event Type | Severity | When it fires |
|---|---|---|---|
| Invocation | invocation.start |
info | Agent begins processing |
invocation.complete |
info | Agent finishes successfully | |
invocation.error |
error | Unhandled exception during invocation | |
invocation.timeout |
error | Invocation exceeded max_invocation_time |
|
| Tools | tool.error |
error | A tool call raised, or an MCP tool returned an error result |
tool.slow |
warning | A tool call took longer than slow_tool_threshold (default 5s) |
|
tool.progress |
info | An MCP tool reports progress (data: tool_name, progress, total, message) |
|
| Guardrails | guardrail.blocked |
warning | Input (or streamed output) blocked by guardrails |
guardrail.redacted |
info | Output had PII/credentials redacted | |
| Approval | approval.requested |
info | Human approval requested |
approval.granted |
info | Approval granted | |
approval.denied |
warning | Approval denied or timed out | |
| Budget (runtime) | budget.warning |
warning | A budget limit is close to being reached |
budget.exceeded |
critical | Budget limit reached | |
budget.daily_reset |
info | Daily budget counters were reset | |
| Mission (runtime) | mission.progress |
info | Mission evaluation completed |
mission.complete |
info | Mission objective achieved | |
mission.failed |
critical | Mission timed out or exceeded limits | |
| Health (runtime) | health.anomaly |
warning | Behavioral anomaly detected |
health.recovered |
info | The process is healthy again after an anomaly | |
| Process (runtime) | process.started |
info | Agent process started |
process.stopped |
info | Agent process stopped | |
process.failed |
critical | Agent process entered FAILED state (failed start, or max_consecutive_failures reached) |
|
process.restarted |
warning | Restart policy is restarting the process (attempt, max_restarts, reason) |
|
| Cache | cache.purged |
info | A user's cached entries were deleted with purge_user() |
tool.error and tool.slow are emitted whenever events= is set — they don't need observe=True.
What counts as a tool error¶
tool.error fires when a tool call:
- raises — any tool: MCP tools,
extra_tools, sandbox and cross-agent tools; - returns an MCP error result — a result with
isError: true, or the structured error a Promptise MCP server sends when a tool raisesToolError({"error": {"code": ..., "message": ..., "retryable": ...}}). The model still sees the error text and can recover; the event tells you it happened.
A tool that returns its own domain answer such as {"error": "No invoice INV-404"} (no code) is not a tool error.
Payloads¶
The data of each event:
| Event Type | data fields |
|---|---|
invocation.start |
model; streaming when streamed |
invocation.complete |
duration_ms; streaming |
invocation.error |
error, error_type; streaming |
invocation.timeout |
timeout_seconds |
tool.error |
tool_name, error (message), error_type, duration_ms; code and retryable when the tool reported them |
tool.slow |
tool_name, latency_ms, threshold_ms |
tool.progress |
tool_name, progress, total, message |
guardrail.blocked |
direction (input/output), error (exception type), reason, findings (detector, category, severity, description per finding — never the matched text); streaming |
guardrail.redacted |
direction; streaming |
approval.requested |
tool_name, request_id, timeout, arguments (as the policy redacts them; {} with include_arguments=False) |
approval.granted |
tool_name, request_id, reviewer, modified_arguments (names of the arguments the reviewer changed), decided_by; classifier_layer when a classifier decided |
approval.denied |
tool_name, request_id, reason, decided_by; classifier_layer when a classifier decided |
budget.warning |
process_name, limit_type, current, limit, percentage |
budget.exceeded |
process_name, limit_type, current, limit |
budget.daily_reset |
process_name |
mission.progress |
process_name, confidence, achieved, invocations |
mission.complete |
process_name, confidence, invocations |
mission.failed |
process_name, reason |
health.anomaly |
process_name, anomaly_type, details |
health.recovered |
process_name |
process.started / process.stopped |
process_name, process_id |
process.failed |
process_name, process_id, reason, error |
process.restarted |
process_name, process_id, reason, attempt, max_restarts |
cache.purged |
user_id, entries_removed |
Approvals that an MCP server requests through elicitation (server-side gates) emit the same approval.* events with source: "mcp_elicitation" and server.
Sinks¶
WebhookSink¶
HTTP POST to any URL. Signed with HMAC-SHA256 and a timestamp. Retry with exponential backoff. SSRF protection.
WebhookSink(
url="https://hooks.slack.com/services/...",
events=["invocation.error", "budget.exceeded"], # Only these events
secret="my-hmac-secret", # HMAC signing
headers={"Authorization": "Bearer tok-123"}, # Custom headers
max_retries=3, # Retry on failure
min_severity="warning", # Skip info events
redact_sensitive=True, # Scan payloads for PII
transform=lambda p: {"text": f"[{p['severity']}] {p['event_type']}"}, # Custom format
)
Each request includes:
X-Promptise-Signature:t=<unix seconds>,v1=<hex>— HMAC-SHA256 over<t>.followed by the exact body bytesX-Promptise-Timestamp: the sametX-Promptise-Event: the event type (e.g.invocation.error)X-Promptise-Delivery: an id that stays the same across retries of one event, for de-duplication
Verifying a delivery¶
Verify against the raw request body, before parsing it. verify_event_signature() checks the HMAC in constant time and rejects signatures older than five minutes (replays):
from promptise import verify_event_signature
# In your web framework's handler (Flask shown):
raw = request.get_data() # bytes, exactly as received
if not verify_event_signature(raw, request.headers.get("X-Promptise-Signature"), SECRET):
abort(401)
event = json.loads(raw)
| Parameter | Default | Description |
|---|---|---|
body |
required | Raw body (bytes, or str encoded as UTF-8) |
signature |
required | The X-Promptise-Signature header |
secret |
required | The sink's secret, or a list of secrets while rotating |
tolerance |
300.0 |
Maximum age in seconds; None or 0 turns the replay check off |
Without Promptise on the receiving side, compute hex(HMAC_SHA256(secret, f"{t}." + raw_body)) and compare it to v1 with a constant-time comparison, then check that t is recent. If you don't pass a secret, the sink generates one; read it from sink.secret.
Changed in the next release
Earlier versions sent X-Promptise-Signature as a bare hex HMAC over json.dumps(payload, sort_keys=True), with no timestamp. Receivers built for that format must switch to verify_event_signature() (or the recipe above).
Private networks¶
WebhookSink refuses localhost and every address that is not public unicast by default (SSRF protection): loopback, private ranges, link-local (cloud metadata at 169.254.169.254), shared/carrier-grade NAT space (100.64.0.0/10), 0.0.0.0, multicast and reserved ranges. The check runs when the sink is created and again before every delivery: the host is resolved, every address is checked, and the request goes to the checked address, so a DNS record that later points at an internal address (DNS rebinding) is not followed. Redirects are never followed. For a receiver on your own machine or private network, opt in:
CallbackSink¶
Python callable (sync or async). Full control.
# Simple
CallbackSink(lambda event: print(event.event_type))
# Async with filtering
async def handle_errors(event):
await alert_team(event.data)
CallbackSink(handle_errors, events=["invocation.error", "process.failed"])
LogSink¶
Structured logging via Python's logging module.
Events appear as structured log lines compatible with ELK, Datadog, Splunk.
EventBusSink¶
Bridge to the Agent Runtime's event bus for inter-process notifications.
EventNotifier¶
The central coordinator. Routes events to configured sinks.
notifier = EventNotifier(
sinks=[sink_a, sink_b, sink_c],
max_queue_size=1000, # Per sink: drop events when its queue is full (never block)
shutdown_timeout=10.0, # How long stop() waits for queued events
slow_tool_threshold=5.0, # Seconds before tool.slow fires (None = off)
)
Fire-and-forget: emit() queues the event and returns immediately. The agent never blocks waiting for event delivery.
Sink isolation: every sink has its own queue and delivery task. A webhook that is down and retrying with backoff delays only its own deliveries — other sinks get every event immediately. Within one sink, events arrive in order. Sink failures are logged, never propagated.
Graceful shutdown: agent.shutdown() stops the notifier, which waits up to shutdown_timeout seconds for queued events to be delivered. Whatever is still undelivered then is dropped and logged (sink, count and event types) and counted in notifier.dropped_count. Size the timeout to your webhook retries: with the defaults, a retrying webhook needs about 7 seconds of backoff plus request time.
Filtering¶
Each sink can filter by event type and/or minimum severity:
# Only errors and critical events
WebhookSink(url="...", min_severity="error")
# Only specific event types
CallbackSink(handler, events=["budget.exceeded", "process.failed"])
# Combined: only critical process events
WebhookSink(url="...", events=["process.failed"], min_severity="critical")
AgentEvent Structure¶
Every event is an AgentEvent dataclass:
| Field | Type | Description |
|---|---|---|
event_type |
str |
Dotted event name (e.g. invocation.complete) |
severity |
str |
info, warning, error, or critical |
timestamp |
float |
When the event occurred (time.time()) |
agent_id |
str \| None |
Who emitted it — see below |
user_id |
str \| None |
From CallerContext (multi-user) |
session_id |
str \| None |
The chat() session, else caller.metadata["session_id"] |
data |
dict |
Event-specific payload (Payloads) |
metadata |
dict |
Context: model and invocation_id on agent events; process_name, process_id (and trigger_type) on runtime events |
agent_id is the same on every event an agent emits: observer_agent_id when you pass it to build_agent(), else the identity's agent_id (or IdP subject). Agents run by an AgentProcess use the process name. Without any of these it is the model name. The model is also in metadata["model"].
metadata["invocation_id"] is the same for every event of one ainvoke()/chat()/stream call, so you can group invocation.start, the tool.errors and invocation.complete of one run.
Security¶
- SSRF protection: WebhookSink validates URLs at construction and again, against the address it connects to, before every delivery — rejects private, loopback, link-local (cloud metadata), CGNAT and other non-public addresses unless
allow_private_networks=True - Signing with replay protection: every webhook carries
X-Promptise-Signature(HMAC over timestamp + raw body); verify withverify_event_signature() - Payload redaction: WebhookSink scans the whole payload —
data,user_id,session_id,metadata— for PII/credentials before sending (regex patterns, no ML models). Auser_idthat is an email address arrives as[EMAIL]; passredact_sensitive=Falseif your receiver needs it - Queue bounds:
max_queue_sizeprevents memory exhaustion. When a sink's queue is full, events are dropped for that sink with a warning log - Sink isolation: One failing or slow sink never affects others
SuperAgent YAML¶
events:
shutdown_timeout: 10 # optional
slow_tool_threshold: 5 # optional; null turns tool.slow off
sinks:
- type: webhook
url: https://hooks.slack.com/services/...
events: [invocation.error, budget.exceeded]
min_severity: warning
- type: webhook
url: http://127.0.0.1:8390/promptise
allow_private_networks: true
- type: log
events: [invocation.complete]
EventNotifier Lifecycle¶
The notifier starts itself when the first event is emitted, and must be stopped to deliver what is still queued:
notifier = EventNotifier(sinks=[...])
await notifier.start() # Start the delivery tasks (optional — emitting starts them)
# ... agent runs, events are emitted ...
await notifier.stop() # Deliver queued events (up to shutdown_timeout), then stop
When passed to build_agent(events=notifier), start() is called automatically and agent.shutdown() calls stop().
In the runtime, the process owns the notifier instead: AgentProcess.stop() emits process.stopped and then stops the notifier, so the last event is delivered too. A notifier shared by an AgentRuntime keeps running while single processes stop and is stopped by runtime.stop_all().
| Method / attribute | Description |
|---|---|
await start() |
Start the delivery tasks. Auto-called by build_agent() and AgentProcess.start(). |
await stop(timeout=None) |
Deliver queued events (up to timeout, default shutdown_timeout), log anything dropped, close sinks, stop. |
await flush(timeout=None) |
Wait until everything queued is delivered, without stopping. Returns False on timeout. |
await emit(event) |
Queue an event for delivery (non-blocking). |
emit_sync(event) |
Queue from synchronous code, from any thread. Never raises. |
dropped_count |
Events dropped so far (full queues, shutdown timeout). |
is_running |
Whether the delivery tasks are running. |
Emitting Custom Events¶
Use emit_event() to emit events from your own code:
from promptise.events import emit_event
# Inside an async function where you have access to the notifier:
emit_event(
notifier,
event_type="custom.my_event",
severity="info",
data={"key": "value"},
agent_id="my-agent",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
notifier |
EventNotifier \| None |
required | The notifier (None = no-op) |
event_type |
str |
required | Dotted event name |
severity |
str |
"info" |
info, warning, error, critical |
data |
dict \| None |
None |
Event payload |
agent_id |
str \| None |
from context | Agent identifier |
session_id |
str \| None |
from context | Session ID |
metadata |
dict \| None |
None |
Additional metadata, merged over the context's |
user_id |
str \| None |
from context | The user the event concerns |
emit_event() is null-safe — passing None as the notifier does nothing. Called during an agent invocation (in a tool, for example), it fills user_id from the current CallerContext and agent_id, session_id and metadata from the running invocation.
Sink Parameter Reference¶
WebhookSink¶
| Parameter | Type | Default | Description |
|---|---|---|---|
url |
str |
required | Webhook URL (SSRF-protected) |
events |
list[str] \| None |
None |
Event types to subscribe to (None = all) |
headers |
dict[str, str] |
{} |
Custom HTTP headers |
secret |
str \| None |
auto-generated | HMAC signing secret (readable as sink.secret) |
max_retries |
int |
3 |
Retry attempts on failure |
retry_delay |
float |
1.0 |
Initial retry delay (doubles each retry) |
redact_sensitive |
bool |
True |
Scan the whole payload for PII/credentials |
min_severity |
str \| None |
None |
Minimum severity to emit |
transform |
Callable \| None |
None |
Custom payload transformation (the signature covers the transformed body) |
allow_private_networks |
bool |
False |
Allow localhost and private-network URLs |
CallbackSink¶
| Parameter | Type | Default | Description |
|---|---|---|---|
callback |
Callable |
required | Async or sync callable |
events |
list[str] \| None |
None |
Event filter |
min_severity |
str \| None |
None |
Minimum severity |
LogSink¶
| Parameter | Type | Default | Description |
|---|---|---|---|
events |
list[str] \| None |
None |
Event filter |
logger_name |
str |
"promptise.events" |
Python logger name |
min_severity |
str \| None |
None |
Minimum severity |
EventBusSink¶
| Parameter | Type | Default | Description |
|---|---|---|---|
event_bus |
Any |
required | Object with emit(event_type, data) method |
events |
list[str] \| None |
None |
Event filter |
EventNotifier¶
| Parameter | Type | Default | Description |
|---|---|---|---|
sinks |
list[EventSink] |
required | At least one sink |
max_queue_size |
int |
1000 |
Undelivered events kept per sink |
shutdown_timeout |
float |
10.0 |
Seconds stop() waits for queued events |
slow_tool_threshold |
float \| None |
5.0 |
Seconds before a tool call emits tool.slow; None disables it |
What's Next?¶
- Observability -- detailed execution traces (events are high-level alerts, observability is full traces)
- Approval -- human-in-the-loop approval that emits
approval.*events - Guardrails -- security scanning that emits
guardrail.*events
Learn the concept
- AI Agent Notifications: Webhook and Slack Alerts in Python -- Alerts for failed tools, guardrail hits, budgets and approvals.