Inbound & BYO inference
Your model, our rails
agentpush never runs your inference. Inbound messages hit the routing engine, which journals them and — for notify routes — POSTs a signed, versioned envelope to your endpoint. Your agent thinks, then replies with send_message. Prefer polling? That works too.
Inbound routes
An inbound_route is a matching rule owned by your workspace. Routes are independent— every enabled route is evaluated against every inbound message; there is no first-match-wins. Each match journals a delivery event tagged with the route's dispatch_tag (idempotent on message_id+ tag, so webhook redeliveries don't duplicate), and notify routes additionally POST the envelope to your URL.
| Field | Meaning |
|---|---|
match_type | catch_all · channel_is · keyword_contains · from_contains · subject_contains · gmail_label (the last two are email-shaped) |
match_value | The keyword / substring / label / channel name. Required unless match_type is catch_all. |
channel | Scopes the route to one channel (whatsapp, telegram, mail, …); null applies everywhere. |
dispatch_mode | journal_only (default) just tags the event; notify also POSTs the envelope to notify_url. |
notify_url | Required for notify. Must be a public https:// destination — loopback, private and link-local addresses are refused (SSRF guard). |
notify_secret | Optional HMAC key for the signature. Encrypted at rest; responses only ever expose has_notify_secret. Omit to receive unsigned notifies. |
dispatch_tag | Stamped as contact_ref on every matched journal event — your agent-side routing key. |
enabled / priority | Toggle without deleting; priority is informational ordering only. |
POST /tools/inbound_route_create
{
"name": "support-agent",
"channel": "whatsapp", // null = every channel
"match_type": "catch_all",
"dispatch_tag": "support", // tags the journaled event
"dispatch_mode": "notify",
"notify_url": "https://agent.example.com/agentpush",
"notify_secret": "whsec_a-long-random-string"
}
// → the route view — notify_secret is never echoed back,
// only "has_notify_secret": trueManage routes via the inbound_route_* tools (list / get / create / update / set_enabled / delete) or the REST twins at /inbound-routes.
The signed envelope
What lands on your notify_url is a versioned wire contract — MessagingInboundEnvelope v1:
{
"version": 1,
"workspaceId": "acme",
"channel": "whatsapp",
"providerAccountId": "pa_7f3c…", // present when a provider account handled it
"from": "+33612345678",
"conversationId": "+33612345678",
"messageId": "wamid.HBgL…",
"text": "I'd like to change my booking",
"media": [
{
"type": "image",
"url": "https://…",
"mimeType": "image/jpeg",
"size": 182734
}
]
}versionis literally1. Receivers must tolerate additivefields within version 1 — parse leniently, don't reject unknown keys.mediaitems carry metadata plus aurland/orproviderMediaId— never inline bytes. Fetch content on demand (for email attachments, usemessaging_attachment_fetch).conversationIdfalls back tofromon channels without a distinct conversation concept.
Verifying the signature
When the route has a notify_secret, agentpush signs the exact raw request body and sends:
X-Agentpush-Signature: sha256=<hex HMAC-SHA256(notify_secret, rawBody)>import { createHmac, timingSafeEqual } from "node:crypto"
function verifySignature(
secret: string,
rawBody: string,
header: string | undefined
): boolean {
if (!header) return false
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
const received = Buffer.from(header)
const wanted = Buffer.from(expected)
return received.length === wanted.length && timingSafeEqual(received, wanted)
}
// Hono receiver — the same shape works in Express, Fastify, Next, …
app.post("/agentpush", async c => {
// Raw body FIRST: the signature covers the exact bytes on the wire.
// Never verify against a re-serialization of parsed JSON.
const rawBody = await c.req.text()
const signature = c.req.header("x-agentpush-signature")
if (!verifySignature(process.env.NOTIFY_SECRET, rawBody, signature)) {
return c.json({ error: "invalid signature" }, 401)
}
const envelope = JSON.parse(rawBody)
// … run your inference on envelope.text, then reply (next section)
return c.json({ ok: true })
})Delivery semantics
- Notifies are at-least-once: a durable notify row is snapshotted before the first attempt, and a worker retry pass re-dispatches failures — make your receiver idempotent on
messageId. - Each attempt is a POST with a 10-second timeout; any non-2xx response, timeout or egress-guard rejection is journaled as
failed. - Editing a route later doesn't rewrite in-flight notifies — the URL, secret and payload are snapshotted at match time.
Replying
Your reply is a normal send: call send_message(REST or MCP) using the envelope's channel and from as the target. Replying promptly also (re)opens the session window for follow-up free-form messages.
curl -X POST "$AGENTPUSH_URL/tools/send_message" \
-H "Authorization: Bearer apk_…" \
-H "Content-Type: application/json" \
-d '{
"to": { "channel": "'"$CHANNEL"'", "address": "'"$FROM"'" },
"content": { "text": "Sure — which date works better for you?" }
}'
# CHANNEL and FROM come straight from the envelope you received.The pull model
No public endpoint? Run the loop the other way around. Generic inbound Requests (non-messaging webhooks relayed through POST /inbound/webhook/:source) are journaled and can be polled with poll_inbound; the agent wakes on its own schedule, processes what's new and replies with send_message:
/tools/poll_inboundalso available over MCP// wake-by-poll: call periodically, advance the cursor
POST /tools/poll_inbound
{ "source": "stripe", "since": 1760012345678, "limit": 50 }
// → journaled inbound events (oldest first) — only *inbound* rows,
// never the notifies agentpush itself dispatched (no self-wake loops).
// Keep max(timestamp) as the next "since".For conversational history on messaging channels, use read_thread (per contact, all channels merged) or the inbox endpoints (GET /inbox/senders, GET /inbox/thread). The counterpart of the pull model for outbound generic payloads is dispatch_request — fire-and-forget signed POSTs through the same egress guard, always journaled.