Building an AI App to Strengthen Human Thinking: How We Used SurrealDB Agent Memory
Students who build with AI tools often end up with code that works and ideas behind it they can't explain. A tool that forgets every session can't tell them whether that is changing. It can't say they needed less help today, or that their guess about what they could explain was closer than last time. Lumente fosters questioning and curiosity in programming students who build with AI, through learning the concepts behind their own code. A student picks a concept like JWT and works it out by asking questions, and a map of four parts (category, similar concepts and differences, defining features and classification) shows them where to look. Our Claude agents ask the questions at first, then the help fades and the student starts asking, because that's the skill we want them to build. Lumente's own database already kept each student's levels for the code that sets the scores. What its agents were missing was memory. The hint giver answers when a student taps Help, and the coach writes a note at the end of each session, but neither could mention an earlier session. This post is how we gave those two agents long-term memory with SurrealDB Agent Memory, without storing anything students wrote. Now they can tell a student how this session compares with the last one, and the app shows the exact fact behind each thing they say. You can find our repository, demo video and live app here. Contents Why we picked SurrealDB Agent Memory Memory that never holds what students wrote How it works Storing a fact Keeping the history of how a student improves Reading it back Citing a fact by its ID The step without a model Notes from the live service Early results What's next Team Why we picked SurrealDB Agent Memory For the coach to say "your guess was closer than last time", it has to remember last time. SurrealDB Agent Memory gave us that, and it let us write each fact ourselves, so it stores our sentences exactly as we wrote them and doesn't run a model on them. It also links each new value to the old one, keeps the history, and can forget a whole scope in one call, so we didn't have to build any of that. It went live in our production app on Oct 23 and is still on. Memory that never holds what students wrote We started from one rule: nothing a student types goes into memory. Their answers and questions are kept in Lumente's own database, never in Agent Memory. Memory only gets short sentences our code writes from numbers the app has already worked out, like "Last time on JWT you guessed 4 and proved 2." Memory doesn't know who the student is, either. Each student's facts live in their own scope, learner/, and the pseudonym is a salted hash of their sign-in ID, so memory never sees a name or email. And students stay in control of it. The Memory sheet in the app shows them what Lumente remembers about them and how it changed. Delete my data calls POST /scopes/forget, which removes their whole scope in one call, and when we checked the live service afterward, there was nothing left to read. How it works Lumente's architecture, with Agent Memory beside the code that sets scores When a student finishes a session, the API writes a few facts to Agent Memory. The next time they practice that concept, the API reads those facts back before the hint giver or the coach runs. withMemory adds the facts to those two agents' input. Each agent sends back its message and the IDs of the facts it used, the API keeps only the IDs it sent, and the app shows those facts under the message. Delete my data removes the student's whole scope. Storing a fact We write each fact as one sentence from a template. There are three kinds: level_: how much help each part of the map gets now, from 1 (a worked example) to 5 (the whole map on their own). We write it when a level changes and the first time a part is finished. gap: the score the student predicted, next to the score they got. help_: how many times the student tapped Help on each part. Each fact is one triple: the entity is the student's concept (topic/_), the key says what the fact is about, and the value is the sentence. We send it with infer: "triples". By default, POST /facts uses infer: "full", where a model reads the text you send and extracts entities, attributes and relations from it. With "triples", we hand Agent Memory the structured triple ourselves, and it stores it as given. Our code already knows each fact exactly, so extraction could only add time, cost and the chance of a wrong fact. This way memory holds exactly the sentences we wrote and nothing more, so the privacy rule above is easy to check. Agent Memory also records these rows with the source upsert, which starts at a trust of 0.95, the highest default in its provenance model. The labels carry the numbers, plus the session, the turn and the agent whose work the fact records, and they came back exactly the way we sent them. Each fact goes out in its own request: async write(scope, labels, triples, signal) { await call("POST", "/facts", { infer: "triples", scopes: [[scope]], memory_category: "knowledge", labels, triples }, signal); }, for (const write of writes) { if (stop?.aborted) break; await api.write(scope, labelsOf({ session: session.id, turn: session.turn, ...write.labels }), [{ entity, key: write.key, value: write.value, occurred_at: at }], AbortSignal.timeout(writeMs)); } api/src/memory.ts L243-245 and L504-513, with the error handling taken out. This is the request behind the gap fact the coach cites below, without the pseudonym and session ID: POST {endpoint}/api/v1/{context}/facts { "infer": "triples", "scopes": [["learner/"]], "memory_category": "knowledge", "labels": ["session=", "turn=", "writer=coach", "predicted=4", "score=2"], "triples": [{ "entity": { "type": "topic", "name": "_jwt" }, "key": "gap", "value": "Last time on JWT you guessed 4 and proved 2.", "occurred_at": "" }] } Keeping the history of how a student improves When a student's level on a part changes, we write the same key again, and this is where long-term memory earns its place. Agent Memory sets supersedes on the new row and closes the old one with validUntil by itself, and our code doesn't touch either. SurrealDB's docs call this "closed in time, not erased." So Lumente always reads the current level, and the history of how the student got there stays readable. The Memory sheet shows students that history. Next to each level, it shows how many agent calls received that fact, which we count from our own logs. The Memory sheet before and after a JWT session. On the left: the last result and the one before it. On the right: the new result superseded the last one, which stays in the history, and the read counts went from 1 to 3. Reading it back Agent Memory has one route that returns an entity's full history. When we measured it on the live service, it took 290 to 370 ms, well inside our 1.5-second budget. Before the hint giver or the coach runs, the API calls that route, keeps the newest value of each key, and skips any rows from the current session. Each agent gets at most 8 facts: the current level of each part, then the latest gap, then the parts that took the most Help. The hint giver and the coach read the same scope, so a hint can use a level that the evaluator's verdicts set. export function factsOf(rows: readonly MemoryRow[], concept: string, exceptSession: string): MemoryFact[] { const chains = chainsOf(rows.filter((row) => labelOf(row, "session") !== exceptSession)); const current = (key: string): MemoryFact[] => { const row = chains.get(key)?.[0]; const fact = row ? factOf(row, concept) : null; return fact ? [fact] : []; }; const levels = MOVE_ORDER.flatMap((move) => current(`level_${move}`)); const helps = MOVE_ORDER.flatMap((move) => current(`help_${move}`)).sort((a, b) => helpsOf(chains, b) - helpsOf(chains, a)); return [...levels, ...current("gap"), ...helps].slice(0, MEMORY_FACTS_MAX); } api/src/memory.ts L332-342. The history read is GET /entities/topic/{name}/history (L247-265). Citing a fact by its ID A remark like "your guess was closer than last time" only helps if the student can trust it. Every row in Agent Memory has its own ID, so a citation can point at one exact fact. We put each fact in the prompt as one line with its ID, and the prompt tells the agent to use only those facts and to list the IDs it used in cites. This is the coach's exchange from one of our JWT sessions: - ... - kdjcdbq1pq1mbg9du2si: Last time on JWT you guessed 4 and proved 2. - a9f8jkmhssc13sdy8eze: 'Bigger group' took no Help on JWT last time. - ... { "message": "... Your guess was closer than last time. ...", "cites": ["kdjcdbq1pq1mbg9du2si"] } Claude could still make up an ID we never sent, so the API checks every hint and coach reply with verifyCites. It keeps only the IDs we sent and logs the rest, which means a made-up source never shows up on screen. We have a test where the hint giver cites a fake ID, and only the real one reaches the student. export function verifyCites(output: T, memory: LearnerMemory | undefined): { output: T; dropped: string[] } { if (!output.cites) return { output, dropped: [] }; const given = new Set((memory?.facts ?? []).map((fact) => fact.id)); const kept = [...new Set(output.cites)].filter((id) => given.has(id)); const dropped = output.cites.filter((id) => !given.has(id)); return { output: { ...output, cites: kept }, dropped }; } agents/src/memory.ts L7-13. The student sees each fact we kept under the message, after "From what Lumente remembers:". "Bigger group" is what we call the part that the app shows as "Category". Left: a hint citing row a9f8jkmhssc13sdy8eze. Right: the coach citing row kdjcdbq1pq1mbg9du2si, in a session with a guess of 5 and a score of 6. The step without a model Memory can't change a score, because the code that sets the scores never sees it. advance() is plain code with no model in it, and it decides what happens next and sets every level and point. Our six Claude agents judge what the answers mean and write the words. The API hands advance() a wrapped callAgent. The wrapper adds the facts to the hint giver's and the coach's input and leaves every other call alone, and advance() only carries the verified IDs onto the message. export function withMemory(callAgent: CallAgent, recall: () => Promise): MemoryTurn { let read: Promise | null = null; let facts: readonly MemoryFact[] = []; return { async callAgent(agent, input) { if (agent !== "hint_giver" && agent !== "coach") return callAgent(agent, input); read ??= recall(); const memory = await read; facts = memory?.facts ?? []; return callAgent(agent, memory ? { ...(input as object), memory } : input); }, given: () => facts, }; } api/src/memory.ts L554-567, used at api/src/app.ts L325-326. The evaluator, whose verdicts feed the score, never gets memory, so a wrong, old or deleted fact can't change a number. Sessions and scores stay in SurrealDB Cloud, separate from Agent Memory. If memory is slow, empty or turned off, the two agents get the same input they had before we added memory, without a block, and the session keeps going. Their instructions tell them to use only the facts in and never to make up an earlier session. Notes from the live service Three details are worth planning for if you build on Agent Memory. We first saw them on Oct 23 and our code handles all three, and the service may have changed since. A key with a dot came back with an underscore, so we use underscores in our keys (level_supraordinate). When we sent parallel POST /facts calls for one entity, some of them returned 500 "unknown error". One at a time, they all went through at about 900 ms each, so we write them in order, off the request path, and the student never waits on a write. POST /scopes/forget reported more rows than we wrote, because it counts across tables. Early results At Build North 2026, Lumente placed in the top 10 of more than 50 teams and was runner-up for Best Use of SurrealDB Agent Memory. We also asked a few people to try it, most of them developers. Several told us the same thing: when they broke down a concept they thought they knew, they hit a wall, and realized they had never asked themselves what the concept itself was. One student put it this way: "I thought I knew the concept well enough to explain it. [...] When I started breaking it down, I got to see the fundamentals that were usually overlooked. [...] It made me question: what do I actually know? And how well do I actually know it?" What's next Next is Check the AI. Today Lumente practices questioning, interpretation, analysis, explanation and self-examination, on concepts: figuring an idea out, telling it apart from similar ones, explaining it in your own words, and checking your prediction against what you proved. Check the AI adds evaluation and inference, practiced on what an AI tells you: judging whether its answer holds up and what follows from it. Questioning stays underneath both. We'll start it once learners show they improve on the concepts. Memory matters even more there. Judging what an AI tells you is a skill that grows over many sessions, never in one. Without memory, each session starts from zero and that growth stays invisible, to the student and to us. With it, Lumente can show a student, fact by fact, how their judgment of AI is getting sharper. Team Name Title What they did Handles Cynthia Salazar Product lead The concept and the research behind it, the AI agents, the orchestrator and their evals, the design direction LinkedIn: cynthia-salazarb, GitHub: CynthiaSalazarB, Instagram: @cynthias.dev Leo Atienza Back end and Agent Memory The API and hosting, Agent Memory, saved sessions, Expo builds and sign-in LinkedIn: leo-atienza-itsm, GitHub: Leo-Atienza, Instagram: @joachimatienza Kyle Argallon Pitch and marketing The presentation and pitch, user feedback, the promotional video and marketing LinkedIn: kjaworks You can find the memory code in api/src/memory.ts and the contract in agents/CONTRACTS.md.
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to