024 — Contact Read and Free-Text Notes
Defines the get_contact read tool, the write_note tool and its note fields, and the larger loop both need. It amends 012 in four places, listed at the end, and leaves out reading anything but the current contact, writing free text to any field that is not a declared note, and every page-level or account-global endpoint.
get_contact returns a whitelist, never the subscriber
ManyChat's GET /fb/subscriber/getInfo returns the whole subscriber: name, phone, email, WhatsApp number, last input text, every tag and every custom field. None of the identifiers is needed to sell, and all of them are PII.
get_contact takes no parameters. It closes over the turn's subscriber, as every 012 tool does, and returns only what tools.json lists as readable:
{
"tags": ["interested_foundation"],
"fields": { "experience": "none", "sales_stage": "nurturing" },
"notes": { "goal": "…" },
}tagslists the configuredtags[].idwhose ManyChat tag the contact has, plus anyreadable.tags[]: tags the tenant's own flows set, which the agent may see but not write. Unconfigured tags are omitted.fieldsmaps each configured enum field, and eachreadable.fields[], to its value. A value outside the field's configuredvaluesis returned as"other", never as the raw string, because a flow may have filled it from the contact's typing.notesmaps each configured note (below) to its current text.
readable has the same entry shapes as tags and fields in 012, and the model sees only its ids, never the ManyChat names:
{
"readable": {
"tags": [
{
"id": "came_from_ad",
"tag": "source-ad",
"description": "The contact arrived by clicking an ad.",
},
],
"fields": [],
},
}The identifiers in the subscriber record (name, phone, email, WhatsApp number, profile picture, last input text) are never returned, whatever tools.json says. The client parses getInfo down to tag names and custom field values before returning it, so they never leave client.ts.
A tag id is unique across tags and readable.tags, and a field id across fields and readable.fields, since the result lists them side by side.
Only a turn that reads history may read the contact
A note holds the contact's own words, and 019 keeps those from a request that does not carry the contact's token. So get_contact is offered only on a turn that reads history: one that is bound, or any turn while CONTACT_TOKENS_ENFORCED is false. An unbound turn is offered the write tools as before, and no read.
Note values come back inside the contact fence
A note is model output summarising the contact's words, and an enum field may have been filled by a flow from the contact's own input. Either can carry an injected instruction. The tool result therefore places notes inside the same untrusted fence as inbound text (C4). Tags and enum fields, which are configured ids by construction, sit outside it.
A read is performed, and its failure is not an escalation
get_contact is the one tool whose execute makes a ManyChat request (ADR-0016). It goes through the existing ManyChatClient and its rate limiter, with its own timeout of 1500 ms, chosen, not measured, so that one slow read cannot consume the race.
A failed or timed-out read returns { available: false } and the turn continues. Reading is a help to the reply, not a precondition of it, and C6 already covers what matters: a turn that cannot ground an answer escalates for that reason, not because the read failed. The failure is logged at warn with the subscriber redacted (C5).
A turn may read at most twice. A third call returns { available: false } without a request.
The reply step offers no tools, so it cannot be shown its tool results (012 § The loop is bounded at four steps). Its note of what was staged carries the turn's last successful read instead, with the notes still fenced.
The loop grows to four steps and eight actions
| Bound | 012 | Now |
|---|---|---|
| Steps | 2 | 4 |
| Staged actions | 3 | 8 |
| Reads | — | 2 |
Steps one to three may call tools; step four offers none and must produce the AgentReply. Both numbers were chosen, not measured: four steps fit read, act, read again and reply; eight actions fit a flow, a funnel stage, two qualification fields, a tag and three notes. Server follow-on writes (023 § The sale ends at the payment-link flow) do not count against the cap.
The race deadline and model abort in 002 are unchanged and apply to the whole loop. More turns will be deferred; that is accepted (ADR-0016). Eight actions after a deferred reply is a burst of up to eight requests through a limiter whose burst is smaller, so the last of them wait. Change these numbers when an eval shows a need, and record the measurement date here.
Three free-text notes, declared and bounded
tools.json gains a notes list. Each entry is a note field the tenant declares is never rendered to the contact (ADR-0017):
{
"notes": [
{
"id": "goal",
"field": "agent_note_goal",
"maxLength": 280,
"neverRendered": true,
"description": "Why the contact wants the course, in one sentence.",
},
{
"id": "handoff_summary",
"field": "agent_note_handoff",
"maxLength": 500,
"neverRendered": true,
"onEscalation": true,
"description": "What the person taking over needs to know, in two or three sentences.",
},
],
}neverRendered must be the literal true, or the entry fails at load. It does not make a flow safe. It makes the tenant state, in the file, the one thing this repository cannot check.
The intended notes are goal, objections and handoff_summary; the ids are the tenant's to choose. maxLength may not exceed 500, chosen to fit a ManyChat text field with room to spare. onEscalation is optional and defaults to false; its meaning is in "A handoff summary survives the escalation it describes". Any note may carry it, but it exists for the handoff summary, and a note that is only useful before a sale (goal) should not.
write_note takes a note id and text. It is staged like any 012 write. The note's field may not equal MANYCHAT_REPLY_FIELD, MANYCHAT_TOKEN_FIELD, any fields[].field or another note's field; any collision fails at load.
Note text is cleaned before it is written
Before a staged note is performed, the server:
- removes URL, email, phone-number and long-number shapes, each replaced by
[removed]; - collapses whitespace and strips control characters;
- truncates to
maxLengthat a word boundary.
The text is cleaned when the note is staged, so the outbox holds only what the field will. A note left empty is not staged, and the call returns { staged: false }. A write replaces the field's value; it does not append. A model that wants to add to a note reads it first.
The shapes in step 1 are the ones the logger redacts (C5), from one list in src/observability/redact.ts, so a change to one is a change to both. URLs other than ManyChat media links were added to the logger's list for this.
A handoff summary survives the escalation it describes
012 § Guardrails run before any action is performed discards every staged action when the turn escalates. A handoff summary is staged precisely on that turn, so the rule would discard every one.
A note marked "onEscalation": true is therefore performed when the turn escalates, only if the escalation came from the model (escalate: true) or the confidence threshold, and the output passed schema validation and the leak checks. A note on a turn that escalated because of a leak, a schema failure, a thrown call or an abort is discarded with everything else, since the text it carries is exactly what failed. All other staged actions are discarded as 012 says.
Such a note is performed after the escalation message is delivered, on whichever path delivers it, as any action follows its text. In turns.actions it is written as staged and moves to performed or failed, like an action on a turn that did not escalate. It is the one entry on an escalated turn that is not discarded, so 012's status table needs no new value: discarded keeps meaning "dropped because the turn escalated", and an onEscalation note that was dropped for a leak or a failure is recorded as discarded too. A call that hits MODEL_ABORT_MS records no agent turn at all (002), so its notes are in no record and are never performed.
Note text never reaches the record or the logs
The turns.actions entry for a note holds its id and the length written, never the text:
{ "tool": "write_note", "id": "goal", "length": 74, "status": "performed" }The 012 history note lists it as write_note goal. The text lives only in the tenant's ManyChat account, which the model can read back with get_contact, and in the outbox row of a deferred turn until it is performed. A failure log for a note carries the id and the ManyChat error, never the text: an error that quotes the text has it replaced by [note].
What this changes in 012
§ Four toolsgainsget_contactandwrite_note, and becomes§ Six tools.§ Free-text field values are refusedholds forfields[]; free text is permitted only innotes[], as above.§ The loop is bounded at two stepsis replaced by the table above, and becomes§ The loop is bounded at four steps.§ Guardrails run before any action is performedgains theonEscalationexception.
012 and 003 are edited in the pull request that implements this spec.
Verification
- A unit test over a full
getInfofixture with invented name, phone and email assertsget_contactreturns none of them, omits unconfigured tags, and maps an out-of-enum field value to"other". - A unit test asserts note values in the tool result are inside the fence, and tags and fields are outside it.
- A test asserts a read that times out at 1500 ms returns
{ available: false }and the turn still produces a reply, and that a third read makes no request. - A test asserts the fourth step offers no tools, and a ninth staged action returns
{ staged: false }. - Config tests assert a note without
neverRendered: true, withmaxLengthover 500, or with a collidingfield, fails at load. - A unit test asserts the cleaning: an invented phone number, email and URL are replaced, and text over
maxLengthis cut at a word boundary. - A test drives each escalation path and asserts an
onEscalationnote is performed after the escalation message for model and confidence escalations, recordedperformed, and recordeddiscardedfor leak, schema and error. On abort it is never performed and no agent turn is recorded. A note withoutonEscalationisdiscardedon every escalation path. - An integration test asserts a note's
turns.actionsentry haslengthand no text, and a log-capture test asserts the text is absent from every log line of the turn.
What this misses: neverRendered is the tenant's word, and nothing here can see a flow that renders a note. The cleaning catches identifier shapes, not identifiers: a name, a town or a health detail in prose passes through to the tenant's CRM. And the read reflects ManyChat at the moment of the call; a flow that changes a tag a second later is not seen until the next read.