Docs / data / Agent-Driven Data Models — Reason → Write → Read

Agent-Driven Data Models — Reason → Write → Read

guidesince 0.2.1

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

1. Read context in the app, PII-safe projection
// 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
});
2. Deterministic prompt naming the exact document shape
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');
}
3. One-shot query, read result back reactively
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 ?? [];
4. Upsert from the agent — ensure the row exists before you update it
// 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];
CriticalNever parse the agent's chat text or messages[] for data — read the persisted document via useCollection.
NoteMongoDB is schemaless: the agent can write any document shape (nested objects, arrays, optional/mixed fields) with no migration. The prompt defines the shape.
NoteUse 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.
CriticalRead the current user's own row with a dedicated 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.
CriticalWhen an agent enriches a record over a conversation, instruct it to upsert: query for the row, 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.