Agent-Driven Data Models — Reason → Write → Read
The core pattern for using an agent as a worker: the app reads context and passes it in the prompt, the agent reasons and writes a structured document, and the app reads the result back via useCollection.
This is the core pattern for using an agent as a worker, not just a chatbox — the proven way to get a data model that works on the first try (validated end-to-end by the Caveat app).
The mental model:
1. The app reads whatever context the agent needs (via useCollection) and hands it to the agent inside the prompt.
2. The agent reasons, then writes its result as a document to a MongoDB collection using its data tools (insert_documents / update_documents). The agent is the writer; its job is to persist a structured doc, not to "reply."
3. The app reads the result back via useCollection (reactively). Never parse the agent's chat text or messages[] for data — read the persisted document.
MongoDB is schemaless — lean on it. A collection imposes no fixed columns, so the agent can write any shape: nested objects, arrays of objects, ragged/optional fields, mixed types, per-row metadata. You do not need a migration, a fixed schema, or matching columns up front. Tell the agent the exact JSON shape you want in the prompt and it will write exactly that — the document is the schema, and it can evolve per write.
Three steps in practice:
1. Read context in the app and pass it to the agent (don't make the agent query for it). Reading a shared pool via useCollection({ layer: 'shared' }) is reliable; hand the data to the agent as JSON in the prompt. Strip PII (email/phone) before handing rows to the agent.
2. Build a deterministic prompt that names the EXACT document shape to write. Keep prompt construction in a small builder function so it's testable and the shape is explicit. Tell the agent which collection to write and the precise JSON — it will follow it verbatim.
3. Run the agent as a one-shot task and read the result back via `useCollection`. This is NOT a chat — it's a single query() whose side effect is a written document. Use capabilities: ['data_access'] to grant the data tools, and a sessionKey so this task's conversation stays isolated. With refreshOnAgentComplete: true, the result collection re-fetches automatically when the agent's write tool completes — no manual refresh() needed.
Why write-to-collection instead of reading `toolCalls[].result`? As of SDK 0.2.1, toolCalls[].result does carry the tool's structured return (and toolCalls[].status is 'error' when a call fails), so it's fine for ephemeral/inline results. But the durable pattern is to have the agent persist a document and read it via useCollection: the result survives refreshes, is reactive across components, and is queryable later.
Examples
// Read the shared pool the agent will reason over.
const pool = useCollection<Record>('records', { layer: 'shared', limit: 200 });
// Read the current user's OWN row with a dedicated 1-doc query — do NOT find() it
// inside the pool. The pool can settle a beat later, so `pool.documents.find(...)`
// reads as "missing" on first paint even when the row exists (a real, hard-won bug).
// A filtered limit:1 query resolves it independently of the pool load.
const mine = useCollection<Record>('records', {
layer: 'shared', filter: { owner_key: myKey }, limit: 1, refreshOnAgentComplete: true,
});
const self = mine.documents[0] ?? null;
const others = pool.documents.filter(r => r.owner_key !== myKey);
// PII-safe projection before the agent ever sees a row.
const toSafe = (r: Record) => ({
owner_key: r.owner_key, title: r.title, tags: r.tags,
// NO email, NO phone
});function buildPrompt(self, others, nowIso: string) {
return [
`TASK — produce a result for owner_key "${self.owner_key}".`,
'Use the data provided below; do NOT query the database — it is all here.',
'',
'SUBJECT:', JSON.stringify(self, null, 2),
`CONTEXT (${others.length}):`, JSON.stringify(others, null, 2),
'',
'Steps:',
'1. Reason over the inputs.',
'2. WRITE exactly one document to the "results" collection with insert_documents.',
` Use this exact run_at (do NOT call a time tool): "${nowIso}". The document MUST be:`,
' { "owner_key": "...", "run_at": "...", "status": "fresh",',
' "items": [{ "ref", "score", "reasons": [] }] }',
'3. ALWAYS write the doc (even if items is empty). Never include email/phone.',
'Then reply with one short sentence summarizing the result.',
].join('\n');
}const agent = useAgent('custom', { capabilities: ['data_access'], sessionKey: 'worker' });
// The result collection — reads back what the agent writes.
const results = useCollection<ResultDoc>('results', {
layer: 'user', // per-user results; no owner_key filter needed (partition is scoped)
sort: { run_at: -1 }, limit: 5,
refreshOnAgentComplete: true, // auto-refresh when the agent's insert_documents completes
});
async function run() {
await Promise.all([pool.refresh(), mine.refresh()]);
if (!self) return; // the 1-doc query may settle after first paint — gate on it
await agent.query(buildPrompt(toSafe(self), others.map(toSafe), new Date().toISOString()));
// results.documents[0] now holds the agent-written result (auto-refreshed). Render it directly.
}
const latest = results.documents[0];
const items = latest?.items ?? [];// When an agent ENRICHES a record over a conversation, update_documents alone
// silently no-ops if the row doesn't exist yet — the user 'finishes' the chat but
// has no saved record (a real, hard-won bug). Tell the agent to UPSERT: insert a
// starter row first, then update it as it learns.
function enrichPrompt(ownerKey: string) {
return [
`ENRICH MODE. owner_key: "${ownerKey}".`,
'FIRST, query the "profiles" collection for this owner_key.',
'If there is NO doc, INSERT one now (insert_documents) as a starter row:',
` { "owner_key": "${ownerKey}", "fields": {}, "created_at": <ISO> }`,
'THEN, as you learn facts, UPDATE that doc (update_documents, filter by',
'owner_key) and set updated_at. Keep nuanced observations as append-only notes.',
].join('\n');
}
// The app reflects the row passively — it never 'completes' on a chat marker.
// With refreshOnAgentComplete, the indicator flips once the row lands.
const mineP = useCollection<Profile>('profiles', {
layer: 'shared', filter: { owner_key: myKey }, limit: 1, refreshOnAgentComplete: true,
});
const ready = !!mineP.documents[0];messages[] for data — read the persisted document via useCollection.capabilities: ['data_access'] to grant the agent its data tools, a sessionKey to isolate the task's conversation, and refreshOnAgentComplete: true on the result collection for automatic reactive read-back.useCollection({ filter: { owner_key }, limit: 1 }) query — not pool.documents.find(...). The shared pool can settle after first paint, so finding 'self' inside it reads as absent on the first render even when the row exists.insert_documents a starter row if missing, then update_documents as it learns. A bare update_documents no-ops on a missing row — the user finishes the chat with no saved record.See also
useAgent · useCollection · two-layer-data-model
Source: README.md#agent-driven-data-models--reason--write--read · Also available in llms-full.txt and registry.json.