@agentproto/runtime 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,356 @@
1
+ # The agentpush ⇄ agentproto transmitter
2
+
3
+ Bidirectional bridge between an imported agentpush MCP server (Telegram,
4
+ etc.) and a live agentproto session: outbound sends can bind a contact to
5
+ the session that sent them, and inbound replies from that contact then
6
+ route straight into the SAME session — as a real user turn — instead of
7
+ spawning a brand-new agent every time.
8
+
9
+ ## The model
10
+
11
+ A single `TransmitterBindingStore` (`transmitter-bindings.ts`) maps
12
+
13
+ ```
14
+ (alias, source, contactRef) -> { sessionId, mode, provider?, lastSeenTs }
15
+ ```
16
+
17
+ - `alias` — the imported MCP alias (agentpush) or bot token alias (telegram).
18
+ - `source` — the channel/phone/chat id the message is scoped to.
19
+ - `contactRef` — the sender's id within that source.
20
+ - `sessionId` — the agentproto session future inbound replies from this
21
+ contact should route into.
22
+ - `mode` — the routing mode this binding was created under (`"route"` or
23
+ `"route-or-spawn"; see below).
24
+ - `provider` — the outbound provider this binding was created through
25
+ (`"agentpush"`, `"telegram"`, etc.). Defaults to `"agentpush"` for
26
+ backward compatibility.
27
+
28
+ One shared store instance is constructed in `index.ts` and injected into
29
+ three places: the inbound poll watcher, the `transmit_message` tool, and
30
+ the `POST /inbound` push route. Bindings persist (debounced JSON write) to
31
+ `~/.agentproto/transmitter-bindings.json`, load-on-construct, and start
32
+ empty on a missing/corrupt file rather than throwing.
33
+
34
+ ## Outbound provider abstraction
35
+
36
+ `sendOutbound` in `outbound-adapters.ts` is the provider-agnostic send primitive.
37
+ It mirrors `inbound-adapters.ts` on the read side: a single dispatch function
38
+ that branches by `OutboundProvider` ("agentpush", "telegram", "whatsapp",
39
+ "slack", "generic", "native"). Adding a new outbound dialect means adding a case
40
+ to the switch in `sendOutbound` — the MCP tool and HTTP routes stay unchanged.
41
+
42
+ ## Outbound: `transmit_message`
43
+
44
+ MCP tool that sends a message out through a provider-specific outbound channel
45
+ and, by default, binds the recipient to the calling session:
46
+
47
+ ```json
48
+ {
49
+ "provider": "agentpush",
50
+ "alias": "agentpush",
51
+ "source": "+33600000000",
52
+ "contact_ref": "alice",
53
+ "text": "your PR is ready for review",
54
+ "sessionId": "sess_abc123",
55
+ "bind": true
56
+ }
57
+ ```
58
+
59
+ - `provider` — optional, defaults to `"agentpush"`. Supported values:
60
+ `"agentpush"`, `"telegram"`, `"whatsapp"`, `"slack"`, `"generic"`, `"native"`.
61
+ - `alias` — for `agentpush`, the imported MCP alias (required). For
62
+ `telegram`, the bot-token alias (defaults to `"default"`).
63
+ - `source` — for `agentpush`, the channel/phone. For `telegram`, the chat id.
64
+ - `contact_ref` — recipient id (`agentpush` contact_ref) or chat id (`telegram`).
65
+ - `text` — message text. Optional when `attachments` is provided; at least one
66
+ of `text` or `attachments` is required.
67
+ - `attachments` — optional array of local files to send as media:
68
+ ```json
69
+ {
70
+ "type": "photo",
71
+ "path": "/tmp/screenshot.png",
72
+ "caption": "optional caption"
73
+ }
74
+ ```
75
+ Supported types: `photo`, `document`, `video`, `audio`. Multiple attachments
76
+ are sent as an album/media group where the provider supports it.
77
+ - `bind` defaults to `true` — on a successful send, upserts a binding
78
+ `(alias, source, contact_ref) -> sessionId` with `mode: "route-or-spawn"` and
79
+ the chosen `provider`. Pass `bind: false` to send without binding.
80
+ - Returns `{ sent: boolean, bound: boolean, message_id?: string }` on success,
81
+ or `{ sent: false, bound: false, error: string, blocked_reason?: string,
82
+ suggestion?: string }` on failure. A send failure (`sent: false`) never
83
+ binds. `message_id` is the provider's own id for the sent message — pass it
84
+ to `check_delivery` to verify the send landed.
85
+ - **agentpush never throws for a send it can't make** — it replies HTTP 200
86
+ with `status: "blocked"` (opt-out, expired session, unapproved template…)
87
+ or `status: "failed"`. `transmit_message` reads that status instead of
88
+ treating MCP transport success as delivery: a `blocked` or `failed` reply
89
+ is surfaced as `sent: false` with `blocked_reason`/`suggestion` (or
90
+ `error`) propagated, never as a false `sent: true`. Telegram gets the same
91
+ treatment for its own backward-compat case — an HTTP 200 body with
92
+ `ok: false` is also reported as `sent: false`, not `sent: true`.
93
+
94
+ ### Provider-specific behaviour
95
+
96
+ **agentpush** calls the real `send_message` MCP tool on the imported alias:
97
+ ```json
98
+ {
99
+ "to": { "channel": "<source>", "address": "<contact_ref>" },
100
+ "content": { "text": "<text>" }
101
+ }
102
+ ```
103
+
104
+ When `attachments` are provided, each file is first uploaded via
105
+ `upload_media` (channel = `<source>`), then referenced by `providerMediaId`
106
+ in `content.media`:
107
+ ```json
108
+ {
109
+ "to": { "channel": "<source>", "address": "<contact_ref>" },
110
+ "content": {
111
+ "text": "<text>",
112
+ "media": [
113
+ { "type": "image", "providerMediaId": "<media_id>", "caption": "..." }
114
+ ]
115
+ }
116
+ }
117
+ ```
118
+
119
+ **telegram** POSTs directly to the Telegram Bot API. Text-only messages use
120
+ `sendMessage`:
121
+ ```
122
+ POST https://api.telegram.org/bot<token>/sendMessage
123
+ { "chat_id": "<source>", "text": "<text>" }
124
+ ```
125
+
126
+ With one attachment, the matching method is used (`sendPhoto`,
127
+ `sendDocument`, `sendVideo`, or `sendAudio`) with a `multipart/form-data`
128
+ body. With several attachments, `sendMediaGroup` is used. The bot token is
129
+ resolved from a `TelegramBotCredsStore` (configured separately via
130
+ `telegram_bot_token_set`).
131
+
132
+ ### Telegram bot wiring recipe
133
+
134
+ 1. Create the bot with @BotFather and copy the token.
135
+ 2. Store the token in the daemon's credential store:
136
+ ```json
137
+ { "tool": "telegram_bot_token_set", "arguments": { "token": "..." } }
138
+ ```
139
+ 3. Create a provider-agnostic inbound endpoint:
140
+ ```json
141
+ {
142
+ "tool": "inbound_endpoint_create",
143
+ "arguments": {
144
+ "slug": "telegram-agentproto",
145
+ "provider": "telegram",
146
+ "alias": "default",
147
+ "mode": "route-or-spawn"
148
+ }
149
+ }
150
+ ```
151
+ The response includes the endpoint `secret`.
152
+ 4. Start the narrow public ingress proxy (see below) and get its public URL.
153
+ 5. Point Telegram's webhook at the proxy:
154
+ ```json
155
+ {
156
+ "tool": "telegram_bot_set_webhook",
157
+ "arguments": {
158
+ "url": "https://<proxy-public-url>/inbound/telegram-agentproto",
159
+ "secret_token": "<endpoint-secret>"
160
+ }
161
+ }
162
+ ```
163
+
164
+ ## Inbound routing modes
165
+
166
+ Both the poll watcher (`inbound-watcher.ts`, `inbound_watcher_start`'s
167
+ `mode` field) and `POST /inbound` share one decision function,
168
+ `routeInboundMessage` (`inbound-router.ts`):
169
+
170
+ | mode | binding exists, session alive | binding exists, session dead | no binding |
171
+ |---|---|---|---|
172
+ | `spawn` (poll watcher default) | *(ignored)* spawns a fresh agent | *(ignored)* spawns a fresh agent | spawns a fresh agent |
173
+ | `route` | routes into it (`"routed"`) | resurrects it, then routes (`"restarted-routed"`) | skips (`"skipped"`) |
174
+ | `route-or-spawn` (`POST /inbound` default) | routes into it (`"routed"`) | resurrects it, then routes (`"restarted-routed"`) | spawns a fresh agent if a spawn path is configured, else skips |
175
+
176
+ "Resurrects" means: an agent-cli session that's no longer alive is
177
+ restarted in place (same primitive `cron-scheduler.ts`'s `prompt-session`
178
+ job uses — `restartAgentSession(..., { forceAgentResume: true })`), and the
179
+ reply is delivered to the (possibly new) resulting session id. A session
180
+ counts as alive unless it's missing entirely or its `processAlive` field
181
+ is explicitly `false` — a pid-less ACP-native/remote session (no OS
182
+ process to probe) is treated as alive, not dead.
183
+
184
+ Every successful route into a bound session refreshes that binding's
185
+ `lastSeenTs`.
186
+
187
+ ## `POST /inbound` — push ingress contract
188
+
189
+ Bearer-gated exactly like the mutating `/sessions/*` routes (read the
190
+ token from `<workspace>/.agentproto/runtime.json`):
191
+
192
+ ```
193
+ POST /inbound
194
+ Authorization: Bearer <token>
195
+ Content-Type: application/json
196
+
197
+ {
198
+ "alias": "agentpush",
199
+ "source": "+33600000000",
200
+ "contact_ref": "alice",
201
+ "text": "sounds good, ship it",
202
+ "messages": [ /* optional: raw agentpush events, for the spawn-fallback template */ ],
203
+ "mode": "route-or-spawn" // optional, default "route-or-spawn"
204
+ }
205
+ ```
206
+
207
+ Responses:
208
+
209
+ - `200 { "action": "routed" | "spawned" | "restarted-routed" | "skipped", "sessionId"?: string }`
210
+ - `400` — a required field (`alias`/`source`/`contact_ref`/`text`) is
211
+ missing, or `mode` isn't one of the three valid values.
212
+ - `401` — missing/invalid bearer token.
213
+ - `501` — the daemon wasn't wired with a `routeInboundMessage` handler
214
+ (i.e. `POST /inbound` isn't enabled on this host).
215
+
216
+ Note: the daemon's own wiring never sets a `spawnForContact` for this
217
+ route — an unauthenticated webhook payload carries no adapter/prompt
218
+ template to spawn an agent with, so an unbound contact under
219
+ `route-or-spawn` is `"skipped"` rather than spawning an arbitrary agent
220
+ from push input. Only the poll watcher (which already has an
221
+ `adapter`/`promptTemplate` configured via `inbound_watcher_start`) spawns.
222
+
223
+ ## Simulating an inbound message
224
+
225
+ ### 1. The sim script (no live daemon needed)
226
+
227
+ `packages/runtime/scripts/simulate-inbound.mjs` composes the real wiring
228
+ in-process — real `SessionsRegistry`, real `TransmitterBindingStore`, real
229
+ `routeInboundMessage`, real `startHttpServer` — with one fake session
230
+ double standing in for a real adapter, so you can see the whole path
231
+ (HTTP → routing → the session actually receiving the turn) without an
232
+ agentpush server, an adapter install, or a running daemon:
233
+
234
+ ```sh
235
+ pnpm --filter @agentproto/runtime sim:inbound
236
+ # or with overrides:
237
+ pnpm --filter @agentproto/runtime sim:inbound -- --contact=bob --text="ping from bob"
238
+ ```
239
+
240
+ Flags (or matching env vars `ALIAS`/`SOURCE`/`CONTACT`/`TEXT`/
241
+ `SESSION_ID`/`PORT`/`TOKEN`): `--alias`, `--source`, `--contact`, `--text`,
242
+ `--session-id`, `--port`, `--token`. It prints the HTTP status, the
243
+ `{action, sessionId}` response, and the exact text the bound session
244
+ received, then exits non-zero if either doesn't match expectations.
245
+
246
+ ### 2. `curl` against a live daemon
247
+
248
+ Bind a contact first (via `transmit_message`, or by hand-seeding
249
+ `~/.agentproto/transmitter-bindings.json`), then:
250
+
251
+ ```sh
252
+ TOKEN=$(jq -r .token <workspace>/.agentproto/runtime.json)
253
+
254
+ curl -sS -X POST "http://127.0.0.1:<port>/inbound" \
255
+ -H "Authorization: Bearer $TOKEN" \
256
+ -H "Content-Type: application/json" \
257
+ -d '{
258
+ "alias": "agentpush",
259
+ "source": "+33600000000",
260
+ "contact_ref": "alice",
261
+ "text": "sounds good, ship it"
262
+ }'
263
+ ```
264
+
265
+ A `200` with `{"action":"routed","sessionId":"..."}` means the reply
266
+ landed as a user turn in that session — check it with `agent_output` /
267
+ `GET /sessions/:id/stream`.
268
+
269
+ ## Provider-agnostic inbound endpoints (`POST /inbound/:slug`)
270
+
271
+ As an alternative to the single shared bearer-gated `POST /inbound`, the
272
+ daemon now supports per-provider webhook endpoints:
273
+
274
+ ```
275
+ POST /inbound/<slug>
276
+ ```
277
+
278
+ Each endpoint is configured with dialect (`agentpush`, `telegram`,
279
+ `whatsapp`, `slack`, `generic`, `native`), an imported MCP alias, an
280
+ optional forced source, an optional webhook signing secret, and a routing
281
+ mode. The MCP tools `inbound_endpoint_create`, `inbound_endpoint_list`,
282
+ and `inbound_endpoint_delete` manage them. All endpoints are created via
283
+ `createInboundEndpointStore()` in `createGateway()` and share the same
284
+ `routeInboundMessage` router as the poll watcher and the legacy push route.
285
+
286
+ ### Signature verification
287
+
288
+ When a secret is configured, the secret is REQUIRED and the bearer gate
289
+ is bypassed, so a publicly exposed webhook endpoint is not accidentally
290
+ opened by the loopback Origin allowlist.
291
+
292
+ | provider | header | algorithm/format |
293
+ |---|---|---|
294
+ | `agentpush` | `X-Agentpush-Signature` | `sha256=<hex-hmac-sha256>` |
295
+ | `telegram` | `X-Telegram-Bot-Api-Secret-Token` | exact secret, constant-time compare |
296
+ | `whatsapp` | `X-Hub-Signature-256` | `sha256=<hex-hmac-sha256>` |
297
+ | `slack` | `X-Slack-Signature` | Slack request signature v0 with replay window |
298
+ | `generic` | `X-Agentproto-Signature` | `sha256=<hex-hmac-sha256>` |
299
+ | `native` | n/a | uses the sessions bearer gate |
300
+
301
+ ### Normalized envelope
302
+
303
+ After verification, the webhook JSON is normalized by `normalizeInbound()"
304
+ (`inbound-adapters.ts`) into the same `InboundMessage` shape the rest of
305
+ the transmitter expects:
306
+
307
+ ```json
308
+ {
309
+ "alias": "<configured alias>",
310
+ "source": "<channel/phone/chat id>",
311
+ "contactRef": "<sender id>",
312
+ "text": "<message text>",
313
+ "messages": [ /* optional: raw provider event */ ]
314
+ }
315
+ ```
316
+
317
+ Provider-specific behaviour:
318
+
319
+ - `agentpush` and `slack` support a `challenge` handshake; the challenge is
320
+ echoed as `{ "challenge": "..." }` and no session is touched.
321
+ - `telegram` and `slack` ignore bot/self messages.
322
+ - Missing text returns `200 { "action": "ignored", "reason": "no_text" }` so
323
+ webhook providers do not retry forever.
324
+
325
+ ### Deduplication
326
+
327
+ Each endpoint keeps an in-memory FIFO of the last ~500 seen provider
328
+ message ids via `markSeen()`. A duplicate returns `200 { "action":
329
+ "duplicate" }`.
330
+
331
+ ### MCP tools
332
+
333
+ ```json
334
+ {
335
+ "tool": "inbound_endpoint_create",
336
+ "arguments": {
337
+ "slug": "telegram-bot",
338
+ "provider": "telegram",
339
+ "alias": "agentpush",
340
+ "source": "@mybot",
341
+ "mode": "route-or-spawn",
342
+ "secret": "<optional>"
343
+ }
344
+ }
345
+ ```
346
+
347
+ `inbound_endpoint_list` returns endpoints without exposing the secret.
348
+ `inbound_endpoint_delete` removes by slug.
349
+
350
+ ## Follow-up: agentpush-side wiring (not covered by this repo)
351
+
352
+ This repo only adds the ingress endpoint. For the push path to actually
353
+ fire in production, agentpush itself needs to be configured to `POST` a
354
+ webhook payload to `<daemon-url>/inbound` (with the daemon's bearer
355
+ token) whenever it receives an inbound message, instead of relying solely
356
+ on `inbound_watcher_start`'s 5-second poll loop.