manychat-ai-agent 0.19.1 → 0.20.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.20.0](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.19.1...v0.20.0) (2026-10-07)
4
+
5
+
6
+ ### Features
7
+
8
+ * let a plugin read data the model answers from (spec 039) ([190c43a](https://github.com/pedronastasi/manychat-ai-agent/commit/190c43adec2aca7f499ed737c8c60bd93a36a274))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * keep a turn's read record when its model call fails (spec 039) ([11b8cc2](https://github.com/pedronastasi/manychat-ai-agent/commit/11b8cc2d44d4a138239ba14542d7af2f0969acb7))
14
+ * read nothing on a turn already abandoned (spec 039) ([0ae0151](https://github.com/pedronastasi/manychat-ai-agent/commit/0ae0151c9c7161fdd337c760f0e425c1509a18e9))
15
+
16
+
17
+ ### Specs and Docs
18
+
19
+ * have spec 042's upgrade find tenant eval suites without EVAL_DIR ([660428f](https://github.com/pedronastasi/manychat-ai-agent/commit/660428f4973bd128182d23b6736f3e010bc8ee73))
20
+ * leave learning-from-outcomes.md as it was ([06a8a58](https://github.com/pedronastasi/manychat-ai-agent/commit/06a8a5844c393469f35afa7e68f31d0390ff9f2a))
21
+ * list every spec 042 amends, and migrate eval case keys ([0ebfe99](https://github.com/pedronastasi/manychat-ai-agent/commit/0ebfe99cace2daee9ed179c69defc8001bba00cc))
22
+ * name the learning tables and the mock's schedule answer in spec 042 ([15ed5cf](https://github.com/pedronastasi/manychat-ai-agent/commit/15ed5cf061e9d83ced70ab9693785b0c68b45a64))
23
+ * record PR 195 in spec 040 ([139d20d](https://github.com/pedronastasi/manychat-ai-agent/commit/139d20d0fdce45e4264f3a20d832861b7fcf5cf9))
24
+ * record that built-in tools are not plugins (ADR-0022) ([666cd36](https://github.com/pedronastasi/manychat-ai-agent/commit/666cd3630c0d4b87b23a2ed0176af257244b7d61))
25
+ * record that built-in tools are not plugins (ADR-0022) ([5ae8aac](https://github.com/pedronastasi/manychat-ai-agent/commit/5ae8aacc8587bb86899e4a51240cdef7b59ad144))
26
+ * refuse an invalid derived tool name and extend the C9 check in spec 040 ([0a32aab](https://github.com/pedronastasi/manychat-ai-agent/commit/0a32aab1f8783c8d5279fb989203172afca9f48a))
27
+ * restore the funnel's config link and point the ADR skill at the new table ([b7961ad](https://github.com/pedronastasi/manychat-ai-agent/commit/b7961adb5595d67f112ec4025b0726657538abd7))
28
+ * say where ADR-0022's reserved built-in names come from ([478b7ef](https://github.com/pedronastasi/manychat-ai-agent/commit/478b7ef83e17e00d8c2bcbc92a9afafab416e5b0))
29
+ * specify agent plugin new, which starts a plugin from the agent's example ([487baef](https://github.com/pedronastasi/manychat-ai-agent/commit/487baef2555313d5ad0f80db2ac50cd0b57de0c9))
30
+ * specify agent plugin new, which starts a plugin from the agent's example ([476c23c](https://github.com/pedronastasi/manychat-ai-agent/commit/476c23cc904b1b8a21170e4ac139d42fd8df5dcc))
31
+ * specify spec 042, the sales layer sells offerings, not courses ([83cb006](https://github.com/pedronastasi/manychat-ai-agent/commit/83cb006a41feba53f072f5ad021789266a2e40bf))
32
+ * specify spec 042, the sales layer sells offerings, not courses ([4446a6d](https://github.com/pedronastasi/manychat-ai-agent/commit/4446a6d85a602b30619c9524465077f27b953d73))
33
+ * specify the README as a landing page (spec 040) ([bd16a36](https://github.com/pedronastasi/manychat-ai-agent/commit/bd16a3623ddee199c72d69250cccec8120e66212))
34
+ * split the README into a landing page and four guides ([02beda6](https://github.com/pedronastasi/manychat-ai-agent/commit/02beda65af96c0f78abf0884b311bcd60ec113e8))
35
+ * split the README into a landing page and four guides ([b09719e](https://github.com/pedronastasi/manychat-ai-agent/commit/b09719e4f3c5726015c0283dd2244ee52d68ba28))
36
+
3
37
  ## [0.19.1](https://github.com/pedronastasi/manychat-ai-agent/compare/v0.19.0...v0.19.1) (2026-10-07)
4
38
 
5
39
 
package/README.md CHANGED
@@ -74,113 +74,49 @@ The conversation loop lives in this service rather than in ManyChat's visual flo
74
74
  builder, via Dynamic Block's `external_message_callback`. That is what keeps the
75
75
  agent portable instead of welded to one vendor's UI.
76
76
 
77
- ## Design decisions
78
-
79
- Every non-obvious choice is written down in [`docs/adr/`](docs/adr/):
80
-
81
- | ADR | Decision |
82
- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
83
- | [0001](docs/adr/0001-hybrid-race-reply-path.md) | Race the model against a deadline instead of choosing sync or async |
84
- | [0002](docs/adr/0002-provider-agnostic-model-layer.md) | One registry module may import providers; everything else uses a port (superseded) |
85
- | [0003](docs/adr/0003-contract-first-with-zod.md) | Zod schemas generate both the types and the OpenAPI document (superseded) |
86
- | [0004](docs/adr/0004-postgres-outbox-over-redis.md) | A Postgres outbox, not Redis — one datastore, transactional enqueue |
87
- | [0005](docs/adr/0005-channel-port-single-adapter.md) | Define the channel port, ship exactly one adapter |
88
- | [0006](docs/adr/0006-manychat-auth-risk-accepted.md) | ManyChat does not sign webhooks; the compensating controls, written down (superseded) |
89
- | [0007](docs/adr/0007-generateobject-not-toolloop.md) | `generateObject`, not a tool loop, while there are no tools (superseded) |
90
- | [0008](docs/adr/0008-classes-for-port-implementations.md) | Ports are implemented by classes; functions stay for pure transformation |
91
- | [0009](docs/adr/0009-no-single-letter-identifiers.md) | Identifiers are at least two characters, enforced by lint |
92
- | [0010](docs/adr/0010-bounded-tool-loop-with-staged-actions.md) | A two-step tool loop whose tools stage actions; the server performs them after guardrails |
93
- | [0011](docs/adr/0011-model-graded-evals-behind-calibration.md) | A judge model grades evals only in a run where it first agrees with hand-labelled calibration |
94
- | [0012](docs/adr/0012-contact-tokens-held-in-manychat.md) | Each contact has a token held by ManyChat; only a request carrying it reads that contact's history |
95
- | [0013](docs/adr/0013-history-spans-30-days-turn-cap-resets-daily.md) | History reaches back 30 days; the turn cap resets after 24 hours of silence |
96
- | [0014](docs/adr/0014-log-conversation-id-not-pseudonym.md) | Logs identify a contact by the conversation's random ID, not a hash of the subscriber ID |
97
- | [0015](docs/adr/0015-the-agent-closes-the-sale.md) | The agent takes a lead to the payment link itself; every C6 limit on what it may claim stays |
98
- | [0016](docs/adr/0016-reads-are-performed-inside-the-loop.md) | Read tools are performed when called and the loop grows to four steps; writes are still staged |
99
- | [0017](docs/adr/0017-bounded-free-text-notes.md) | Free text only in declared note fields no flow renders, length-capped and stripped of identifiers |
100
- | [0018](docs/adr/0018-port-to-python-beside-typescript-on-one-database.md) | Port to Python on LangChain, LangGraph, FastAPI and Pydantic, cut over beside TypeScript on one database |
101
- | [0019](docs/adr/0019-flows-are-sent-inside-the-loop.md) | On an inbound turn a flow is sent when the model calls it, so the reply follows it; other writes stage |
102
- | [0020](docs/adr/0020-learning-is-offline-and-human-approved.md) | Learning is offline, from paid outcomes; a tactic reaches the prompt only after approval and an eval |
103
- | [0021](docs/adr/0021-tenants-depend-on-a-package-not-a-fork.md) | A tenant depends on one published package and starts from a scaffolder; nobody forks to deploy |
104
-
105
- Behavior is specified before it is implemented, in [`specs/`](specs/) —
106
- a [constitution](specs/000-constitution.md) of non-negotiables, the
107
- [agent's behavior](specs/001-agent-behavior.md), and the
108
- [channel contract](specs/002-channel-contract.md). Tests cite the clause they
109
- enforce.
110
-
111
- ## Model agnosticism
112
-
113
- No module imports a provider package except
114
- [`src/agent/registry.ts`](src/agent/registry.ts). The active model is a string:
115
-
116
- ```bash
117
- AGENT_MODEL=anthropic:claude-haiku-4-5 # default
118
- AGENT_MODEL=openai:gpt-5-mini # same behavior, zero code changed
119
- AGENT_MODEL=google:gemini-2.5-flash
120
- AGENT_MODEL=ollama:llama3.1:8b # local, free — see "Local model" below
121
- AGENT_MODEL=mock:demo # offline, deterministic, free
122
- ```
123
-
124
- Callers depend on an `AgentRunner` port, not on the AI SDK, so replacing even the
125
- SDK is contained to one file.
126
-
127
- ## What makes it production-shaped
128
-
129
- **Fails closed, toward a human.** Low model confidence, a schema violation, an
130
- exhausted budget, a rate limit, or a provider outage all escalate. The agent
131
- never invents a price — an unanswerable question is a handoff, by design.
132
-
133
- **Untrusted input, treated as such.** Contact text is fenced and labelled as
134
- data, with the fence markers stripped first so they cannot be forged. Model
135
- output is validated before any of it reaches a customer, and replies that leak
136
- the prompt are dropped. The golden set includes injection attempts.
137
-
138
- **A contact's history is theirs, not whoever names them.** The shared secret
139
- proves a request came from the flow, not which contact it speaks for. Each
140
- contact has a random token kept in their own ManyChat custom field and stored
141
- here only as a hash. A request without it is answered from its own message
142
- alone, so a leaked secret does not expose anyone's conversation
143
- ([spec 019](specs/019-contact-tokens.md)).
144
-
145
- **Spend is bounded.** Every turn records tokens, cache hits, and estimated cost.
146
- Daily token and dollar caps degrade to escalation rather than to an error — a
147
- bill cap that fails into a human is the correct failure mode for a business.
148
-
149
- **Channel constraints are data, not memory.** Quick replies silently do nothing
150
- on WhatsApp; ManyChat accepts them and the contact never sees them. That lives in
151
- a `ChannelCapabilities` value with a test, not in a comment someone forgets.
152
-
153
- **No PII in logs.** Redaction is configured at the logger, so a new log statement
154
- cannot opt out. Telemetry spans omit prompts by default. Log lines name a
155
- conversation by its random ID, never by anything derived from the subscriber.
156
-
157
- **An eval harness.** `pnpm eval` replays a labelled golden set through the current
158
- prompt and asserts escalation behavior, price grounding, prompt leakage, and
159
- latency — then prints every reply, because a green suite whose tone has drifted
160
- is still a failure and only a person can see that.
161
-
162
- ## Layout
163
-
164
- ```
165
- specs/ behavior specified before implementation
166
- docs/adr/ why each decision was made
167
- src/
168
- contracts/ Zod schemas — the single source of truth
169
- agent/ registry (the only provider import), runner, prompt, guardrails
170
- channels/ the port, plus the ManyChat adapter and a local simulator
171
- conversation/ store, rate limits, budget caps
172
- media/ voice notes, images and videos: resolver and ffmpeg splitter
173
- outbox/ deferred delivery
174
- routes/ auth, the turn handler with the race
175
- evals/ golden set + runner
176
- packages/create/ `npm create manychat-ai-agent`: scaffolds a tenant project
177
- test/ unit and integration (PGlite: real Postgres, no container)
178
- ```
77
+ The full design — what fails closed, how spend is bounded, and every decision
78
+ record — is in [How it works](docs/guides/how-it-works.md).
79
+
80
+ ## What it can do
81
+
82
+ - **Answer from the catalog, or hand off.** Every fact it may state lives in
83
+ `config/catalog.json`; anything else escalates to a person rather than being
84
+ guessed ([spec 001](specs/001-agent-behavior.md)).
85
+ - **Run on any model.** The model is one environment variable —
86
+ `anthropic:claude-haiku-4-5`, `openai:gpt-5-mini`, `ollama:llama3.1:8b`, or the
87
+ offline `mock:demo` — and only [`src/agent/registry.ts`](src/agent/registry.ts)
88
+ imports a provider.
89
+ - **Keep each contact's history theirs.** A token held in the contact's own
90
+ ManyChat field, not the shared secret, unlocks their conversation
91
+ ([spec 019](specs/019-contact-tokens.md)).
92
+ - **Read voice notes, images and videos** sent on WhatsApp
93
+ ([spec 020](specs/020-inbound-media.md)).
94
+ - **Act in ManyChat and sell.** Optionally send flows, set tags and fields, read
95
+ the contact, schedule a follow-up, and take a lead to the payment link
96
+ ([spec 012](specs/012-agent-tools.md), [spec 023](specs/023-sales-funnel.md)).
97
+ - **Learn from outcomes**, offline and only after a person approves
98
+ ([spec 031](specs/031-learning-from-outcomes.md)).
99
+
100
+ ## Documentation
101
+
102
+ The same pages, with search, are on the
103
+ [docs site](https://pedronastasi.github.io/manychat-ai-agent/).
104
+
105
+ | To… | Read |
106
+ | -------------------------------------------- | ----------------------------------------------------------------------------------- |
107
+ | Deploy an agent for your own business | [Starting a new agent](docs/guides/getting-started.md) |
108
+ | Try it from a clone, with curl or Docker | [Running it locally](docs/guides/running-locally.md) |
109
+ | Point ManyChat at it | [Connecting ManyChat](docs/guides/connecting-manychat.md) |
110
+ | Turn on tools, follow-ups or the funnel | [Agent tools and the sales funnel](docs/guides/agent-tools-and-the-sales-funnel.md) |
111
+ | Write the tenant's prompt, catalog and rules | [Tenant configuration](config/README.md) |
112
+ | Understand the design | [How it works](docs/guides/how-it-works.md) and the [decision records](docs/adr/) |
113
+ | Know exactly what it must do | The [specs](specs/), starting from the [constitution](specs/000-constitution.md) |
114
+ | Contribute | [CONTRIBUTING](CONTRIBUTING.md) |
179
115
 
180
116
  ## Development
181
117
 
182
118
  ```bash
183
- pnpm bootstrap # local config + .env from the committed examples
119
+ pnpm bootstrap # local config + .env from the committed examples
184
120
  pnpm dev # offline by default: mock model, embedded Postgres
185
121
  pnpm simulate "…" # send a Dynamic Block request as ManyChat would
186
122
  pnpm test # unit + integration
@@ -188,457 +124,6 @@ pnpm eval:mock # golden set, offline and free
188
124
  pnpm lint && pnpm typecheck
189
125
  ```
190
126
 
191
- Integration tests run against **PGlite** — real Postgres compiled to WASM, in
192
- process — so `FOR UPDATE SKIP LOCKED`, upserts and constraints behave as in
193
- production with no container to start in CI.
194
-
195
- ### Local model
196
-
197
- Run a real model locally via [Ollama](https://ollama.com) — free, no API key,
198
- useful for testing the model boundary (schema adherence, latency, judgment)
199
- without incurring API costs.
200
-
201
- ```bash
202
- docker compose --profile local-model up # starts Ollama alongside Postgres
203
- AGENT_MODEL=ollama:llama3.1:8b pnpm simulate "hello"
204
- ```
205
-
206
- The first run pulls ~4.7 GB of weights; they persist in a named Docker volume so
207
- subsequent starts are instant. Any Ollama-supported model works — just use the
208
- tag from `ollama list`:
209
-
210
- ```bash
211
- AGENT_MODEL=ollama:gemma3:4b pnpm simulate "how much does the course cost?"
212
- ```
213
-
214
- Inside Compose the agent reaches Ollama at `http://ollama:11434/v1`. Running
215
- locally (outside Docker), it defaults to `http://localhost:11434/v1`; override
216
- with `OLLAMA_BASE_URL` if needed.
217
-
218
- Ollama models price at zero, so the daily dollar cap never fires. The token cap
219
- still applies and guards against runaway loops. The 8 s race deadline means the
220
- deferred path (acknowledge now, push the real answer later) becomes the default
221
- for slower local models — that is by design.
222
-
223
- ## Running the full stack with Docker
224
-
225
- Everything below uses the **mock model** (free, deterministic, no API key). To
226
- use a real cloud model, set `AGENT_MODEL` and the matching API key in `.env`
227
- before step 3.
228
-
229
- ### 1. Clone and bootstrap
230
-
231
- ```bash
232
- git clone https://github.com/pedronastasi/manychat-ai-agent.git
233
- cd manychat-ai-agent
234
- pnpm install && pnpm bootstrap
235
- ```
236
-
237
- `pnpm bootstrap` copies the example configs and `.env`. Everything in `config/`
238
- is gitignored, so tenant data never reaches version control.
239
-
240
- ### 2. Set a shared secret
241
-
242
- Open `.env` and replace the placeholder `MANYCHAT_SHARED_SECRET` with a real
243
- value — any string of at least 16 characters. This is the bearer token you will
244
- use in curl:
245
-
246
- ```bash
247
- # generate one if you like
248
- openssl rand -hex 32
249
- ```
250
-
251
- ### 3. Start the containers
252
-
253
- ```bash
254
- docker compose up --build
255
- ```
256
-
257
- This starts Postgres 18 and the agent. Wait for the log line:
258
-
259
- ```
260
- {"level":30,"msg":"Server listening at http://0.0.0.0:3000"}
261
- ```
262
-
263
- ### 4. Chat with the agent
264
-
265
- The agent exposes one endpoint: `POST /v1/channels/manychat/message`. It expects
266
- a ManyChat Dynamic Block payload — a JSON body with at least `subscriber_id` and
267
- `text` — and an `Authorization: Bearer <secret>` header.
268
-
269
- **Ask about course prices:**
270
-
271
- ```bash
272
- curl -s http://localhost:3000/v1/channels/manychat/message \
273
- -H 'Content-Type: application/json' \
274
- -H 'Authorization: Bearer <your-secret-from-.env>' \
275
- -d '{"subscriber_id": "test-001", "text": "how much is the foundation course?"}' \
276
- | jq .
277
- ```
278
-
279
- The response is a [Dynamic Block v2](https://manychat.github.io/dynamic_block_docs/)
280
- object. The agent's reply is in `.content.messages[].text`:
281
-
282
- ```json
283
- {
284
- "version": "v2",
285
- "content": {
286
- "messages": [
287
- { "type": "text", "text": "The Foundation Course is $450.00." },
288
- { "type": "text", "text": "It runs 24 hours total. Want the enrolment link?" }
289
- ],
290
- "external_message_callback": {
291
- "url": "https://agent.example.com/v1/channels/manychat/message",
292
- "method": "post",
293
- "headers": { "Authorization": "Bearer ..." },
294
- "payload": {
295
- "text": "{{last_input_text}}",
296
- "subscriber_id": "{{contact.id}}",
297
- "ai_token": "{{ai_token}}"
298
- },
299
- "timeout": 86400
300
- }
301
- }
302
- }
303
- ```
304
-
305
- The `external_message_callback` is what keeps the conversation alive: ManyChat
306
- sends the contact's next message back here, rather than falling through to its
307
- own flow, with their token filled in from their custom field.
308
-
309
- **Continue the conversation** (same `subscriber_id`):
310
-
311
- ```bash
312
- curl -s http://localhost:3000/v1/channels/manychat/message \
313
- -H 'Content-Type: application/json' \
314
- -H 'Authorization: Bearer <your-secret-from-.env>' \
315
- -d '{"subscriber_id": "test-001", "text": "yes, send it"}' \
316
- | jq .content.messages
317
- ```
318
-
319
- This remembers the first question because `pnpm bootstrap` sets
320
- `CONTACT_TOKENS_ENFORCED=false`. Locally there is no ManyChat field to hold the
321
- contact's token, so curl has none to send. With it `true`, as in production, a
322
- request without the token is answered from its own message alone.
323
-
324
- **Trigger an escalation:**
325
-
326
- ```bash
327
- curl -s http://localhost:3000/v1/channels/manychat/message \
328
- -H 'Content-Type: application/json' \
329
- -H 'Authorization: Bearer <your-secret-from-.env>' \
330
- -d '{"subscriber_id": "test-001", "text": "I want to speak to a human"}' \
331
- | jq .content.messages
332
- ```
333
-
334
- **Healthcheck:**
335
-
336
- ```bash
337
- curl http://localhost:3000/health
338
- curl http://localhost:3000/ready
339
- ```
340
-
341
- ### 5. (Optional) Use a local model
342
-
343
- To test with a real model for free, add Ollama:
344
-
345
- ```bash
346
- docker compose --profile local-model up --build
347
- ```
348
-
349
- Then set `AGENT_MODEL=ollama:llama3.1:8b` in `.env` and restart the agent
350
- container (`docker compose up --build agent`). The first run pulls ~4.7 GB;
351
- subsequent starts are instant.
352
-
353
- With a local model, responses typically take 9–120 s, so the **deferred path**
354
- fires on most turns: the agent sends an acknowledgement within 8 s, and the full
355
- reply arrives later via the outbox worker.
356
-
357
- ### Using `pnpm simulate` instead of curl
358
-
359
- If you prefer running outside Docker (`pnpm dev`), the simulator wraps the same
360
- request and formats the output:
361
-
362
- ```bash
363
- pnpm simulate "how much is the foundation course?"
364
- pnpm simulate --subscriber 42 "can you give me a discount?"
365
- ```
366
-
367
- ## Deployment
368
-
369
- A deployment is a tenant project that depends on this package, never a fork of
370
- this repository (ADR-0021). Generate one from the fictional demo tenant:
371
-
372
- ```bash
373
- npm create manychat-ai-agent@latest my-agent
374
- ```
375
-
376
- It writes `config/`, an offline `.env`, an eval suite, CI, Compose and
377
- Renovate, depending on the agent and its image at the scaffolder's version.
378
- Replace `config/` with your own and keep the repository private: the generated
379
- CI fails when it is public
380
- ([specs/035](specs/035-create-scaffolds-a-tenant-project.md)). The steps are in
381
- [Starting a new agent](docs/guides/getting-started.md).
382
-
383
- Point a ManyChat **Dynamic Block** (Dev Tools, requires a Pro plan) at
384
- `POST /v1/channels/manychat/message` and add an `Authorization: Bearer <secret>`
385
- header matching `MANYCHAT_SHARED_SECRET`. The OpenAPI document is generated from
386
- the same schemas that validate at runtime.
387
-
388
- Create a Text custom field named `ai_token` (or whatever `MANYCHAT_TOKEN_FIELD`
389
- names), then send it as a top-level key of the Dynamic Block's request body,
390
- next to the keys you already send:
391
-
392
- ```json
393
- {
394
- "subscriber_id": "{{contact.id}}",
395
- "text": "{{last_input_text}}",
396
- "ai_token": "{{ai_token}}"
397
- }
398
- ```
399
-
400
- Insert the value with ManyChat's variable picker rather than typing it, so it
401
- points at the field. The key is always `ai_token`; any key the service does not
402
- know is refused with a 400. Nothing else needs configuring: the callback the
403
- service registers asks for the same field.
404
-
405
- ### Contact tokens
406
-
407
- ```mermaid
408
- sequenceDiagram
409
- autonumber
410
- participant C as Contact
411
- participant M as ManyChat
412
- participant A as Agent
413
- participant F as Caller with the secret only
414
-
415
- C->>M: First message
416
- M->>A: Dynamic Block body, ai_token empty
417
- Note over A: No token yet: issue one and store only its SHA-256 hash
418
- A-)M: setCustomFieldByName writes the token to the contact's field
419
- A-->>M: Reply, with a callback that asks for ai_token
420
- M->>C: Reply
421
- C->>M: Next message
422
- M->>A: Callback body, ai_token filled in from the field
423
- Note over A: Hash matches: bound, reads the contact's history
424
- A-->>M: Reply built on the conversation so far
425
- F->>A: Same subscriber_id, no token or a guessed one
426
- Note over A: Unbound: no history read, turn stored but kept out of it
427
- A-->>F: Reply from that message alone
428
- ```
429
-
430
- - **The token never appears in a response or a log line.** It reaches the
431
- contact's field only through ManyChat's API, so the only way to present it is
432
- to be the contact ManyChat sends it for.
433
- - **An unbound request repairs itself.** It also writes a fresh token to the
434
- contact's field, at most once an hour. That fixes a cleared field or a failed
435
- write, and a forger triggering it gains nothing, because the token goes only
436
- to the real contact.
437
- - **The previous token stays valid** until the next one is issued, so a message
438
- sent while a new token is being written still binds.
439
- - **A failed write is retried by the outbox worker** with a fresh token. The
440
- token itself is stored nowhere but the contact's field.
441
-
442
- Turning tokens on for a deployment that already has contacts takes a flag and a
443
- backfill, in that order, or everyone loses their context at once. The five steps
444
- are in
445
- [spec 019](specs/019-contact-tokens.md#tokens-reach-existing-contacts-before-they-are-required);
446
- `pnpm tokens:backfill` (`node dist/backfill.js` in the image) is step 2, and its
447
- `--check` is step 4.
448
-
449
- ### Voice notes, images and videos
450
-
451
- WhatsApp media reaches the service as a link in `text`. The service downloads
452
- the file itself, and the link is never stored, logged or shown to the model
453
- ([spec 020](specs/020-inbound-media.md)):
454
-
455
- - **A voice note** is transcribed by `TRANSCRIPTION_MODEL` and then treated as
456
- typed text, escalation keywords included.
457
- - **An image** goes to the answering model once, as bytes. History keeps
458
- `[image]` in its place.
459
- - **A video** is split by ffmpeg, which is part of the image, into up to four
460
- frames and a transcript of its soundtrack.
461
-
462
- Whatever it cannot read gets `rules.messages.mediaFallback`, which asks the
463
- contact to type. Without that message, the turn hands off to a person. A failed
464
- download or transcription always hands off. The boot log's `media
465
- capabilities` line shows what this server can read.
466
-
467
- ### Agent tools
468
-
469
- With an optional `config/tools.json`, the agent can also act on the contact in
470
- ManyChat: send one of the tenant's flows, add or remove a tag, or record one of a
471
- field's allowed values ([spec 012](specs/012-agent-tools.md)). It can also read
472
- what is recorded on the contact and write short notes for the team
473
- ([spec 024](specs/024-contact-read-and-notes.md)).
474
-
475
- A turn where the contact asks for something to read, answered inside the
476
- deadline ([spec 029](specs/029-flows-before-the-reply.md)):
477
-
478
- ```mermaid
479
- sequenceDiagram
480
- autonumber
481
- participant C as Contact
482
- participant M as ManyChat
483
- participant A as Agent
484
- participant L as Model
485
-
486
- C->>M: "Can you send me something to read?"
487
- M->>A: Dynamic Block request
488
- A->>L: Step 1, tools offered
489
- L-->>A: Calls send_flow foundation_brochure
490
- A->>M: sendFlow, during the turn
491
- M->>C: The brochure flow
492
- A->>L: Result: sent. Last step, no tools
493
- L-->>A: Reply that follows the brochure, question last
494
- Note over A: Guardrails run. A handoff cannot recall the flow
495
- Note over A: Waits for the flow's settleSeconds (spec 030)
496
- A-->>M: Dynamic Block response with the reply
497
- M->>C: Reply, then its closing question
498
- Note over A: turns.actions records the flow as performed
499
- ```
500
-
501
- When the model misses the deadline, the contact gets the holding message; the
502
- flow has already gone out, and the outbox worker delivers the reply after it.
503
-
504
- - **A flow goes out when the model sends it** (ADR-0019). The model is told
505
- whether ManyChat accepted it and writes its reply after, so the contact reads
506
- the flow, then the reply, then its question. A handoff later in the turn
507
- cannot recall it. A payment link takes the turn's stage writes with it, so
508
- `link_sent` is the last stage ManyChat is given. A nudge turn keeps its flows
509
- staged.
510
- - **Every other tool call only stages the action.** Tags, fields, notes and
511
- nudges reach ManyChat after the reply has gone out, either as the Dynamic
512
- Block response or through the outbox, and a turn that ends in a handoff
513
- discards them. A turn has at most four model steps and eight actions, and
514
- each action gets one attempt.
515
- - **The model only picks from the config.** It names entries by `id`, never a
516
- ManyChat name or free text, and every action lands on the contact whose
517
- message it is answering.
518
- - **Every action is recorded** on its turn with what became of it, and the next
519
- turn's history tells the model what it already sent.
520
-
521
- #### Reading the contact and writing notes
522
-
523
- `get_contact` is the one tool performed while the model runs (ADR-0016): reading
524
- changes nothing on the contact, so there is nothing for a handoff to undo.
525
- `write_note` stages free text like any other write (ADR-0017).
526
-
527
- - **A read returns a whitelist.** The client parses ManyChat's `getInfo` down to
528
- tag names and field values, so the contact's name, phone and email never
529
- reach the model. It returns only the tags, fields and notes `tools.json`
530
- lists, by id. A field holding a value outside its list reads as `other`.
531
- - **Notes come back fenced.** A note summarises the contact's words, so it is
532
- returned inside the same fence as their messages and treated as data (C4).
533
- - **A read never blocks the reply.** It gives up after 1.5 seconds and returns
534
- `{ available: false }`, and the turn goes on. A turn reads at most twice, and
535
- only when it carries the contact's token (specs/019).
536
- - **Notes are bounded and cleaned.** A note field must be declared
537
- `"neverRendered": true` and capped at 500 characters. Links, emails, phone
538
- numbers and long numbers are replaced by `[removed]` before it is staged. The
539
- turn record holds the note's length, never its text, and so do the logs.
540
- - **A handoff summary survives the handoff.** A note marked `onEscalation` is
541
- still written after the escalation message, but only when the model or the
542
- confidence threshold escalated. On a leak, an invalid reply or a failed call
543
- it is discarded with everything else.
544
-
545
- ```mermaid
546
- sequenceDiagram
547
- autonumber
548
- participant M as ManyChat
549
- participant A as Agent
550
- participant L as Model
551
-
552
- A->>L: Step 1, tools offered
553
- L-->>A: Calls get_contact
554
- A->>M: getInfo, 1.5 s timeout
555
- M-->>A: Whole subscriber record
556
- Note over A: Cut to configured ids, notes fenced
557
- A-->>L: { tags, fields, notes }
558
- L-->>A: Calls write_note handoff_summary, escalates
559
- Note over A: Guardrails: a model escalation keeps onEscalation notes
560
- A-->>M: Escalation message
561
- A-)M: setCustomFieldByName for the note, after the message
562
- Note over A: turns.actions records write_note with its length only
563
- ```
564
-
565
- #### Following up on a quiet contact
566
-
567
- With a `nudge` section, the agent can also schedule one follow-up for a contact
568
- who goes quiet ([spec 025](specs/025-in-window-nudge.md)). A worker checks at
569
- due time that the contact has not written, no person has taken over, the sale
570
- is not closed and WhatsApp's 24-hour window is still open. Only then does it
571
- run a model turn on a system note in place of a message. The model may decline,
572
- and then nothing is sent. Otherwise the follow-up goes out through the outbox,
573
- and it never schedules another.
574
-
575
- How to configure tools and follow-ups, read the record, and what to check before
576
- enabling them is in
577
- [config/README.md](config/README.md#toolsjson-actions-the-agent-can-take-optional).
578
-
579
- ### Sales funnel
580
-
581
- With a funnel field and a payment-link flow in `tools.json`, the agent takes a
582
- new lead from their first reply to the payment link instead of only answering
583
- questions ([spec 023](specs/023-sales-funnel.md),
584
- [ADR-0015](docs/adr/0015-the-agent-closes-the-sale.md)). It replaces a drip
585
- sequence: each piece the drip used to send on a timer becomes a flow the agent
586
- sends when the conversation calls for it.
587
-
588
- The agent records where the sale stands in a ManyChat field, one stage at a time:
589
-
590
- ```mermaid
591
- stateDiagram-v2
592
- direction LR
593
- [*] --> new: first reply
594
- new --> qualifying: agent asks what it needs
595
- qualifying --> nurturing: fit known, content sent
596
- nurturing --> offered: course and catalog price put
597
- offered --> link_sent: payment-link flow performed
598
- new --> link_sent: contact asks for the link
599
- qualifying --> link_sent
600
- nurturing --> link_sent
601
- link_sent --> enrolled: a person confirms payment
602
- note right of link_sent: Written by the server, never by the model
603
- note right of enrolled: Set in ManyChat by a person, not by this service
604
- ```
605
-
606
- - **The stage only moves forward.** A write to an earlier stage than the last
607
- one performed is refused, so a confused turn cannot send a lead back to
608
- `qualifying` after the offer. The model is told the contact's current stage
609
- on every turn.
610
- - **The agent qualifies before it sends content**, one question per turn, and
611
- records each answer with `set_field`. A direct question is answered first,
612
- and a contact who asks for the link gets it, qualified or not.
613
- - **Each flow is sent at most once per contact** within the history window. A
614
- flow already performed is removed from the model's choices on the next turn,
615
- unless it is marked `repeatable`, as the payment link usually is.
616
- - **The server, not the model, writes `link_sent`.** It is a follow-on of the
617
- payment-link flow: it runs only once ManyChat accepted the flow, and a failed
618
- flow writes no stage. It does not count against the eight-action cap.
619
- - **Objections are answered from the catalog.** "Too expensive" or "can I pay
620
- in parts?" gets the catalog's `paymentOptions`; "I don't have time" gets the
621
- content flow that addresses it. A discount request that no payment option
622
- answers still escalates as `price_negotiation`.
623
- - **It asks for the sale, and never invents a reason to buy now.** Once the
624
- stage is `offered`, the closing question asks for the enrolment plainly. No
625
- invented scarcity or deadline, no promised job outcome, no price absent from
626
- the catalog: a deposit or instalment figure is allowed only because it is in
627
- `paymentOptions`.
628
- - **Payment is a person's job.** A contact who says they have paid, or sends a
629
- receipt, is escalated as `payment_reported`. The agent cannot see the payment
630
- and never confirms it.
631
-
632
- The stage rules are system instructions, the same for every tenant. How the
633
- agent sounds while selling is the tenant's, in `config/prompt.md` and each
634
- flow's `description`. Setup, the rollout checklist and how to measure the
635
- result are in
636
- [config/README.md](config/README.md#the-sales-funnel-optional).
637
-
638
- Configuration is files, not code: `config/catalog.json` holds every fact the
639
- agent may state, so a price change is a JSON edit and `kill -HUP`. Nothing in
640
- `config/` is ever committed.
641
-
642
127
  ## Status
643
128
 
644
129
  MVP. Answers, escalates, and can send a tenant's ManyChat flows, tags and field
@@ -0,0 +1 @@
1
+ ALTER TABLE "turns" ADD COLUMN "reads" jsonb;