Skip to content
New

Introducing Scale: SurrealDB Cloud for high availability and scale

Learn more

1/4

Chat with your meeting notes: a CocoIndex knowledge graph and a text-to-SurrealQL agent

Tutorial
AI

Aug 5, 202612 min read

Martin Schaer

Martin Schaer

Show all posts

Chat with your meeting notes: a CocoIndex knowledge graph and a text-to-SurrealQL agent

Our newsletter

Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks

A folder of meeting notes is a collection of documents that should be a graph instead. Every note records who ran the meeting, who showed up, what got decided, and who owns each action item. Without a structure, these notes evaporate into prose on a shared drive, where the only way to sift through the content is through keyword and full-text search.

Once you extract a structure, the interesting questions become answerable: what is Carol on the hook for? who has David worked with? which meeting produced the most action items? These involve concrete graph traversals, instead of directly searching through text.

This tutorial shows how to put this idea into action by building two things pointed at the same database:

  1. A CocoIndex pipeline that reads Markdown notes, extracts meetings, people and tasks with an LLM, deduplicates the people, and writes a knowledge graph into SurrealDB — incrementally, so editing one note re-extracts one note.

  2. A Pydantic AI chat agent with a tool to turn a question into SurrealQL. The prompt that tool sends to the model is not a string in the repository — it is a function call against the live database, so it cannot drift away from the graph the pipeline is writing.

Architecture

Text-to-SQL failures are not always the model to blame. Someone pasted a schema into a system prompt in March, a field was renamed in June, and since then the model has been confidently generating queries against a database that no longer matches expectations.

SurrealDB has the tools to close the gap. INFO FOR DB and INFO FOR TABLE return the live DDL of a running database, COMMENT clauses let you write notes for the model into that DDL, and DEFINE FUNCTION lets you assemble the finished prompt server-side. The prompt stops being a document you maintain and becomes a query you run.

There is a second reason SurrealQL is a good generation target. Who has been in a meeting with David Kim is two self-joins in SQL. In SurrealQL, this is much simpler:

SELECT record::id(id) AS person
FROM person:`David Kim`->attended->meeting<-attended<-person
WHERE id != person:`David Kim`
GROUP BY person;

The relationship is a table you traverse, so the joins disappear, doing away with join errors - the largest single category of text-to-SQL mistakes.

  • The surreal binary — curl -sSf https://install.surrealdb.com | sh

  • Python 3.11+ and uv

  • An OPENAI_API_KEY, for extraction and for the agent

FilePurpose
pipeline.pyThe CocoIndex pipeline: notes → graph
schema.surqlThe layer the model reads: comments, the example pool, the prompt functions, the read-only user
seed.surqlA snapshot of the extracted graph, so you can skip the pipeline
queries.surqlThe demos below
agent.pyThe chat agent, its text-to-SurrealQL tool, and the web UI
notes/Two sample notes
cp .env.example .env    # set OPENAI_API_KEY
uv sync
No API key?

Import schema.surql then seed.surql and every SurrealQL demo in this post works against the same graph. You need the key only for the pipeline and the agent — and the EXAMPLES block of the prompt stays empty until the agent seeds the pool on its first run, since those rows need an embedder.

surreal start --user root --pass secret memory

memory resets when you stop the process. Swap in rocksdb://kg.db if you want the graph to survive a restart.

The pipeline is the CocoIndex meeting-notes example, lightly adapted. It runs in three phases:

# Phase 1 — per note: split at headings, extract, declare meeting/task + decided
@coco.fn
async def process_file(file, meeting_table, task_table, decided_rel, extractions):
    for section in _split_meetings(await file.read_text()):
        extracted = await extract_meeting(section)          # instructor + LiteLLM
        meeting_id = await id_generator.next_id(extracted.time)
        meeting_table.declare_record(row=Meeting(id=meeting_id, ...))
        for task in extracted.tasks:
            task_table.declare_record(row=Task(id=task.description))
            decided_rel.declare_relation(from_id=meeting_id, to_id=task.description)

# Phase 2 — collapse "alice chen" / "Alice C." / "bob m." into canonical names
@coco.fn(memo=True)
async def _resolve_persons(raw_persons: set[str]) -> ResolvedEntities:
    return await resolve_entities(entities=raw_persons, embedder=coco.use_context(EMBEDDER),
                                  resolve_pair=LlmPairResolver(model=...))

# Phase 3 — declare canonical person records + attended / assigned_to
async def sync_person_graph(extractions, person_table, attended_rel, assigned_rel):
    for canonical_name in persons.canonicals():
        person_table.declare_record(row=Person(id=canonical_name))

Phase 2 is the part you would otherwise write badly by hand. The notes say Alice Chen in one place and alice chen and Alice C. in others; resolve_entities embeds every raw name, filters to the close pairs by vector similarity, and asks an LLM to confirm only those. Four people come out the other end.

uv run cocoindex update pipeline
✅ process_file: 2 total | 2 added
⏳ Elapsed: 55.4s

Three record tables and three edge tables:

SELECT record::id(in) AS person, out.time AS meeting
FROM attended WHERE is_organizer
ORDER BY meeting;
[
    { meeting: '2026-07-06', person: 'Alice Chen' },
    { meeting: '2026-07-13', person: 'Alice Chen' },
    { meeting: '2026-07-15', person: 'Carol Diaz' }
]

Note what the record ids are. person:`Alice Chen` and task:`Draft the Q3 roadmap.` carry their whole content in the id, so person and task are tables with no fields at all. That is idiomatic for a graph keyed by resolved entities — and it is exactly the kind of thing a model will never guess.

The connector creates the tables, and it creates them bare:

meeting:     DEFINE TABLE meeting TYPE NORMAL SCHEMAFULL PERMISSIONS NONE
attended:    DEFINE TABLE attended TYPE RELATION IN person OUT meeting SCHEMALESS PERMISSIONS NONE

While correct, this is useless as a prompt. schema.surql re-declares the same shapes with a COMMENT on every table and field — comments written for a model to read, because INFO FOR TABLE hands them straight back:

DEFINE TABLE OVERWRITE person TYPE NORMAL SCHEMAFULL
    COMMENT "A person, deduplicated across notes. Has no fields: the record id IS the canonical full name, e.g. person:`Alice Chen`. Read the name with record::id(id). Meetings: person->attended->meeting. Tasks owned: person->assigned_to->task.";

DEFINE FIELD OVERWRITE time ON meeting TYPE string
    COMMENT "Meeting date as an ISO string, 'YYYY-MM-DD'. It is a string and not a datetime, so compare and sort it lexicographically: time >= '2026-07-10'.";

Both comments carry knowledge that exists nowhere in the DDL. The first explains a record-id convention. The second warns about a potential issue: the connector stores the date as a string because JSON transport can't produce a SurrealDB datetime literal, and a model told TYPE string would otherwise reach for time:: functions on it.

There is one field the model could not see at all:

DEFINE FIELD OVERWRITE is_organizer ON attended TYPE bool
    COMMENT "True if this person ran the meeting, false if they only attended. Exactly one attendee per meeting has it true.";

The attended field is SCHEMALESS, so is_organizer is written on every edge and appears in no schema. Declaring the field is what puts it in the prompt. Without this line, who ran the most meetings is unanswerable and the model has no way to know why.

Schema diagram

The query_example field is the agent's own few-shot memory, filled in Step 5, which sits apart from the domain, connected to nothing. That separation is deliberate, and Step 4 enforces it.

surreal import --endpoint http://localhost:8000 \
  --user root --pass secret --ns ai --db meetings schema.surql
Order matters

Import this after the first pipeline run. The connector issues a plain DEFINE TABLE when it thinks a table is missing, so pre-creating meeting makes the pipeline fail with The table 'meeting' already exists.

After that first run the two layers are independent. DEFINE TABLE OVERWRITE replaces a definition without touching its rows, and re-running the pipeline leaves every comment in place — verified by re-running it and re-reading INFO FOR DB on every cycle in this post.

INFO FOR DB lists every table's definition; INFO FOR TABLE lists its fields. One function walks both and renders the schema block:

-- The tables the agent is allowed to see. Anything absent here
-- is invisible to the model.
DEFINE FUNCTION OVERWRITE fn::exposed_tables() {
    RETURN ["person", "attended", "meeting", "decided", "task", "assigned_to"];
};

DEFINE FUNCTION OVERWRITE fn::schema_context() {
    LET $defs = (INFO FOR DB).tables;

    RETURN fn::exposed_tables().map(|$t| {
        LET $fields = object::values((INFO FOR TABLE $t).fields);
        RETURN array::concat([$defs[$t]], $fields)
            .map(|$d| string::replace(
                string::replace($d, " PERMISSIONS FULL", ""),
                " PERMISSIONS NONE", "") + ";")
            .join("\n");
    }).join("\n\n");
};

Three details:

  • INFO FOR TABLE $t takes a plain string, so you can loop over table names. INFO FOR TABLE type::table($t) does not work.

  • The allowlist is a function, not a wildcard. query_example holds the agent's few-shot pool; showing the model the questions about the questions invites confusion. It is also your access-control surface for the prompt, distinct from the one the server enforces in Step 8.

  • PERMISSIONS clauses are stripped. The engine enforces them whatever the prompt says, so including them only spends tokens.

RETURN fn::schema_context();
DEFINE TABLE person TYPE NORMAL SCHEMAFULL COMMENT 'A person, deduplicated across notes. Has no fields: the record id IS the canonical full name, e.g. person:⟨Alice Chen⟩. Read the name with record::id(id). Meetings: person->attended->meeting. Tasks owned: person->assigned_to->task.';

DEFINE TABLE attended TYPE RELATION IN person OUT meeting SCHEMALESS COMMENT 'Links a person (in) to a meeting they attended (out). Traverse: person->attended->meeting. One edge per person per meeting.';
DEFINE FIELD in ON attended TYPE record<person>;
DEFINE FIELD is_organizer ON attended TYPE bool COMMENT 'True if this person ran the meeting, false if they only attended. Exactly one attendee per meeting has it true.';
DEFINE FIELD out ON attended TYPE record<meeting>;

DEFINE TABLE meeting TYPE NORMAL SCHEMAFULL COMMENT 'One meeting, extracted from a Markdown note. Attendees: meeting<-attended<-person. Tasks decided in it: meeting->decided->task.';
DEFINE FIELD note ON meeting TYPE string COMMENT 'One-paragraph summary of what the meeting was about, written by the extraction model.';
DEFINE FIELD note_file ON meeting TYPE string COMMENT 'Path of the Markdown file this meeting was extracted from. One file can hold several meetings.';
DEFINE FIELD time ON meeting TYPE string COMMENT "Meeting date as an ISO string, 'YYYY-MM-DD'. It is a string and not a datetime, so compare and sort it lexicographically: time >= '2026-07-10'.";

DEFINE TABLE decided TYPE RELATION IN meeting OUT task SCHEMALESS COMMENT 'Links a meeting (in) to a task decided in it (out). Traverse: meeting->decided->task.';
DEFINE FIELD in ON decided TYPE record<meeting>;
DEFINE FIELD out ON decided TYPE record<task>;

DEFINE TABLE task TYPE NORMAL SCHEMAFULL COMMENT 'An action item. Has no fields: the record id IS the task description, e.g. task:⟨Draft the Q3 roadmap.⟩. Read it with record::id(id). Owner: task<-assigned_to<-person. Meeting that decided it: task<-decided<-meeting.';

DEFINE TABLE assigned_to TYPE RELATION IN person OUT task SCHEMALESS COMMENT 'Links a person (in) to a task they own (out). Traverse: person->assigned_to->task. A task can have several owners.';
DEFINE FIELD in ON assigned_to TYPE record<person>;
DEFINE FIELD out ON assigned_to TYPE record<task>;

Look at what the engine contributed for free. DEFINE FIELD in ON attended TYPE record<person> was never written by anyone — SurrealDB derives it from TYPE RELATION IN person OUT meeting. The model is told exactly what sits at each end of every edge, which is precisely the information a JOIN-generating model normally has to guess.

The schema says person has no fields. It does not say that the ids are Alice Chen and Bob Martinez. In a graph keyed by names, the record ids are the enum, and they are where a model will hallucinate: person:alice, person:'Alice', person:alice_chen. So read them out of the data:

DEFINE FUNCTION OVERWRITE fn::value_hints() {
    LET $people = (SELECT VALUE record::id(id) FROM person).sort();
    LET $files  = (SELECT VALUE note_file FROM meeting GROUP BY note_file).sort();
    LET $dates  = (SELECT VALUE time FROM meeting GROUP BY time).sort();

    RETURN "person ids (" + <string> array::len($people) + " total): "
            + $people.map(|$p| "⟨" + $p + "⟩").join(", ")
        + "\nmeeting.note_file: " + $files.join(", ")
        + "\nmeeting.time range: " + $dates.first() + " to " + $dates.last()
        + "\ntask ids are full sentences; match them with string::contains(record::id(id), '...') rather than guessing an exact id.";
};
person ids (4 total): `Alice Chen`, `Bob Martinez`, `Carol Diaz`, `David Kim`
meeting.note_file: notes/product-review.md, notes/team-sync.md
meeting.time range: 2026-07-06 to 2026-07-15
task ids are full sentences; match them with string::contains(record::id(id), '...') rather than guessing an exact id.

GROUP BY on a SELECT VALUE is SurrealQL's distinct. Task descriptions are deliberately not listed — they are sentences, and there are more of them than people — so the model gets a matching strategy instead of a list.

Enumerating every person only works while there are few of them. Past a few hundred, drop the list and give the agent a name-lookup tool; the honest version of this function has a cardinality ceiling and you should know where yours is.

A fixed set of examples in a system prompt has the staleness problem all over again, plus a budget problem: the twenty examples that cover your domain won't fit next to everything else, and nineteen are irrelevant to any given question.

Store them in a table and retrieve the relevant ones:

DEFINE TABLE OVERWRITE query_example SCHEMAFULL
    COMMENT "A natural-language question paired with SurrealQL known to answer it.";

DEFINE FIELD OVERWRITE question  ON query_example TYPE string;
DEFINE FIELD OVERWRITE surql     ON query_example TYPE string;
DEFINE FIELD OVERWRITE embedding ON query_example TYPE array<float>
    ASSERT array::len($value) = 384;

DEFINE INDEX OVERWRITE query_example_vec ON query_example
    FIELDS embedding
    HNSW DIMENSION 384 DIST COSINE TYPE F32 EFC 150 M 12 M0 24;

384 is the dimension because the pipeline already downloads Snowflake/snowflake-arctic-embed-xs to deduplicate people, and reusing it costs nothing — no second model, no second API key. Retrieval is a KNN query:

DEFINE FUNCTION OVERWRITE fn::similar_examples($qvec: array<float>) {
    RETURN SELECT question, surql, vector::distance::knn() AS dist
        FROM query_example
        WHERE embedding <|3, 40|> $qvec
        ORDER BY dist ASC;
};
`K` must be a literal

<|3, 40|> works; <|$k, 40|> is a parse error, even inside a function.

agent.py seeds the pool on first run from a list of seven pairs, each one executed by hand against the graph first — an example that returns the wrong rows teaches the model to return the wrong rows:

SEED_EXAMPLES = [
    ("Who ran the most meetings?",
     "SELECT record::id(in) AS person, count() AS meetings FROM attended "
     "WHERE is_organizer GROUP BY person ORDER BY meetings DESC LIMIT 50;"),
    ("Who has been in a meeting with David Kim?",
     "SELECT record::id(id) AS person FROM person:⟨David Kim⟩->attended->meeting"
     "<-attended<-person WHERE id != person:⟨David Kim⟩ GROUP BY person LIMIT 50;"),
    # ...five more
]

Then fn::nl2surql_prompt stacks the pieces. Every variable part is a function call, so the prompt regenerates itself per request:

DEFINE FUNCTION OVERWRITE fn::nl2surql_prompt($question: string, $qvec: array<float>) {
    RETURN "You translate questions into SurrealQL for SurrealDB 3.x, against a knowledge graph built from meeting notes.\n"
        + "\nRules:\n"
        + "1. Reply with one SELECT statement and nothing else. No prose, no code fences.\n"
        + "2. Use only the tables and fields in the schema below.\n"
        + "3. Follow relationships with graph arrows (a->edge->b, b<-edge<-a). SurrealQL has no JOIN and no DISTINCT: deduplicate with GROUP BY on a projected field.\n"
        + "4. person and task have no fields — the record id is the name or the description. Project it as record::id(id) AS person, and write literal ids as person:⟨Alice Chen⟩.\n"
        + "5. Alias every projected expression, or the column comes back called 'record::id'.\n"
        + "6. WHERE filters the table named in FROM. To filter on the far end of a relationship, SELECT FROM the edge table and use in.field / out.field: FROM decided WHERE in.time = '2026-07-13'.\n"
        + "7. Filter inside a traversal with [WHERE ...]: ->attended[WHERE is_organizer]->meeting.\n"
        + "8. Use the literal ids listed under VALUES; do not invent your own.\n"
        + "9. Always end with a LIMIT of 50 or less.\n"
        + "\nSCHEMA\n"   + fn::schema_context() + "\n"
        + "\nVALUES\n"   + fn::value_hints()    + "\n"
        + "\nEXAMPLES\n" + fn::example_context($qvec) + "\n"
        + "\nQUESTION\n" + $question + "\nSURQL\n";
};

Every one of those nine rules is there for a failure observed while building this post. Step 7 shows four of them.

One database call now produces a complete, current, question-specific prompt.

The whole text-to-SurrealQL step is: get the prompt, send it to a model, run what comes back on a read-only connection, and feed the error back if it fails.

nl2surql = Agent(os.environ.get("NL2SURQL_MODEL", "openai:gpt-5-mini"), output_type=str)


async def ask_graph(deps: Deps, question: str) -> dict:
    prompt = await deps.admin.query(
        "RETURN fn::nl2surql_prompt($q, $v);",
        {"q": question, "v": embed(question)},
    )

    attempts: list[dict] = []
    message = prompt
    for _ in range(1 + MAX_REPAIRS):
        surql = _strip_fence((await nl2surql.run(message)).output)
        rows, error = await _run_read(deps.reader, surql)
        attempts.append({"surql": surql, "error": error})
        if error is None:
            return {"surql": surql, "rows": rows, "repairs": len(attempts) - 1}
        # The parser names the offending column, which makes a very good
        # repair signal. Feed it back with the original prompt.
        message = (f"{prompt}{surql}\n\nThat query failed with:\n{error}\n\n"
                   "Reply with the corrected SurrealQL only.\n")

    return {"error": "could not produce a query that runs", "attempts": attempts}

The generator has no system prompt of its own. The database supplies it.

Two connections, on purpose: prompt assembly reads DDL and needs privilege; the generated query runs as a VIEWER (Step 8). Reading the error out of the response takes one wrinkle worth knowing:

response = await reader.query_raw(surql)
# A parse error rejects the whole request and comes back as `error`;
# a runtime error comes back per statement. Both are repair signals.
if "error" in response:
    return None, str(response["error"].get("message", response["error"]))

Wrap that in a tool and the chat agent's job is small:

@chat_agent.tool_plain
async def ask_meeting_graph(question: str) -> dict:
    """Query the meeting-notes knowledge graph with a question in English.

    Translates the question into SurrealQL against the live schema and runs it
    read-only. Returns the generated query and the rows it produced.
    """
    return await ask_graph(await deps(), question)

Rules 3–7 all came from watching the generator fail. Here is what it produced before the few-shot pool was seeded and before those rules existed — same graph, same model, same questions as Step 8.

Loud: a syntax error it could not repair. Who has David Kim been in a meeting with?

SELECT DISTINCT record::id(p) AS name FROM person:`David Kim`->attended->meeting<-attended<-person AS p LIMIT 50;
Parse error: Unexpected token `RECORD`, expected FROM
 --> [1:17]
  |
1 | SELECT DISTINCT record::id(p) AS name FROM person:⟨David Kim⟩->attended->meet...
  |                 ^^^^^^

This is the good failure: the parser points at the column and that message is a high-quality repair signal. But DISTINCT doesn't exist in SurrealQL, so the model spent all three attempts rearranging parentheses around a keyword that was never going to parse. Retries fix typos; they do not fix a wrong mental model. Rule 3 — no JOIN and no DISTINCT: deduplicate with GROUP BY — fixed it in one shot.

Silent: a traversal filter that reads as false. Who ran the most meetings?

SELECT record::id(id) AS person, count(person->attended[is_organizer = true]->meeting) AS ran_count
FROM person ORDER BY ran_count DESC LIMIT 1;
[{ person: 'Alice Chen', ran_count: 0 }]

No error. [is_organizer = true] is not how you filter a traversal — [WHERE is_organizer] is — so the count came back zero for everyone and ORDER BY picked an arbitrary winner. The name in that row is even correct, by luck, which is the worst possible outcome. Rule 7.

Silent: a WHERE on the wrong table. Which tasks came out of the 13 July sync?

SELECT record::id(id) FROM meeting->decided->task WHERE time = '2026-07-13' LIMIT 50;
[]

Also no error. The traversal lands on task, which has no time field, so the filter matched nothing. An empty result is indistinguishable from "that meeting decided nothing", and an agent will report exactly that. Rule 6 — SELECT FROM the edge table and use in.field — is the fix, and one of the seven seeded examples demonstrates it.

This asymmetry is the whole argument for Steps 4 and 5. You cannot catch silent failures downstream: there is no error to catch, and a wrong-but-plausible answer is worse than a crash. Two more from queries.surql, for completeness:

SELECT time, chair FROM meeting;
[
    { chair: NULL, time: '2026-07-06' },
    { chair: NULL, time: '2026-07-15' },
    { chair: NULL, time: '2026-07-13' }
]

SCHEMAFULL constrains what you can write, not what you can select — a field that doesn't exist reads as NULL.

SELECT record::id(out) AS meeting FROM attended WHERE in = person:alice;
[]

Grounding fixes truth; retries only fix syntax.

Same six questions, with the schema comments, the value hints and the seven examples in place. Every one is correct, and every one on the first attempt — zero repairs:

Q: Who ran the most meetings?
SELECT record::id(in) AS person, count() AS meetings FROM attended
WHERE is_organizer GROUP BY person ORDER BY meetings DESC LIMIT 50;
→ [{ meetings: 2, person: 'Alice Chen' }, { meetings: 1, person: 'Carol Diaz' }]

Q: What is Carol Diaz on the hook for?
SELECT record::id(out) AS task FROM assigned_to WHERE in = person:`Carol Diaz` LIMIT 50;
→ [{ task: 'Draft the Q3 roadmap.' }, { task: 'Share the prototype timeline with the sales team.' }]

Q: Which meetings mentioned the ingestion pipeline?
SELECT record::id(id) AS meeting, time AS time, note AS note, note_file AS note_file
FROM meeting WHERE string::contains(note, 'ingestion pipeline') LIMIT 50;
→ [{ meeting: 1, time: '2026-07-06', note: 'Weekly sync covering the ingestion pipeline migration...' }]

Q: Who has David Kim been in a meeting with?
SELECT record::id(id) AS person FROM person:`David Kim`->attended->meeting<-attended<-person
WHERE id != person:`David Kim` GROUP BY person LIMIT 50;
→ [{ person: 'Alice Chen' }, { person: 'Bob Martinez' }, { person: 'Carol Diaz' }]

Q: Which tasks came out of the 13 July sync?
SELECT record::id(out) AS task FROM decided WHERE in.time = '2026-07-13' LIMIT 50;
→ [{ task: 'Review the Q3 roadmap draft.' }, { task: 'Schedule the onboarding design review.' }]

Q: Which meeting produced the most action items?
SELECT record::id(in) AS meeting, count() AS action_items FROM decided
GROUP BY meeting ORDER BY action_items DESC LIMIT 1;
→ [{ action_items: 3, meeting: 2 }]

Nothing about the model changed between Step 7 and here. Everything that changed lives in the database.

Pydantic AI ships a chat UI for local development, so there is no frontend to write:

app = chat_agent.to_web()
uv run uvicorn agent:app --port 7932

Open http://127.0.0.1:7932 and ask it something. The chat agent's instructions keep it honest about where answers come from:

chat_agent = Agent(
    os.environ.get("CHAT_MODEL", "openai:gpt-5.2"),
    instructions=(
        "You answer questions about a company's meetings, the people in them, "
        "and the tasks they produced. The only source of truth is the "
        "`ask_meeting_graph` tool — never answer from memory, and never guess a "
        "name, date or task that did not come back from it.\n"
        "Answer in prose, and show the SurrealQL the tool used in a ```surql "
        "block so the user can check your work. If a call comes back empty, say "
        "so plainly rather than filling the gap."
    ),
)

Showing the generated SurrealQL in the answer is not decoration. It is the only way a user can tell a right answer from a plausible one, and it is what makes the silent failures in Step 7 reviewable instead of invisible.

User: What did we decide about onboarding, and who owns it?

Agent: Decisions that mention onboarding produced these owned tasks:

  • Prototype the single-screen onboarding flow — Owner: David Kim

  • Schedule the onboarding design review — Owner: David Kim

  • Share the prototype timeline with the sales team — Owner: Carol Diaz

  • Size the engineering work required for the prototype — Owner: Alice Chen

SELECT record::id(out) AS task, record::id(in) AS owner FROM assigned_to
WHERE string::contains(string::lowercase((out<-decided.in.note)[0]), 'onboarding')
GROUP BY task, owner LIMIT 50;

agent.py also runs standalone — uv run python agent.py asks one question and prints the answer, which is the fastest way to check a change without a browser.

Prompt rules are requests. These are not.

The agent connects as a read-only user. Rule 1 asks for a SELECT; a VIEWER role enforces it:

DEFINE USER OVERWRITE agent ON DATABASE PASSWORD "agent-secret"
    ROLES VIEWER
    DURATION FOR TOKEN 1h, FOR SESSION 12h;

Reads work. Writes do not:

DEFINE FIELD hax ON meeting TYPE string;
→ IAM error: Not enough permissions to perform this action

DELETE person; from that session leaves all four rows intact — but it returns an empty result [] rather than an IAM error. Don't rely on your error channel to tell you a write was blocked; rely on the role. Regex-filtering the model's output for DELETE is theatre by comparison: it fails open on anything you didn't anticipate, and the role fails closed on everything.

Connecting as a database-level user

A DEFINE USER ... ON DATABASE account must authenticate at database level, so the namespace and database go in the credentials; a root user must not send them, or the server looks for a database user by that name and returns There was a problem with authentication. And set DURATION FOR SESSION explicitly — the default is NONE, which also fails to authenticate.

EXPLAIN is a free dry run. It parses and plans without reading a row, so it validates syntax and prices the query at the same time:

EXPLAIN SELECT record::id(out) AS task FROM assigned_to WHERE in = person:⟨Alice Chen⟩;
SelectProject [ctx: Db] [projections: task]
    Compute [ctx: Db] [fields: task = record::id(...)]
        TableScan [ctx: Db] [table: assigned_to, direction: Forward, predicate: in = person:`Alice Chen`, pre_decode_filter: yes]

TableScan — on seven edges that's free; not so for a table with seven million records. Checking the plan before executing lets you reject or re-route an expensive generation, which is a guardrail no prompt rule can give you.

TIMEOUT is a hard stop the model cannot override:

SELECT count() FROM attended GROUP ALL TIMEOUT 2s;
[{ count: 10 }]

Together: the role bounds what a generated query can do, EXPLAIN bounds what you let it cost, TIMEOUT bounds how long it runs. All three are enforced by the engine, so none depend on the model having read the prompt.

This is the payoff for generating the prompt instead of writing it. Drop a third note into notes/:

## Onboarding Design Review — 2026-07-22

David Kim organized the onboarding design review. Alice Chen, Carol D. and
Priya Raman from design attended.

Priya walked through the single-screen prototype. The group agreed to cut the
optional-profile step entirely, and to instrument the workspace-creation form
before the next release.

Action items:

- Priya Raman to cut the optional-profile step from the prototype.
- Alice Chen to instrument the workspace-creation form.
uv run cocoindex update pipeline
✅ process_file: 3 total | 1 added, 2 reprocessed
⏳ Elapsed: 18.3s

One note extracted, not three — @coco.fn(memo=True) caches each extraction by content. Then, with no other change anywhere:

RETURN fn::value_hints();
person ids (5 total): ⟨Alice Chen⟩, ⟨Bob Martinez⟩, ⟨Carol Diaz⟩, ⟨David Kim⟩, ⟨Priya Raman⟩
meeting.note_file: notes/design-review.md, notes/product-review.md, notes/team-sync.md
meeting.time range: 2026-07-06 to 2026-07-22

Carol D. resolved to the existing Carol Diaz rather than becoming a fifth person, and the prompt already knows about Priya. Ask the agent about someone who did not exist a minute ago and it answers, including a four-hop traversal nobody wrote an example for:

User: What is Priya Raman working on, and who else was in that meeting?

Agent: Priya Raman is working on "Cut the optional-profile step from the prototype." The other attendees were Alice Chen, Carol Diaz and David Kim.

SELECT record::id(id) AS person
FROM person:⟨Priya Raman⟩->assigned_to->task<-decided<-meeting<-attended<-person
WHERE id != person:⟨Priya Raman⟩ GROUP BY person LIMIT 50;

It reconciles in both directions. Delete the note and re-run:

✅ process_file: 3 total | 2 reprocessed, 1 deleted
⏳ Elapsed: 0.3s

The meeting, Priya's person record, her task and every edge that touched them are gone; the four original people are back; no orphans, no cleanup script. And the comments added in Step 3 survived every one of those cycles.

  • Entity resolution is a model's judgement, and it is not deterministic. One run of this exact pipeline kept Bob M. separate from Bob Martinez — five people instead of four. The tell was in fn::value_hints(), because the prompt lists what is actually in the graph; a hand-written prompt would have hidden it. Read the person list after a run over new notes, and treat a suspicious pair as a bug in your resolution model, not in the query.

  • Cache the schema block. fn::schema_context() hits INFO on every call. Schemas change rarely: cache the rendered string in your application and invalidate on deploy. Keep fn::value_hints() live — that one is the part that actually moves.

  • The graph has no completion state. There is no done flag anywhere, so "who is behind?" has no honest answer. The agent will still attempt one. Either extract status into the schema or tell the agent in its instructions which questions it cannot answer.

  • Gate what enters the few-shot pool. fn::remember_example turns an accepted query into an example. Gate it on a thumbs-up or a review queue: a pool that accepts whatever ran without erroring will happily learn the silent failures from Step 7 and start teaching them to the model.

  • Log every generation — question, prompt, generated SurrealQL, whether it parsed, whether the user accepted it. That log is how you find out which questions your prompt handles badly, and it is the raw material for the next batch of examples.

You now have a knowledge graph that maintains itself from a folder of notes, a prompt that maintains itself from the graph, and a chat agent whose queries are fenced in by a database role rather than a regex. The pattern generalises past meeting notes: any database whose structure you can query is a database that can describe itself to a model — and one that can also hold the examples, run the retrieval that selects them, and assemble the prompt around them.

Related posts

Our newsletter

Get tutorials, AI agent recipes, webinars, and early product updates in your inbox every two weeks

SurrealDB

The context layer for AI agents.

Documents, graphs, vectors, time-series, and memory.
One transaction, one query, one deployment.

Explore with AI

Stay in the loop

Tutorials, AI agent recipes, and product updates, every two weeks.

Independently verified

SOC 2 Type 2

GDPR

Cyber Essentials Plus

ISO 27001

Trust Centre

Copyright © 2026 SurrealDB Ltd. Registered in England and Wales. Company no. 13615201

Registered address: 3rd Floor 1 Ashley Road, Altrincham, Cheshire, WA14 2DT, United Kingdom

Trading address: Huckletree Oxford Circus, 213 Oxford Street, London, W1D 2LG, United Kingdom