The agent's own records
RecordsKeep small JSON documents the agent needs across days and people, in the same store as its conversations.
Some things an agent must remember outside any one conversation: a buyer’s question it passed to
a seller, waiting for the answer, or a note it keeps about a person for next week. Records hold
that state as small JSON documents in collections you declare on createAgent. They live in the
same store as the agent’s conversations, so they survive restarts and deploys, and they behave the
same in the playground, self-hosted on SQLite or Postgres, and on Flow’s hosting.
Records need @flow-engineer/flow 0.6.0 or later.
Declare a collection
Section titled “Declare a collection”Add records to the createAgent options in your buildAgent (src/agent.ts in the starter).
Each collection names the top-level fields it can be queried by:
records: { questions: { people: ["buyer", "seller"], fields: ["listing", "status"] },},| Key | Purpose |
|---|---|
people | Fields that hold a person, as their context.person string. Queryable, and a record naming a person here is deleted when that person is forgotten |
fields | Other fields to query by, such as a status or an id from your system |
Only declared fields can be queried. Everything else in a record is stored and returned, but not
matched on. A collection may declare no fields at all: it is then read by id, or listed whole.
agent.records.collections lists the declared names, in the order declared.
Change a declaration
Section titled “Change a declaration”Add a field to people or fields and restart the agent. Before anything runs, the start indexes
the records already kept for the new field, so query and forgetting a person find them. A value
the new declaration refuses, such as a number in a people field, stays in the record but is left
out of the index.
The store keeps every field ever declared for a collection and keeps indexing it, so an older
declaration on the same store takes nothing away: a deploy’s previous revision still running, or
an old checkout running flow records export. Records it puts are indexed for the newer fields too.
Use records in a tool
Section titled “Use records in a tool”A tool reaches the records as context.records. This pair of tools lets a buyer ask about a
listing and lets the seller see the questions waiting for them. Add them inside the existing
tools array in src/tools.ts, and the SELLERS map and Question type above it:
// Your listings' sellers, by listing id. Sam is one of the starter's test people.const SELLERS: Record<string, string> = { "bike-42": "phone:+15555550102" };
interface Question { listing: string; question: string; buyer: string; seller: string; status: "waiting" | "answered"; replyTo: { channel: string; address: string } | null; askedAt: number; answer?: string;}
// Inside the tools array:tool({ name: "ask_seller", description: "Pass a buyer's question about a listing to its seller. The answer comes later.", input: z.object({ listing: z.string(), question: z.string() }), run: async ({ listing, question }, context) => { const seller = SELLERS[listing]; if (!seller) return { error: `There is no listing ${listing}.` }; const id = `${listing}:${context.idempotencyKey}`; await context.records.put("questions", id, { listing, question, buyer: context.person, seller, status: "waiting", replyTo: context.address ? { channel: context.channel, address: context.address } : null, askedAt: Date.now(), }); return { id, status: "waiting" }; },}),tool({ name: "questions_for_me", description: "List buyers' questions waiting for this seller's answer, oldest first.", input: z.object({}), run: async (_input, context) => { const waiting = await context.records.query<Question>("questions", { seller: context.person, status: "waiting" }); return waiting.map(({ id, value }) => ({ id, listing: value.listing, question: value.question })); },}),The id uses context.idempotencyKey, which is the same when a cut-off turn runs again, so a retry
replaces the record instead of adding a second one. buyer is context.person, the verified
identity, never a name the model supplied.
Try it
Section titled “Try it”Ask as the buyer
Run npm start, open the playground, choose Maria and send “Does the bike-42 come with
lights? Please ask the seller.”
See the record
Open the Records tab, shown when the agent declares collections. It lists the agent’s records by collection, every person’s, newest first and read only, and refreshes after each turn.
Check it worked: questions shows one record whose value includes
"buyer": "phone:+15555550101", "seller": "phone:+15555550102" and "status": "waiting".
Read it as the seller
Choose Sam and send “Any questions for me?” The agent calls questions_for_me, which
returns:
[ { "id": "bike-42:<idempotency key>", "listing": "bike-42", "question": "Does the bike-42 come with lights?" }]The question’s wording is what the model passed to ask_seller. As Maria, the same tool
returns []: she is not the seller of anything.
Use records outside a turn
Section titled “Use records outside a turn”Everywhere else (your routes, beforeSend, intercept, a script, a job in your backend) use
agent.records. It is the same object a tool gets. This route takes the seller’s answer from your
own app, keeps it on the record and has the agent tell the buyer:
const agent = createAgent({ // ...the starter's options, and records as above routes: { "/answers": async (request) => { if (request.headers.get("authorization") !== `Bearer ${process.env.ANSWERS_TOKEN}`) { return new Response(null, { status: 401 }); } const { id, answer } = (await request.json()) as { id: string; answer: string }; const question = await agent.records.get<Question>("questions", id); if (!question || question.status !== "waiting") { return Response.json({ error: "no waiting question" }, { status: 404 }); } await agent.records.put("questions", id, { ...question, status: "answered", answer }); if (!question.replyTo) return Response.json({ status: "answered" }); const sent = await agent.nudge({ to: question.replyTo, prompt: `The seller answered the question about ${question.listing}: ${answer}`, }); return Response.json(sent); }, },});return agent;Export Question from src/tools.ts and import it here. The agent answers routes through
agent.fetch(request). The nudge follows the usual delivery rules: its status says
whether it was sent, held for quiet hours, or refused, for example outside WhatsApp’s window.
What the records API does
Section titled “What the records API does”| Call | Result |
|---|---|
put(collection, id, value) | Keeps value under id, replacing the whole value. createdAt stays from the first put |
get(collection, id) | The value, or null |
query(collection, where?, { limit, after }?) | Entries { id, value, createdAt, updatedAt } whose declared fields equal every value in where |
delete(collection, id) | true, or false when there was no record |
forgetPerson(person) | Deletes every record naming person in a people field, in any collection; resolves to how many |
collections | The declared collections’ names |
export() | Every record of the agent, oldest first, as an async iterable of { collection, id, value, createdAt, updatedAt } |
A query matches by equality only and returns the oldest first (by when each record was first put,
then by id). Without where it returns the whole collection. It returns 50 entries unless limit
says otherwise, at most 100. For the next page, pass the last entry you got as after:
import type { RecordEntry } from "@flow-engineer/flow";
let after: RecordEntry | undefined;for (;;) { const page = await agent.records.query("questions", { status: "waiting" }, { limit: 100, after }); for (const { id, value } of page) console.log(id, value.listing); if (page.length < 100) break; after = page.at(-1);}There are no ranges, sorting, joins, text search or transactions across records. Two puts to the same id are not merged: the later value replaces the earlier one.
What a value may hold
A value is a plain JSON object. A Date is refused: keep a time as milliseconds
(Date.now()) or ISO text. NaN, Infinity, a Map, a class instance or undefined inside a
list are refused too, with an error naming where it is. Keys whose value is undefined are left
out.
A declared field holds a string, a number, a boolean or null. A people field holds a string.
Queries compare JSON values, so 1 and "1" are different. A query cannot match a missing
field or null.
Errors
Every refusal is an error whose message starts with flow: records: and says what to change,
for example no collection named orders: declare it in createAgent(...) or
questions cannot be queried by askedAt: declare it in .... A declaration that breaks a limit
throws when createAgent runs.
Limits
Section titled “Limits”RECORD_LIMITS, exported from @flow-engineer/flow:
| Limit | Value |
|---|---|
| One record’s value, as JSON | 64 KB (65,536 bytes) |
| Records per agent, per environment, across collections | 10,000 |
| Collections per agent | 20 |
Declared fields per collection (people and fields together) | 8 |
| A record id | 1 to 128 characters, no control characters |
| A declared field’s string value | 256 characters |
| Entries per query | 50 by default, at most 100 |
A collection name starts with a letter and has up to 64 letters, digits, - or _. A declared
field is a top-level name of letters, digits and _. A put of a new record past 10,000 is refused,
and nothing is written; replacing an existing record always works. The count takes no lock, so new
records put at the same moment may pass 10,000 by up to as many as are put at once. Delete records the agent no
longer needs, or keep that data in your own database.
Forgetting a person
Section titled “Forgetting a person”agent.forgetPerson(conversationId) deletes everything kept about a person, and with it every
record that names them in a people field. A record matches when that field holds any of their
names:
- their person id (
phone:+15550100100,key:<your id>or<channel>:<address>), - their conversation id,
- any channel identity they wrote from, as
<channel>:<address>, - the number of each identity they have on one of the agent’s phone channels, as
phone:+15550100100or+15550100100. This holds also when yourresolveor a sign-in keys them askey:....
A record that mentions the person only in a field not listed in people is kept. So is a record
holding just a bare chat address, such as a Telegram id, since another channel may use the same
value.
A tool of theirs that is still running when they are forgotten cannot keep a record afterwards.
Forgetting waits for puts already under way and deletes what they wrote. A later put from that
tool is refused with flow: records: not kept: the person was forgotten while the tool ran.
Someone named in records who never wrote to the agent has no conversation to forget, such as a
seller who only answers through your app. agent.records.forgetPerson(person) deletes the records
holding exactly that value in a people field and resolves to how many it deleted:
const deleted = await agent.records.forgetPerson("phone:+15555550102");Export records
Section titled “Export records”From the agent’s folder, print every record as JSON Lines, one record per line:
npx @flow-engineer/flow records export > records.jsonl{"collection":"questions","id":"bike-42:<key>","value":{"listing":"bike-42","question":"Does the bike-42 come with lights?","buyer":"phone:+15555550101","seller":"phone:+15555550102","status":"waiting","replyTo":{"channel":"whatsapp","address":"+15555550101"},"askedAt":1792836642480},"createdAt":1792836642482,"updatedAt":1792836642482}It ends with Exported 1 record. on stderr and exit status 0 (1 for a usage error or an agent that
could not be built). The command builds the agent from src/agent.ts (loading .env first) with a
model that is never called, so it needs no model key, starts no turn and sends nothing. It reads
the store your buildAgent opens on this machine, never a hosted agent’s: with the starter’s
in-memory store it exports nothing.
In code, agent.records.export() gives the same records, oldest first. To move them to another
store, put each one, in that order, into an agent built on the new store:
for await (const r of oldAgent.records.export()) { await newAgent.records.put(r.collection, r.id, r.value);}Each copy gets new createdAt and updatedAt times, the time of its put. Putting them oldest
first, as export gives them, keeps their order for query.
Records in MCP tools
Section titled “Records in MCP tools”A tool served over MCP gets context.records only when the server is given them. Without them,
a tool that uses records fails with not available here: give mcpServer({ records: agent.records }).
In the process that runs the messaging agent, give the MCP server that agent’s records:
import { mcpServer } from "@flow-engineer/flow/mcp";import { buildAgent } from "./agent.ts";import { tools } from "./tools.ts";
const agent = buildAgent({ channels }); // your channels, as in your messaging entry pointconst mcp = mcpServer({ name: "my-product", version: "1.0.0", tools, records: agent.records });Serve mcp as in the MCP guide. Its tools then read and write the same
records as the messaging agent, in the agent’s store. Building the agent needs its model key,
as npm start does.
On Flow’s hosting, src/mcp.ts may export a factory instead of a server. The host calls it
with the hosted agent, whose records are in the host’s store:
import { fileURLToPath } from "node:url";import type { Agent } from "@flow-engineer/flow";import { mcpServer } from "@flow-engineer/flow/mcp";import { name } from "./name.ts";import { tools } from "./tools.ts";
export function mcp(agent: Agent) { return mcpServer({ name, version: "1.0.0", tools, dir: fileURLToPath(new URL("../agent", import.meta.url)), records: agent.records, });}The starter’s npm run mcp serves an exported server object, not a factory, so keep a
separate local entry point if you switch to one.
A standalone MCP call has no channel address, so context.address is absent there.
Where records are kept
Section titled “Where records are kept”Records are rows in the agent’s store, beside its conversations, keyed by the agent’s id:
| Where the agent runs | Where its records are |
|---|---|
npm start and the playground, with the starter | In memory, gone when it stops |
| Self-hosted | The agent’s database, SQLite or Postgres, or your own store |
| Flow’s hosting | The host’s store, separately for each environment; promoting staging does not copy its records |
| Scenario tests in the test cloud | A store of the test’s own, starting empty |
The first start on 0.6.0 adds three tables to the agent’s database: records, record_index
and record_collections (the fields declared for each collection). It changes no existing table. Back records up with the rest of the database.
Upgrading to 0.6.0
ToolContexthas a new field,records. Flow fills it for every tool call. Code that builds aToolContextitself, such as a unit test calling a tool’srun, addsrecords: agent.records.- A custom
storethat forwards each call to aStore, as Flow’s hosting does, forwards the new record methods too:putRecord,getRecord,queryRecords,deleteRecord,forgetRecords,exportRecordsandreindexRecords. - On Flow’s hosting, an agent on 0.6 deploys once the host runs 0.6; until then
flow deployrefuses it, as for any newer release line.
Records or a nudge
Section titled “Records or a nudge”A follow-up at a given time is not a record. agent.nudge({ to, prompt, at }) gives the agent a
turn then, and that turn’s tools can read the records. See reminders and nudges.
Thanks, we read every comment.