@agentproto/runtime 4.0.0 → 5.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.
@@ -185,12 +185,24 @@ interface DefaultsAdapterConfig {
185
185
  options?: Record<string, boolean | number | string>;
186
186
  auth?: DefaultsAdapterAuthConfig;
187
187
  contextContinuity?: ContextContinuityPolicy;
188
+ /** Capability bundle ids (`bundle_list`) auto-attached to spawns of this
189
+ * adapter — unioned with the global `defaults.bundles`. See
190
+ * {@link resolveBundleDefaults}. */
191
+ bundles?: string[];
192
+ /** Explicitly mount the daemon's own scoped `/mcp` for spawns of this
193
+ * adapter — the only way an adapter outside `shouldInjectDaemonSelfMount`'s
194
+ * allowlist (opencode, codex, gemini, …) gets it by default. Overridden
195
+ * per-spawn by `agent_start.daemonMount`. */
196
+ daemonMount?: boolean;
188
197
  }
189
198
  /** Shape of `config.json`'s top-level `defaults` block. */
190
199
  interface SpawnDefaultsConfig {
191
200
  skills?: string[];
192
201
  options?: Record<string, boolean | number | string>;
193
202
  adapters?: Record<string, DefaultsAdapterConfig>;
203
+ /** Capability bundle ids auto-attached to every spawn — see
204
+ * {@link DefaultsAdapterConfig.bundles} for the per-adapter union partner. */
205
+ bundles?: string[];
194
206
  contextContinuity?: ContextContinuityPolicy;
195
207
  /** Depth cutoff for the role-derived default (see `resolveRole` in
196
208
  * `role.ts`) applied when an `agent_start` call omits `role`:
@@ -40,6 +40,10 @@ interface UserPreset extends Partial<SessionConfig> {
40
40
  * `SpawnAgentSessionInput.skills`. Omitted means the adapter/defaults
41
41
  * decide. */
42
42
  skills?: string[];
43
+ /** Capability bundle ids (`bundle_list`) for a spawn from this preset — the
44
+ * same axis as `SpawnAgentSessionInput.bundles`. Omitted means the
45
+ * adapter/defaults decide. */
46
+ bundles?: string[];
43
47
  /** `agent_start.browser` for spawns from this preset. Ranks below the
44
48
  * role's own default (see `resolveBrowserMode`). */
45
49
  browser?: SpawnBrowserMode;
@@ -79,6 +83,7 @@ declare const userPresetsFileSchema: z.ZodObject<{
79
83
  contextProfile: z.ZodOptional<z.ZodString>;
80
84
  cwd: z.ZodOptional<z.ZodString>;
81
85
  skills: z.ZodOptional<z.ZodArray<z.ZodString>>;
86
+ bundles: z.ZodOptional<z.ZodArray<z.ZodString>>;
82
87
  browser: z.ZodOptional<z.ZodUnion<readonly [z.ZodLiteral<"headless">, z.ZodLiteral<false>]>>;
83
88
  }, z.core.$strip>>;
84
89
  }, z.core.$strip>;
@@ -30,6 +30,7 @@ var userPresetSchema = z.object({
30
30
  contextProfile: z.string().min(1).optional(),
31
31
  cwd: z.string().min(1).optional(),
32
32
  skills: z.array(z.string().min(1)).optional(),
33
+ bundles: z.array(z.string().min(1)).optional(),
33
34
  browser: z.union([z.literal("headless"), z.literal(false)]).optional()
34
35
  });
35
36
  var userPresetsFileSchema = z.object({
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/user-presets.ts"],"names":[],"mappings":"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","file":"user-presets.mjs","sourcesContent":["/**\n * User-owned spawn presets.\n *\n * This is deliberately separate from `preset-tools.ts`: provider presets are\n * static gateway definitions shipped by packages, while a UserPreset is a\n * private saved combination of the orthogonal session-config axes.\n */\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\"\nimport { homedir } from \"node:os\"\nimport { join, resolve } from \"node:path\"\nimport { z } from \"zod\"\nimport type {\n ContextProfile,\n EffortLevel,\n Posture,\n RouteSpec,\n SessionConfig,\n} from \"./session-config.js\"\nimport type { SpawnBrowserMode } from \"./browser-mount.js\"\n\nconst effortSchema = z.enum([\"low\", \"medium\", \"high\", \"xhigh\", \"max\", \"ultracode\"])\nconst postureSchema = z.union([\n z.enum([\"default\", \"plan\", \"accept-edits\", \"bypass\", \"read-only\"]),\n z.object({ harnessModeId: z.string().min(1) }),\n])\nconst routeSchema = z.object({\n gateway: z.string().min(1),\n baseUrl: z.string().url().optional(),\n})\n\n/** A reusable, user-scoped subset of the spawn/session config axes. */\nexport interface UserPreset extends Partial<SessionConfig> {\n /** Stable machine-local id, e.g. `fast-deepseek`. */\n id: string\n /** Human-readable name shown by CLI and editor pickers. */\n label: string\n /** Adapter harness to use. Omitted means the caller selects one. */\n adapter?: string\n /** Canonical harness slug — alias for `adapter`. */\n harness?: string\n model?: string\n route?: RouteSpec\n access?: { profileRef?: string }\n posture?: Posture\n effort?: EffortLevel\n contextProfile?: ContextProfile\n /** Working directory the favorite pins to. When set, a spawn from this\n * preset lands here regardless of the caller's active folder — the axis\n * that makes a favorite fully location-pinned (true zero-input). Omitted\n * means the caller's cwd ladder resolves it as before. */\n cwd?: string\n /** Skills to preload for a spawn from this preset — the same axis as\n * `SpawnAgentSessionInput.skills`. Omitted means the adapter/defaults\n * decide. */\n skills?: string[]\n /** `agent_start.browser` for spawns from this preset. Ranks below the\n * role's own default (see `resolveBrowserMode`). */\n browser?: SpawnBrowserMode\n}\n\nconst userPresetSchema = z.object({\n id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),\n label: z.string().min(1),\n adapter: z.string().min(1).optional(),\n harness: z.string().min(1).optional(),\n model: z.string().min(1).optional(),\n route: routeSchema.optional(),\n access: z.object({ profileRef: z.string().min(1).optional() }).optional(),\n posture: postureSchema.optional(),\n effort: effortSchema.optional(),\n contextProfile: z.string().min(1).optional(),\n cwd: z.string().min(1).optional(),\n skills: z.array(z.string().min(1)).optional(),\n browser: z.union([z.literal(\"headless\"), z.literal(false)]).optional(),\n}) satisfies z.ZodType<UserPreset>\n\nconst userPresetsFileSchema = z.object({\n version: z.literal(1),\n presets: z.array(userPresetSchema),\n})\n\nexport type UserPresetsFile = z.infer<typeof userPresetsFileSchema>\n\nfunction emptyFile(): UserPresetsFile {\n return { version: 1, presets: [] }\n}\n\nexport function userPresetsPath(): string {\n return resolve(homedir(), \".agentproto\", \"presets.json\")\n}\n\n/** Missing or malformed user config is treated as empty — a bad preset must\n * never prevent the daemon from starting. Writes always restore valid JSON. */\nexport async function loadUserPresets(): Promise<UserPresetsFile> {\n try {\n return userPresetsFileSchema.parse(JSON.parse(await readFile(userPresetsPath(), \"utf8\")))\n } catch {\n return emptyFile()\n }\n}\n\nasync function writeUserPresets(file: UserPresetsFile): Promise<void> {\n const dir = join(homedir(), \".agentproto\")\n await mkdir(dir, { recursive: true })\n await writeFile(userPresetsPath(), JSON.stringify(file, null, 2) + \"\\n\", {\n encoding: \"utf8\",\n mode: 0o600,\n })\n}\n\nexport async function listUserPresets(): Promise<UserPreset[]> {\n return (await loadUserPresets()).presets\n}\n\nexport async function getUserPreset(id: string): Promise<UserPreset | undefined> {\n return (await loadUserPresets()).presets.find(preset => preset.id === id)\n}\n\n/** Add or replace a preset by id. The parser makes this the single validation\n * boundary for CLI, MCP and editor callers. */\nexport async function saveUserPreset(preset: UserPreset): Promise<void> {\n const validated = userPresetSchema.parse(preset)\n const file = await loadUserPresets()\n const index = file.presets.findIndex(existing => existing.id === validated.id)\n if (index === -1) file.presets.push(validated)\n else file.presets[index] = validated\n await writeUserPresets(file)\n}\n\nexport async function deleteUserPreset(id: string): Promise<boolean> {\n const file = await loadUserPresets()\n const index = file.presets.findIndex(preset => preset.id === id)\n if (index === -1) return false\n file.presets.splice(index, 1)\n await writeUserPresets(file)\n return true\n}\n"]}
1
+ {"version":3,"sources":["../src/user-presets.ts"],"names":[],"mappings":"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","file":"user-presets.mjs","sourcesContent":["/**\n * User-owned spawn presets.\n *\n * This is deliberately separate from `preset-tools.ts`: provider presets are\n * static gateway definitions shipped by packages, while a UserPreset is a\n * private saved combination of the orthogonal session-config axes.\n */\n\nimport { mkdir, readFile, writeFile } from \"node:fs/promises\"\nimport { homedir } from \"node:os\"\nimport { join, resolve } from \"node:path\"\nimport { z } from \"zod\"\nimport type {\n ContextProfile,\n EffortLevel,\n Posture,\n RouteSpec,\n SessionConfig,\n} from \"./session-config.js\"\nimport type { SpawnBrowserMode } from \"./browser-mount.js\"\n\nconst effortSchema = z.enum([\"low\", \"medium\", \"high\", \"xhigh\", \"max\", \"ultracode\"])\nconst postureSchema = z.union([\n z.enum([\"default\", \"plan\", \"accept-edits\", \"bypass\", \"read-only\"]),\n z.object({ harnessModeId: z.string().min(1) }),\n])\nconst routeSchema = z.object({\n gateway: z.string().min(1),\n baseUrl: z.string().url().optional(),\n})\n\n/** A reusable, user-scoped subset of the spawn/session config axes. */\nexport interface UserPreset extends Partial<SessionConfig> {\n /** Stable machine-local id, e.g. `fast-deepseek`. */\n id: string\n /** Human-readable name shown by CLI and editor pickers. */\n label: string\n /** Adapter harness to use. Omitted means the caller selects one. */\n adapter?: string\n /** Canonical harness slug — alias for `adapter`. */\n harness?: string\n model?: string\n route?: RouteSpec\n access?: { profileRef?: string }\n posture?: Posture\n effort?: EffortLevel\n contextProfile?: ContextProfile\n /** Working directory the favorite pins to. When set, a spawn from this\n * preset lands here regardless of the caller's active folder — the axis\n * that makes a favorite fully location-pinned (true zero-input). Omitted\n * means the caller's cwd ladder resolves it as before. */\n cwd?: string\n /** Skills to preload for a spawn from this preset — the same axis as\n * `SpawnAgentSessionInput.skills`. Omitted means the adapter/defaults\n * decide. */\n skills?: string[]\n /** Capability bundle ids (`bundle_list`) for a spawn from this preset — the\n * same axis as `SpawnAgentSessionInput.bundles`. Omitted means the\n * adapter/defaults decide. */\n bundles?: string[]\n /** `agent_start.browser` for spawns from this preset. Ranks below the\n * role's own default (see `resolveBrowserMode`). */\n browser?: SpawnBrowserMode\n}\n\nconst userPresetSchema = z.object({\n id: z.string().regex(/^[a-z0-9][a-z0-9-]*$/),\n label: z.string().min(1),\n adapter: z.string().min(1).optional(),\n harness: z.string().min(1).optional(),\n model: z.string().min(1).optional(),\n route: routeSchema.optional(),\n access: z.object({ profileRef: z.string().min(1).optional() }).optional(),\n posture: postureSchema.optional(),\n effort: effortSchema.optional(),\n contextProfile: z.string().min(1).optional(),\n cwd: z.string().min(1).optional(),\n skills: z.array(z.string().min(1)).optional(),\n bundles: z.array(z.string().min(1)).optional(),\n browser: z.union([z.literal(\"headless\"), z.literal(false)]).optional(),\n}) satisfies z.ZodType<UserPreset>\n\nconst userPresetsFileSchema = z.object({\n version: z.literal(1),\n presets: z.array(userPresetSchema),\n})\n\nexport type UserPresetsFile = z.infer<typeof userPresetsFileSchema>\n\nfunction emptyFile(): UserPresetsFile {\n return { version: 1, presets: [] }\n}\n\nexport function userPresetsPath(): string {\n return resolve(homedir(), \".agentproto\", \"presets.json\")\n}\n\n/** Missing or malformed user config is treated as empty — a bad preset must\n * never prevent the daemon from starting. Writes always restore valid JSON. */\nexport async function loadUserPresets(): Promise<UserPresetsFile> {\n try {\n return userPresetsFileSchema.parse(JSON.parse(await readFile(userPresetsPath(), \"utf8\")))\n } catch {\n return emptyFile()\n }\n}\n\nasync function writeUserPresets(file: UserPresetsFile): Promise<void> {\n const dir = join(homedir(), \".agentproto\")\n await mkdir(dir, { recursive: true })\n await writeFile(userPresetsPath(), JSON.stringify(file, null, 2) + \"\\n\", {\n encoding: \"utf8\",\n mode: 0o600,\n })\n}\n\nexport async function listUserPresets(): Promise<UserPreset[]> {\n return (await loadUserPresets()).presets\n}\n\nexport async function getUserPreset(id: string): Promise<UserPreset | undefined> {\n return (await loadUserPresets()).presets.find(preset => preset.id === id)\n}\n\n/** Add or replace a preset by id. The parser makes this the single validation\n * boundary for CLI, MCP and editor callers. */\nexport async function saveUserPreset(preset: UserPreset): Promise<void> {\n const validated = userPresetSchema.parse(preset)\n const file = await loadUserPresets()\n const index = file.presets.findIndex(existing => existing.id === validated.id)\n if (index === -1) file.presets.push(validated)\n else file.presets[index] = validated\n await writeUserPresets(file)\n}\n\nexport async function deleteUserPreset(id: string): Promise<boolean> {\n const file = await loadUserPresets()\n const index = file.presets.findIndex(preset => preset.id === id)\n if (index === -1) return false\n file.presets.splice(index, 1)\n await writeUserPresets(file)\n return true\n}\n"]}
@@ -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.