Bridge an imported agentpush MCP server (Telegram, WhatsApp, etc.) with live agentproto sessions via the daemon's transmitter subsystem: transmit_message sends outbound and binds the recipient to a session, inbound_watcher_start polls agentpush for new messages, and inbound_endpoint_create/POST /inbound(/:slug) route inbound replies back into the SAME session instead of spawning a fresh agent every time. Use when wiring push notifications, chat-bot replies, or any inbound/outbound messaging channel (Telegram, WhatsApp, Slack, a generic webhook) into an agentproto agent.
Install
npx skillscat add agentproto/ts/agentpush Install via the SkillsCat registry.
agentpush — the agentpush ⇄ agentproto transmitter
Bidirectional bridge between an imported agentpush MCP server (Telegram,
etc.) and live agentproto sessions: transmit_message sends outbound and
can bind the recipient to the sending session, and inbound replies from that
contact then route straight into the SAME session as a real user turn —
instead of spawning a brand-new agent for every reply.
Source: packages/runtime/src/transmitter-bindings.ts,inbound-adapters.ts, outbound-adapters.ts, inbound-router.ts,inbound-watcher.ts, the inbound_*/transmit_message/telegram_bot_*
tools in orchestration-tools.ts/telegram-bot-creds.ts, and thePOST /inbound(/:slug) routes in http-server.ts. Full reference:packages/runtime/docs/TRANSMITTER.md.
The model
One TransmitterBindingStore maps
(alias, source, contactRef) -> { sessionId, mode, provider?, lastSeenTs }alias— the imported MCP alias (agentpush) or bot-token alias (telegram).source— the channel/phone/chat id the message is scoped to.contactRef— the sender's id within that source.sessionId— the agentproto session future inbound replies from this
contact should route into.mode—"route"or"route-or-spawn"(the mode this binding was
created under).provider— the outbound provider the binding was created through
("agentpush","telegram", …). Defaults to"agentpush".
Bindings live in memory, persist (debounced write) to~/.agentproto/transmitter-bindings.json, load on construct, and start
empty on a missing/corrupt file rather than throwing. One store instance is
shared across the poll watcher, transmit_message, and the inbound HTTP
routes.
MCP tools that actually exist
All verified against orchestration-tools.ts / telegram-bot-creds.ts —
don't assume a verb exists beyond this list.
| tool | purpose |
|---|---|
transmit_message |
Send outbound through a provider; binds the recipient to a session by default. Registered only when both an MCP proxy and a binding store are wired. |
inbound_watcher_start |
Start a background poller against poll_inbound on an imported agentpush alias; spawns (or routes, per mode) per contact_ref. Returns a watcherId. |
inbound_watcher_stop |
Stop a watcher by id. Cursor position is preserved for a later restart. |
inbound_watcher_list |
List all watchers (running + stopped) with cursor, poll/fire times, spawn count. |
inbound_endpoint_create |
Create/update a provider-agnostic POST /inbound/<slug> webhook endpoint (dialect, alias, mode, optional signing secret). |
inbound_endpoint_list |
List endpoints. Secrets are never emitted. |
inbound_endpoint_delete |
Delete an endpoint by slug. |
telegram_bot_token_set |
Store/rotate a Telegram bot token (0600 file under ~/.agentproto/telegram-bot-creds/), never echoed back. |
telegram_bot_token_status |
Check whether a token is configured (fingerprint/last4 only). |
telegram_bot_set_webhook |
Call Telegram's setWebhook for a bot alias using the stored token. |
mcp_import |
Prerequisite: import the agentpush MCP server under an alias before any tool above can use it. |
inbound_watcher_* requires an inboundWatcher to be wired on the daemon;transmit_message requires both an MCP proxy and a binding store;inbound_endpoint_* requires an endpointStore. On a daemon started
without one of these, the corresponding tools are simply absent — checktools/list, don't assume.
Outbound: transmit_message
{
"provider": "agentpush",
"alias": "agentpush",
"source": "+33600000000",
"contact_ref": "alice",
"text": "your PR is ready for review",
"sessionId": "sess_abc123",
"bind": true
}provider— default"agentpush". Enum:agentpush,telegram,whatsapp,slack,generic,native— but onlyagentpushandtelegramhave a workingsendOutboundimplementation today; the
other four return{ sent: false, error: "unsupported_provider" }
(outbound-adapters.ts's switch has no case for them on the send side,
even though they're valid inbound dialects — send and receive support
are NOT symmetric).alias— required foragentpush; defaults to"default"fortelegram.source/contact_ref— channel/phone and recipient id (agentpush), or
chat id (telegram, ascontact_ref).text— required unlessattachmentsis given.attachments[]—{ type: photo|document|video|audio, path, caption? },
local files; multiple become an album/media group where supported.sessionId— required by the tool schema itself (not merely "used
when binding") — every call must pass one, even if you intendbind: false.bind— defaulttrue: upserts(alias, source, contact_ref) -> sessionIdwithmode: "route-or-spawn"and the resolvedprovider. A
failed send (sent: false) never binds.- Returns
{ sent: true, bound: boolean, message_id?: string }on success,
or{ sent: false, bound: false, error: string, blocked_reason?: string, suggestion?: string }on failure.
A failed send (sent: false) never binds.message_idis the provider's own id for the sent message.blocked_reasonandsuggestionare populated when agentpush responds HTTP 200 withstatus: "blocked"
(opt-out, expired session, unapproved template, etc.) orstatus: "failed"—transmit_messagesurfaces
those assent: false, never as a falsesent: true.
agentpush dispatch calls the imported alias's real send_message tool:{ to: { channel: source, address: contact_ref }, content: { text } }.
Attachments are uploaded first via upload_media (channel: source), then
referenced in content.media[] by the returned providerMediaId.
telegram POSTs directly to the Bot API: sendMessage (text-only),sendPhoto/sendDocument/sendVideo/sendAudio (one attachment),sendMediaGroup (several). Text/captions are converted from Markdown to
Telegram's MarkdownV2 (headers, bold, italic, strikethrough, code spans —
special chars auto-escaped) before sending. The bot token comes fromtelegram_bot_token_set's store, resolved by alias.
Inbound routing modes
Both the poll watcher and POST /inbound(/:slug) share one decision
function, routeInboundMessage (inbound-router.ts):
| mode | binding + session alive | binding + session dead | no binding |
|---|---|---|---|
spawn (poll watcher default) |
(ignored) spawns fresh | (ignored) spawns fresh | spawns fresh |
route |
routes ("routed") |
resurrects, routes ("restarted-routed") |
skips ("skipped") |
route-or-spawn (webhook default) |
routes | resurrects, routes | spawns if a spawn path is configured, else skips |
"Resurrects" = an agent-cli session that's no longer alive is restarted in
place (restartAgentSession(..., { forceAgentResume: true }), same
primitive the cron scheduler's prompt-session job uses); the reply lands
in the (possibly new) resulting session id. A session counts as alive
unless it's missing entirely or processAlive === false explicitly — a
pid-less ACP-native/remote session is treated as alive. Every successful
route refreshes the binding's lastSeenTs.
Only the poll watcher spawns unbound contacts. Neither the nativePOST /inbound route nor a POST /inbound/:slug endpoint is ever wired
with a spawnForContact — an unauthenticated webhook payload carries no
adapter/prompt template to spawn with — so an unbound contact underroute-or-spawn on either HTTP path is "skipped", not spawned. Onlyinbound_watcher_start (which is configured with adapter +promptTemplate) can spawn from an inbound event.
POST /inbound — native push ingress
Bearer-gated exactly like the mutating /sessions/* routes (token from<workspace>/.agentproto/runtime.json):
POST /inbound
Authorization: Bearer <token>
Content-Type: application/json
{
"alias": "agentpush",
"source": "+33600000000",
"contact_ref": "alice",
"text": "sounds good, ship it",
"messages": [ /* optional: raw agentpush events */ ],
"mode": "route-or-spawn" // optional, default
}200 { "action": "routed"|"spawned"|"restarted-routed"|"skipped", "sessionId"?: string }400— missingalias/source/contact_ref/text, or an invalidmode.401— missing/invalid bearer token.501 { "error": "inbound_routing_not_configured" }— the daemon wasn't
wired with arouteInboundMessagehandler.
POST /inbound/:slug — provider-agnostic webhook endpoints
Configured via inbound_endpoint_create (dialect, imported alias, optional
forced source, optional signing secret, routing mode). Managed
endpoints share the same routeInboundMessage router as the poll watcher
and the native route.
404 { "error": "unknown_inbound_endpoint" }— unknown slug, or the
endpoint exists butenabled: false.When a secret is configured, it's REQUIRED and the sessions bearer
gate is bypassed (so a public webhook isn't accidentally opened by the
loopback Origin allowlist). When no secret is configured, the route
falls back to the same sessions bearer gate as the native route — a
webhook endpoint is never silently unauthenticated either way.Signature verification per provider:
provider header format agentpushX-Agentpush-Signaturesha256=<hex-hmac-sha256>telegramX-Telegram-Bot-Api-Secret-Tokenexact secret, constant-time whatsappX-Hub-Signature-256sha256=<hex-hmac-sha256>slackX-Slack-SignatureSlack v0 signature + 5-minute replay window genericX-Agentproto-Signaturesha256=<hex-hmac-sha256>nativen/a uses the sessions bearer gate (not applicable to :slugendpoints)Bad/missing signature →
401 { "error": "bad_signature", "reason": ... }.After verification,
normalizeInbound()maps the provider's raw webhook
body to{ alias, source, contactRef, text, messages? }.agentpushandslackhandle achallengehandshake (200 { "challenge": "..." }, no
session touched).telegramandslackignore bot/self messages.
Missing text →200 { "action": "ignored", "reason": "no_text" }(so the
provider doesn't retry forever) rather than an error.A
?mode=spawn|route|route-or-spawnquery param overrides the endpoint's
configured mode per-request.Dedup: each endpoint keeps an in-memory FIFO of the last ~500 seen
provider message ids (global cap ~5000 across all endpoints); a repeat →200 { "action": "duplicate" }. In-memory only — a daemon restart
re-accepts anything a provider redelivers.501 { "error": "inbound_routing_not_configured" }— same as the native
route, ifrouteInboundMessageisn't wired.
Simulating an inbound message
pnpm --filter @agentproto/runtime sim:inbound
# or with overrides:
pnpm --filter @agentproto/runtime sim:inbound -- --contact=bob --text="ping from bob"packages/runtime/scripts/simulate-inbound.mjs wires the realSessionsRegistry, TransmitterBindingStore, routeInboundMessage, andstartHttpServer in-process, with one fake session standing in for a real
adapter — so you see the whole path (HTTP → routing → the session
receiving the turn) with no live agentpush server or daemon required. Flags
(or env ALIAS/SOURCE/CONTACT/TEXT/SESSION_ID/PORT/TOKEN):--alias, --source, --contact, --text, --session-id, --port,--token. Exits non-zero if the HTTP response or the delivered text
doesn't match expectations.
Telegram bot wiring recipe
- Create the bot with @BotFather, copy the token.
telegram_bot_token_set{ token }(alias"default"unless running
multiple bots).inbound_endpoint_create{ slug, provider: "telegram", alias, mode: "route-or-spawn" }— response includes the generatedsecret.- Start a public ingress proxy, get its public URL.
telegram_bot_set_webhook{ url: "https://<proxy>/inbound/<slug>", secret_token: "<endpoint-secret>" }.
Gotchas
transmit_message.sessionIdis not conditionally required — the tool
schema requires it on every call,bind: falseor not.- Telegram's binding
sourceis the literal string"telegram", never
the chat id — collapsing it to the chat id would collide withcontactRefin the(alias, source, contactRef)key and break every
lookup written viatransmit_message(which always sendssource: "telegram"). This is an explicit code comment ininbound-adapters.ts, not an accident. - Send/receive dialect support isn't symmetric.
whatsapp/slack/generic/nativeare valid inbound webhook dialects but have no
outboundsendOutboundcase —transmit_messagewith those providers
always fails withunsupported_provider. - A failed send never binds — check
sentbefore trustingbound. - Native
POST /inboundand per-slug endpoints never spawn unbound
contacts; onlyinbound_watcher_startcan, because only it carries anadapter/promptTemplateto spawn with. - Endpoint secrets gate signature verification, not the bearer gate
outright — configuring a secret trades the sessions bearer gate for
HMAC/signature verification; omitting it keeps the bearer gate. There is
no endpoint mode that skips both. - This repo only implements the ingress side. For inbound push to fire
in production without the 5-second poll loop, agentpush itself must be
configured toPOSTits webhook payload to<daemon-url>/inbound(or a
configured/inbound/:slug) with the daemon's bearer token — that
configuration lives outside this repo. - Wiring an agentpush API key without leaking it into every agent's env
is a job for the credential broker, not a raw env var — see [[auth]]
(worked example there uses agentpush itself:credentialRefon anagent_start.mcpServers[]entry resolves to a freshAuthorization
header at spawn time).