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 +34 -0
- package/README.md +39 -554
- package/db/migrations/0011_fixed_the_leader.sql +1 -0
- package/db/migrations/meta/0011_snapshot.json +1076 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/dist/agent/contact.d.ts +25 -3
- package/dist/agent/contact.js +40 -12
- package/dist/agent/mock-provider.js +80 -12
- package/dist/agent/prompt.d.ts +12 -2
- package/dist/agent/prompt.js +23 -5
- package/dist/agent/runner.d.ts +7 -1
- package/dist/agent/runner.js +9 -4
- package/dist/agent/tools.d.ts +6 -0
- package/dist/agent/tools.js +2 -0
- package/dist/cli/run.js +9 -3
- package/dist/contracts/agent.d.ts +12 -0
- package/dist/contracts/agent.js +11 -0
- package/dist/conversation/store.d.ts +3 -1
- package/dist/conversation/store.js +1 -0
- package/dist/db/schema.d.ts +29 -0
- package/dist/db/schema.js +6 -0
- package/dist/evals/runner.js +10 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/plugins/api.d.ts +112 -11
- package/dist/plugins/api.js +21 -5
- package/dist/plugins/loader.js +136 -44
- package/dist/plugins/plugins.d.ts +28 -2
- package/dist/plugins/plugins.js +56 -2
- package/dist/plugins/reads.d.ts +66 -0
- package/dist/plugins/reads.js +209 -0
- package/dist/routes/turn.d.ts +3 -0
- package/dist/routes/turn.js +20 -3
- package/dist/server.js +1 -0
- package/package.json +1 -1
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
[agent
|
|
108
|
-
[
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
[
|
|
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
|
|
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;
|