Skip to content

Ask the docs

Answers come only from these docs and are written by an AI model (Gemini), so check the linked pages. Your question is not kept. Do not include personal details or keys.

Keep 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.

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:

src/agent.ts — add to the createAgent options
records: {
questions: { people: ["buyer", "seller"], fields: ["listing", "status"] },
},
KeyPurpose
peopleFields that hold a person, as their context.person string. Queryable, and a record naming a person here is deleted when that person is forgotten
fieldsOther 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.

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.

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:

src/tools.ts
// 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.

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:

Tool result
[
{
"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.

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:

src/agent.ts — buildAgent
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.

CallResult
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
collectionsThe 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:

Read every waiting question, page by page
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.

RECORD_LIMITS, exported from @flow-engineer/flow:

LimitValue
One record’s value, as JSON64 KB (65,536 bytes)
Records per agent, per environment, across collections10,000
Collections per agent20
Declared fields per collection (people and fields together)8
A record id1 to 128 characters, no control characters
A declared field’s string value256 characters
Entries per query50 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.

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:+15550100100 or +15550100100. This holds also when your resolve or a sign-in keys them as key:....

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:

Forget a seller who never wrote
const deleted = await agent.records.forgetPerson("phone:+15555550102");

From the agent’s folder, print every record as JSON Lines, one record per line:

Terminal
npx @flow-engineer/flow records export > records.jsonl
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:

Copy records to an agent on another 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.

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:

src/server.ts — a fragment
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 point
const 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.

A standalone MCP call has no channel address, so context.address is absent there.

Records are rows in the agent’s store, beside its conversations, keyed by the agent’s id:

Where the agent runsWhere its records are
npm start and the playground, with the starterIn memory, gone when it stops
Self-hostedThe agent’s database, SQLite or Postgres, or your own store
Flow’s hostingThe host’s store, separately for each environment; promoting staging does not copy its records
Scenario tests in the test cloudA 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
  • ToolContext has a new field, records. Flow fills it for every tool call. Code that builds a ToolContext itself, such as a unit test calling a tool’s run, adds records: agent.records.
  • A custom store that forwards each call to a Store, as Flow’s hosting does, forwards the new record methods too: putRecord, getRecord, queryRecords, deleteRecord, forgetRecords, exportRecords and reindexRecords.
  • On Flow’s hosting, an agent on 0.6 deploys once the host runs 0.6; until then flow deploy refuses it, as for any newer release line.

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.