Gateway Inbound Hooks
Trigger agents from external services via authenticated POST /hooks/{path}
Inbound hooks expose a POST /hooks/<path> endpoint on the gateway — point any external service (GitHub Actions, Linear, Gmail push, Sentry alerts) at it and the gateway runs an agent and delivers the reply to a configured channel.
from praisonaiagents import Agent
from praisonai.gateway import Gateway
agent = Agent(name="triage", instructions="Triage inbound alerts and summarise the action needed.")
gw = Gateway(agents=[agent])
gw.register_hook({"path": "alerts", "agent": "triage", "auth": "my-shared-secret"})
gw.start()The user POSTs JSON to /hooks/<path>; the gateway authenticates the payload, runs the mapped agent, and delivers the reply to the configured channel.
Quick Start
- Define a hook and start the gateway
python from praisonai.gateway import Gateway from praisonaiagents import Agent agent = Agent( name="triage", instructions="Triage the inbound alert and summarise the action needed." ) gw = Gateway(agents=[agent]) gw.register_hook({ "path": "alerts", "agent": "triage", "auth": "my-shared-secret", "deliver_to": "telegram:123456789", "message": "Alert from {{ payload.source }}: {{ payload.title }}", }) gw.start() - Point your external service at the hook URL
bash curl -X POST https://your-gateway/hooks/alerts \ -H "Authorization: Bearer my-shared-secret" \ -H "Content-Type: application/json" \ -d '{"source": "Sentry", "title": "NullPointerException in prod"}' - The agent replies to Telegram automatically
No polling, no webhook handler code — the gateway does it all.
How It Works
Three Ways to Register a Hook
Python
from praisonai.gateway import Gateway
from praisonaiagents import Agent
agent = Agent(
name="email-triager",
instructions="Triage the email and decide on an action."
)
gw = Gateway(agents=[agent])
gw.register_hook({
"path": "gmail",
"agent": "email-triager",
"action": "agent",
"auth": "shared-secret",
"session_key": "hook:gmail:{from}",
"idempotency_key": "{message_id}",
"deliver_to": "telegram:123456789",
"message": "New mail from {{ payload.from }}: {{ payload.subject }}",
})
gw.start()YAML (gateway.yaml)
hooks:
- path: gmail
agent: email-triager
action: agent
auth: shared-secret
session_key: "hook:gmail:{from}"
idempotency_key: "{message_id}"
deliver_to: telegram:123456789
message: "New mail from {{ payload.from }}: {{ payload.subject }}"Start the gateway pointing at the config:
praisonai gateway start --config gateway.yamlCLI
# Path is a positional argument, not --path
praisonai gateway hooks add gmail \
--agent email-triager \
--deliver-to telegram:123456789 \
--message "New mail from {from}: {subject}"
praisonai gateway hooks list
praisonai gateway hooks remove gmail| Flag | Type | Default | Description |
|---|---|---|---|
<path> (positional) | str | required | Hook path — gmail exposes POST /hooks/gmail |
--agent | str | first registered agent | Agent id to run |
--action | str | agent | agent runs a turn; wake nudges a session |
--auth | str | none | Bearer token / shared secret for this hook |
--session-key | str | none | Session key template (see HookConfig Options) |
--idempotency-key | str | none | Idempotency key template |
--deliver-to | str | none | platform:target delivery spec, e.g. telegram:12345 |
--message | str | none | Message template built from the payload |
--config | str | gateway.yaml | Path to the gateway configuration file |
HookConfig Options
| Option | Type | Default | Description | |
|---|---|---|---|---|
path | str | required | URL segment after /hooks/. "gmail" exposes POST /hooks/gmail. Must be non-empty. | |
agent | str | None | Agent name/id to run. Falls back to the gateway’s first registered agent. | |
action | str | "agent" | "agent" runs a new turn; "wake" nudges an existing session without a new message. | |
auth | str | None | Bearer token required on inbound requests. Falls back to the gateway’s global auth_token. | |
session_key | str | None | Template for the session id, e.g. "hook:gmail:{message_id}". Defaults to "hook:{path}". | |
idempotency_key | str | None | Template for the dedup key. When unset, hashes the entire payload. | |
deliver_to | str | None | platform:target delivery spec, e.g. "telegram:123456789". Omit to skip outbound delivery. | |
message | str | None | Template for the message built from the payload and sent to the agent. | |
enabled | bool | True | Whether the hook is active. false returns 404. | |
metadata | dict | {} | Free-form key/value pairs passed to the agent context. | |
secret | str | None | HMAC signing secret. When set, the gateway verifies the provider signature over the raw body before any agent runs and rejects (401) a missing/invalid signature — fail-closed. | |
signature_header | str | None | Header carrying the signature. Auto-defaults to "X-Hub-Signature-256" when secret is set with no explicit header. | |
signature_algo | str | "sha256" | Digest for the HMAC. Any name hashlib.new accepts (e.g. "sha1", "sha512"). | |
signature_prefix | str | None | Optional signature prefix, e.g. "sha256=". | |
signature_scheme | str | "hmac_hex" | How the provider signs — "hmac_hex" (default, hex HMAC of the body), "hmac_base64" (base64 HMAC of the body), "hmac_timestamped" (hex HMAC of "{timestamp}.{body}"), or "structured" (Stripe-style t=…,v1=… composite header; any v1 may match). Invalid values raise ValueError. | |
timestamp_header | str | None | Header carrying the provider timestamp (unix seconds). Only valid with hmac_timestamped or structured; required for hmac_timestamped. For structured, the t= component is read from the signature header when omitted. | |
signature_tolerance_seconds | int | 300 | Replay window for timestamped and structured schemes — a request outside ± this many seconds is rejected before any agent runs. 0 disables the freshness check (not recommended). Negative values raise ValueError. | |
events | list[str] | None | Allow-list of event types. Unmatched deliveries are ack’d (200) with no LLM turn. A string is coerced to a single-element list. | |
event_header | str | None | Header carrying the event type, e.g. "X-GitHub-Event". When omitted the event is read from the payload as a dotted path (defaulting to "event"). | |
coalesce | CoalesceConfig \ | dict | None | Opt-in burst coalescing. Deliveries whose group_key renders to the same value within window_seconds collapse into one agent run carrying the latest payload. None keeps one run per delivery. See Burst Coalescing. |
deliver_only | bool | False | When True the rendered message is the delivered content, routed straight through deliver_to with no LLM turn. |
hooks.idempotency (gateway-level, not per-hook)
| Option | Type | Default | Description |
|---|---|---|---|
store_backend | str | "sqlite" | "sqlite" (default, durable, survives restart) — "memory" (per-process, zero-dep, opt-in for tests / ephemeral runs) — "redis" (cluster-wide exactly-once admission across replicas via RedisIdempotencyStore, Issue #5154; reuses the gateway’s push RedisConfig — or a redis_url override; falls back to durable SQLite and records a durability:idempotency degraded fact if the redis package is missing or Redis is unreachable at build time; a runtime outage fails open (admits) and records the degraded fact, then clears it automatically on recovery — see Cross-Replica Idempotency and Gateway Turn Lock). Unset means "sqlite" (Issue #4339). A genuinely unknown value (e.g. "totally-unknown") falls back to "memory" with a debug log. |
Also accepted as siblings when hooks: is a list:
hooks_idempotency: { store_backend: sqlite }idempotency: { store_backend: sqlite }- Under
gateway:→gateway.hooks_idempotency: { store_backend: sqlite }
Templating
Both {{ payload.x }} (Jinja-style) and {x} (format-string style) are accepted:
message: "New mail from {{ payload.from }}: {{ payload.subject }}"
session_key: "hook:gmail:{message_id}"Rules:
- The leading
payload.is optional —{{ payload.from }}and{from}resolve identically. - Missing keys render as empty strings — the template never raises on partial payloads.
- Single-pass substitution — a payload value containing
{...}is never re-expanded (injection-safe). - Interpolated payload values are automatically fenced as untrusted request data on agent-turn hooks. Operator template text stays outside the fence. See Untrusted Request Fencing.
Actions
"agent" (default) — runs a full agent turn with the rendered message as the user input:
action: agent
message: "Triage: {{ payload.title }}""wake" — nudges an existing session (triggers proactive delivery or a scheduled check-in) without injecting a new user message:
action: wake
session_key: "daily:{{ payload.user_id }}"Idempotency & Retries
The gateway deduplicates concurrent and retried deliveries:
- When
idempotency_keyis set, the key is rendered from the payload and hashed asSHA-256(path + "\x00" + rendered_key). - When unset, the entire payload is JSON-canonicalized and hashed.
- The key is reserved in-flight atomically — concurrent identical POSTs dedup across the await boundary.
- The key is committed only after a successful agent run — transient failures remain retryable.
Dedup Store — Durable (SQLite) by default vs Memory
The dedup store is durable (SQLite) by default — a webhook provider that retries after a gateway restart within its retry window keeps its dedup key, so the retry does not start a duplicate agent run (duplicate reply, duplicate tool action).
Explicitly opt out to the in-memory backend for tests / ephemeral runs:
hooks:
idempotency:
store_backend: memory # explicit opt-out — "sqlite" (default) | "memory"
hooks:
- path: gmail
agent: assistant
# ...When hooks: is a top-level list (the common shape), use the sibling key instead:
hooks_idempotency:
store_backend: memory
hooks:
- path: gmail
agent: assistant
# ...| Backend | Survives restart? | Shared across replicas? | Dependencies | When to use |
|---|---|---|---|---|
sqlite (default) | ✅ | ✅ (when the DB file is shared) | stdlib sqlite3 only | Production, any provider with retries, multi-replica |
memory | ❌ | ❌ | none | Tests / ephemeral runs (explicit opt-out) |
redis | ✅ | ✅ genuine cross-replica dedup via Redis (falls back to durable SQLite when Redis is unavailable) | redis Python package (pip install redis) | Multi-replica gateways needing cluster-wide exactly-once inbound admission; auto-shares the gateway’s push RedisConfig. See Multi-replica gateways. |
Storage details (SQLite):
- DB file:
~/.praisonai/state/hook_idempotency.sqlite(auto-created; same state dir as the ingress journal and outbound queue). - WAL mode with
synchronous=NORMALfor durable but low-latency writes. reserveis a crash-safeUNIQUEinsert — a redelivery and a concurrent duplicate both fail on the primary-key constraint (this is the inbound analogue of the outbound queue’sUNIQUE idempotency_key).- Bounded: max
10,000recorded entries +24hTTL, pruned lazily onreserve. - Crash recovery: a durable
inflightreservation whose run neither recorded nor released it (only cause: a process crash during the hook) is reclaimable after a15 mininflight_lease_secondslease, so the provider’s post-restart retry re-runs instead of being deduplicated for the full TTL.recordedkeys are never touched by the lease. - On any failure to build the SQLite store, the gateway falls back to in-memory so inbound delivery keeps working (a warning is logged). On fallback it also records a durability-degraded fact (
owner_kind="gateway",owner_id="durability:idempotency") — visible inpraisonai gateway statusandhealth()["degraded_owners"]withretry_hint="praisonai gateway doctor --fix". The reason is redacted (the raw store path stays in the log). The fact clears automatically the moment the durable store comes up (fresh start or hot-reload). See Gateway State Durability and Degraded Capabilities. - Selecting
"redis"builds the cluster-wideRedisIdempotencyStorewhen Redis is reachable — genuine cross-replica exactly-once admission. If theredispackage is missing or Redis is unreachable at build time, the wrapper falls back to the durable SQLite store, logs awarning(path-redacted), and recordsdurability:idempotencywith reason"redis idempotency backend not available (running per-replica)". If the SQLite fallback also fails, the more severe"...unavailable (running in-memory)"fact wins — the milder “per-replica” reason never masks the loss of restart durability. See Cross-Replica Idempotency and Multi-replica gateways.
Multi-replica gateways and store_backend: redis
store_backend: redis gives cluster-wide exactly-once admission via RedisIdempotencyStore (Issue #5154), mirroring the RedisTurnLock. A webhook fanned to multiple replicas is admitted on exactly one, keyed on (platform, account, channel_id, message_id), so a duplicate delivery never runs your agent twice.
The store reuses the gateway’s push RedisConfig — no new operator knob — exactly as build_turn_lock does:
reserve(key)→ a singleSET NX PX(atomic claim + owner token + inflight-lease TTL). Rejects both a redelivery and a concurrent duplicate on another replica; a crashed replica’s claim self-heals via TTL expiry.record(key)→ an owner-checked upgrade to a long-livedrecordedvalue (TTLttl_seconds). A laterecordfrom a replica whose lease already expired is a no-op when another replica reclaimed the key.release(key)→ a compare-and-del on the owner token, so it never drops another replica’s live claim.
If the redis package is missing or Redis is unreachable at build time, the wrapper falls back to the durable SQLite store, logs a warning (path-redacted), and records durability:idempotency with reason "redis idempotency backend not available (running per-replica)". A runtime outage (Redis reachable at build, dropped later) fails open — it admits the delivery rather than 500-ing the hook path — and records reason "redis idempotency backend unreachable at runtime (dedup best-effort until it recovers)", which auto-clears on the next successful Redis op.
Check status with any of:
praisonai gateway status
praisonai gateway doctorOr read health()["degraded_owners"] in-process.
# Multi-replica gateway with cluster-wide exactly-once inbound admission.
# Reuses the gateway's push RedisConfig; no extra idempotency knob needed.
hooks:
idempotency:
store_backend: redis
hooks:
- path: gmail
agent: assistantWhen Redis is unavailable and the store falls back, health() shows:
{
"degraded_owners": [
{
"owner_kind": "gateway",
"owner_id": "durability:idempotency",
"reason": "redis idempotency backend not available (running per-replica)",
"retry_hint": "praisonai gateway doctor --fix"
}
]
}Enable both the turn lock and store_backend: redis together for a multi-replica gateway — see Cross-Replica Idempotency.
Hot-reload: flipping store_backend via reload_config rebuilds the store lazily on the next reserve. See Gateway Hot-Reload.
Burst Coalescing
Set coalesce to collapse a burst of deliveries for the same entity into one agent run on the latest payload.
Without coalescing, five rapid pushes to one PR run the agent five times. With it, you get one run, fewer LLM calls, and no stale replies.
Python
from praisonaiagents import Agent
from praisonaiagents.gateway import CoalesceConfig
from praisonai.gateway import Gateway
reviewer = Agent(name="reviewer", instructions="Summarise what changed on this PR.")
gw = Gateway(agents=[reviewer])
gw.register_hook({
"path": "github",
"agent": "reviewer",
"message": "Latest push: {{ payload.head_commit.message }}",
"coalesce": CoalesceConfig(
group_key="{repository.full_name}#{pull_request.number}",
window_seconds=30,
max_wait_seconds=300,
),
})
gw.start()YAML
hooks:
- path: github
agent: reviewer
message: "Latest push: {{ payload.head_commit.message }}"
coalesce:
group_key: "{repository.full_name}#{pull_request.number}"
window_seconds: 30
max_wait_seconds: 300How Coalescing Works
sequenceDiagram
participant Ext as External Service
participant GW as Gateway /hooks/github
participant Dedup as Idempotency Store
participant Buf as HookCoalescer
participant Agent
Ext->>GW: POST push #1
GW->>Dedup: reserve + record
GW->>Buf: submit(group=repo#PR, payload #1)
GW-->>Ext: 202 {"coalesced": true, "pending": 1}
Ext->>GW: POST push #2
GW->>Buf: submit(group=repo#PR, payload #2)
GW-->>Ext: 202 {"coalesced": true, "pending": 2}
Note over Buf: quiet window elapses<br/>(or max_wait reached)
Buf->>Agent: start("[2 events coalesced; only the latest is shown] ...")
Agent-->>Buf: reply
Buf->>GW: deliverCoalesce Options
| Option | Type | Default | Description |
|---|---|---|---|
group_key | str | required | Template over the payload, e.g. "{repository.full_name}#{pull_request.number}". A delivery whose rendered key is empty runs on its own (not coalesced). Groups are scoped per hook path. |
window_seconds | float | 30.0 | Debounce quiet window. The group fires after this many seconds with no new delivery. Must be > 0. |
max_wait_seconds | float | 300.0 | Hard cap from the first delivery in the group, so a steady stream still dispatches. Must be >= window_seconds. |
Invalid values raise ValueError (empty group_key, non-finite durations, window_seconds <= 0, or max_wait_seconds < window_seconds).
Agent Message Prefix
When a run absorbs more than one delivery, the agent sees:
[N events coalesced; only the latest is shown]
<rendered message from the latest payload>Response Shapes
| Outcome | Status | Body |
|---|---|---|
| Delivery buffered into a coalesced group | 202 | {"ok": true, "coalesced": true, "pending": N} |
Pending-group cap (10,000) exceeded | 503 + Retry-After: 30 | {"ok": false, "error": "too many pending coalesced groups"} |
coalesce not configured, or group_key renders empty | 200 after the run (unchanged) | existing shape |
Choosing Options
Key on the entity, not the event
For GitHub pushes to a PR use "{repository.full_name}#{pull_request.number}", not "{head_commit.id}". The commit sha changes every push, so nothing coalesces.
Keep window_seconds short for interactive bots
30s suits background triage; use 5–10s when a user is waiting.
Set max_wait_seconds to a multiple of the window
The default is 10×. A steady stream (CI logs, scrapers) still dispatches at the cap.
Prefer deliver_only when you don't need the LLM
Plain forwarding with deliver_only is already sub-second. Coalescing pays off by skipping repeated LLM turns on the same entity.
Security
| Behaviour | Detail |
|---|---|
| Auth required | 401 on missing or invalid Authorization: Bearer header |
| Per-hook auth | auth field on the hook overrides the gateway’s global auth_token |
| Malformed JSON | 400 response, no agent run |
| Missing agent | 500 when the hook names an agent that isn’t registered (no silent fallback to another agent) |
| Disabled hook | 404 when enabled: false |
| Untrusted-payload fencing | On for agent-turn hooks; interpolated {{ payload.x }} values wrapped in <external_request_payload>. Off for deliver_only: true where the message is delivered verbatim. |
Verifying Provider Signatures (HMAC)
Set secret to verify the provider’s signature, and pick signature_scheme to match how they sign — a missing, invalid, or stale signature returns 401 {"error": "invalid signature"} before any agent runs.
Verification is fail-closed and opt-in: with no secret set, nothing changes. When secret is set without an explicit signature_header, the header defaults to X-Hub-Signature-256 (the GitHub/webhook convention) so a bare secret is a working config, not a 401 trap. The default signature_scheme is hmac_hex, so existing hooks keep working unchanged.
GitHub (hmac_hex)
hooks:
- path: github
agent: triager
secret: "${GITHUB_WEBHOOK_SECRET}"
signature_prefix: "sha256=" # signature_header defaults to X-Hub-Signature-256
# signature_scheme: hmac_hex # default — can be omitted
deliver_to: "slack:#triage"
message: "New {{ payload.action }} on #{{ payload.issue.number }}: {{ payload.issue.title }}"Base64 (hmac_base64)
hooks:
- path: custom
agent: triager
secret: "${CUSTOM_WEBHOOK_SECRET}"
signature_header: "X-Signature"
signature_scheme: "hmac_base64"Timestamped (hmac_timestamped)
hooks:
- path: billing
agent: billing-support
secret: "${PROVIDER_WEBHOOK_SECRET}"
signature_header: "X-Signature"
signature_scheme: "hmac_timestamped"
timestamp_header: "X-Timestamp"
signature_tolerance_seconds: 300 # ±5 minutes — defaultStripe (structured)
hooks:
- path: stripe
agent: billing-support
secret: "${STRIPE_WEBHOOK_SECRET}"
signature_header: "Stripe-Signature"
signature_scheme: "structured" # t=…,v1=… composite
# timestamp_header omitted — t= is read from Stripe-Signature itself
deliver_to: "slack:#billing"
message: "Stripe event {{ payload.type }} for {{ payload.data.object.customer }}"| Scheme | Signs | Signature format |
|---|---|---|
hmac_hex (default) | raw body | hex digest, optionally with an algo= prefix |
hmac_base64 | raw body | base64 digest |
hmac_timestamped | "{timestamp}.{body}" | hex digest; timestamp in timestamp_header |
structured | "{t}.{body}" | t=…,v1=…,v1=… header; any v1 may match |
Replay Window
A signed body captured from network logs can be replayed forever unless the timestamp is checked — signature_tolerance_seconds rejects requests older (or newer) than the window.
- The freshness check runs only for
hmac_timestampedandstructured, because only those sign the timestamp.hmac_hexandhmac_base64hooks behave exactly as before. - Default is
300seconds (±5 minutes).0disables the check — use it for testing only. - A negative or non-integer
signature_tolerance_secondsraisesValueErrorat config time. timestamp_headeronhmac_hex/hmac_base64is refused at config time: an unsigned timestamp would let an attacker replay an old body and signature with a fresh header.hmac_timestampedwithouttimestamp_headeris also refused (nowhere to read the timestamp from).
Test Locally
Sign the exact body you POST.
hmac_hex (GitHub)
SECRET="my-signing-secret"
BODY='{"action":"opened","issue":{"number":42,"title":"Bug"}}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')"
curl -X POST http://localhost:8765/hooks/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIG" \
-H "X-GitHub-Event: issues" \
-d "$BODY"hmac_timestamped
SECRET="my-signing-secret"
BODY='{"id":"evt_1"}'
NOW=$(date +%s)
SIG=$(printf '%s' "$NOW.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:8765/hooks/billing \
-H "Content-Type: application/json" \
-H "X-Signature: $SIG" \
-H "X-Timestamp: $NOW" \
-d "$BODY"structured (Stripe)
SECRET="my-signing-secret"
BODY='{"id":"evt_1","type":"invoice.paid"}'
NOW=$(date +%s)
V1=$(printf '%s' "$NOW.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST http://localhost:8765/hooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=$NOW,v1=$V1" \
-d "$BODY"Event Filtering
Set events to an allow-list so only matching deliveries run a turn — everything else is a cheap 200 {"ok": true, "skipped": "event"} with no LLM cost.
The event type is read from event_header (a request header) or, when that header is absent, from the payload as a dotted path (defaulting to "event").
hooks:
- path: github
agent: triager
event_header: "X-GitHub-Event"
events: ["issues.opened", "pull_request.opened"]
deliver_to: "slack:#triage"
message: "New {{ payload.action }} on #{{ payload.issue.number }}"GitHub sends the base event (issues) in the header and the sub-type in the payload’s action. A namespaced filter like issues.opened matches only when action == "opened" — fail-closed: a delivery that omits action is never admitted, so a bare issues event cannot slip through a filter that only allows issues.opened.
Read the event from a payload field instead of a header by pointing event_header at a dotted path:
hooks:
- path: stripe
deliver_only: true
deliver_to: "slack:#billing"
event_header: "type" # read payload["type"]
events: ["invoice.payment_failed"]
message: "⚠️ Payment failed for {{ payload.data.object.customer }}"Deliver-Only Mode (no LLM turn)
Set deliver_only: true to route the rendered message straight to deliver_to — no agent, no LLM cost, sub-second forwarding.
hooks:
- path: sentry
deliver_only: true
deliver_to: "telegram:${OPS_CHAT_ID}"
secret: "${SENTRY_WEBHOOK_SECRET}"
signature_header: "Sentry-Signature"
message: "🚨 {{ payload.project }}: {{ payload.event.title }}\n{{ payload.url }}"gw.register_hook({
"path": "sentry",
"deliver_only": True, # zero LLM cost, sub-second forwarding
"deliver_to": "telegram:${OPS_CHAT_ID}",
"secret": "${SENTRY_WEBHOOK_SECRET}",
"signature_header": "Sentry-Signature",
"signature_algo": "sha256",
"message": "🚨 {{ payload.project }}: {{ payload.event.title }}\n{{ payload.url }}",
})deliver_only composes with signature verification and event filtering and requires deliver_to. Response shapes:
| Outcome | Response |
|---|---|
| Delivered | {"ok": true, "action": "deliver", "delivered": true} |
| Empty rendered message | {"ok": true, "action": "deliver", "delivered": false, "skipped": "empty message"} |
Missing deliver_to | {"ok": false, "error": "deliver_only hook requires 'deliver_to'"} |
| Delivery failed | {"ok": false, "error": "hook delivery failed", "delivered": false} |
End-to-End Example: Gmail → Triage Agent → Telegram
hooks:
- path: gmail
agent: email-triager
action: agent
auth: ${GMAIL_HOOK_SECRET}
session_key: "gmail:{message_id}"
idempotency_key: "{message_id}"
deliver_to: telegram:123456789
message: |
From: {{ payload.from }}
Subject: {{ payload.subject }}
Snippet: {{ payload.snippet }}
Triage this email and suggest a one-line action.- Gmail push subscription fires
POST /hooks/gmailwith the email payload. - Gateway verifies the bearer token from
$GMAIL_HOOK_SECRET. - Session key
gmail:<message_id>scopes the conversation to this email thread. - The rendered message is sent to the
email-triageragent. - The agent’s reply is delivered to Telegram chat
123456789.
Best Practices
Always set an idempotency_key for event-driven sources
External webhooks retry on timeout. Without an idempotency_key, a slow agent run followed by a timeout retry will run the agent twice. Use a message or event id from the payload.
Use per-hook auth tokens, not the global token
Set a distinct auth secret per hook so you can rotate individual secrets without restarting the gateway or changing the global token.
Set session_key to scope conversations
Without session_key, all deliveries to a hook share the same session (hook:<path>). Use a payload field like {user_id} or {message_id} to isolate conversations by sender or thread.
Test locally with curl before connecting a real service
curl -X POST http://localhost:8765/hooks/gmail \
-H "Authorization: Bearer my-shared-secret" \
-H "Content-Type: application/json" \
-d '{"from": "alice@example.com", "subject": "Hello", "message_id": "test-001"}'Related
HMAC signature verification for outbound bot webhooks (different surface)
Proactive DeliveryDelivery routing — the channel:target format used in deliver_to
Gateway OverviewGateway configuration, channels, and multi-bot mode
Gateway CLIAll gateway CLI commands including hooks add / list / remove
Untrusted Request FencingHow inbound payloads are fenced as data before the agent sees them