An agent that remembers you is almost always judged in chat. It recalls something, it says the thing, and if the thing is wrong you can see that it's wrong — it's a sentence, in your reading flow, and you push back. "No, I moved." Memory corrected, one turn, no damage.
Then we wired the same memory into an input box.
We build Xenition, an AI workspace — chat on one side, and on the other a couple of hundred small things you can actually open and use: documents, decks, forms, calculators, converters. Two surfaces, one signed-in user, one memory. Connecting them was the obvious feature, and it took us a while to notice we'd changed what memory is.
A user tells the assistant, in passing, in some conversation weeks ago, that they're 173 cm and 70 kg. Later they open the BMI calculator and both fields are already filled. It feels like magic the first time. It is genuinely good product.
It is also the exact moment the safety properties you were relying on quietly stop applying — because a prefilled field doesn't get read. It gets submitted.
Every example below is our own code rather than a paraphrase, because those are the ones I can quote exactly. None of it is product-specific — if your agent remembers anything and your UI has a form, you have this problem too.
I want to write about that transition, because the agent-memory conversation right now is almost entirely about what memory says. Three of this week's most-read #agents posts are about provenance, authority ranking, and remembering decisions instead of data. All correct. But nothing changes the risk profile of a memory system as much as changing where its output lands, and I haven't seen anyone write that part down.
A remembered fact has two consumers, and only one of them can hedge
This is the whole idea, so let me put it plainly.
When a remembered fact becomes a sentence, it can carry doubt. "I think you mentioned 70 kg — still right?" is a completely legitimate output. Hedging is free. It's one clause.
When the same fact becomes a value, hedging is impossible. There is no way to type "probably 70" into a number input. The field is either empty or it is an assertion — and it's an assertion sitting inside the user's form, above the user's submit button, which means it is now attributed to the user, not to the model.
| fact as a sentence | fact as a field value | |
|---|---|---|
| Attributed to | the assistant | the user |
| Wrongness looks like | a claim you can dispute | a default you skim past |
| Hedging | natural, free | impossible |
| Cost to correct | one reply | you have to notice it first |
| Blast radius | that turn | whatever the form does |
Two consequences fall out of this, and they're the load-bearing rules for everything below:
- The confidence bar for writing into a field is higher than the bar for mentioning in chat. A hunch is an acceptable sentence. A hunch is not an acceptable default.
- The UI has to supply the hedge the value can't carry. If the value can't say "probably," the interface around it has to.
The shape of the thing
For context, the pipeline Xenition ended up with. Nothing exotic:
chat turn (signed in)
→ engine extracts durable personal facts → long-term memory, keyed by verified user id
later, user opens a tool/form
→ client sends the field SCHEMA (name, label, type) + instantly-known profile facts
→ server does extractive recall over that user's memory
→ returns { values: { heightCm: 173, weightKg: 70 } }
→ client merges into empty fields only
We built it for forms first — smart forms that fill themselves from what you've already told the assistant — then went to extend it to the roughly two hundred tools next door — and it was that second pass that forced us to actually write the rules down, because the first version had at least three of the bugs below and we'd been lucky.
Rule 1: the user id must be injected by whoever verified it
Start with the one that's a security bug rather than a UX bug.
Most "assist" endpoints in a gateway are a thin passthrough. Client posts a body, gateway forwards it to the engine, engine answers. That's completely fine while the body contains only what the user typed.
Memory recall is the first endpoint where the body has to say whose memory to read. And if that identifier arrives from the client, you have built a cross-tenant read that anyone can perform by editing one JSON field in devtools.
So this endpoint doesn't get the generic passthrough. It gets its own handler, whose entire job is to throw away any client-supplied id and inject the one the auth layer verified:
// dedicated handler, NOT the generic /v1/assist/* passthrough
uid, ok := auth.UserID(r.Context()) // verified at the edge, never from the body
if !ok {
uid = "" // anonymous: profile facts only, no recall
}
body.UserID = uid // overwrite unconditionally
Ours is 63 lines and exists purely for that overwrite. Worth every one of them. The rule generalizes: the first time a request body needs to name a principal, your proxy stops being a proxy.
Anonymous is a real case and should work — it just falls back to facts the client already legitimately has (locale, timezone, currency). No recall, no error, no login wall.
Rule 2: extractive only. Empty beats plausible.
If you hand a generative model a field labelled "Weight (kg)" and ask it to fill the form, it will fill the form. It will not return empty. Models are extremely good at producing a plausible adult human.
And plausible is precisely the failure mode, because at the point of use, an invented value and a recalled value are indistinguishable. They're both just a number in a box. The user has no way to tell which one they're about to submit.
So the recall path is strictly extractive: if the fact isn't in memory, the field stays empty. Not "estimated." Not "typical." Empty.
An empty field costs the user four seconds of typing. An invented field costs them a wrong answer with their own name on it.
This is also the single easiest thing to get wrong when you swap the model or rewrite the prompt, and it will not show up in any test that only asserts the response shape — the shape is perfect. Assert on the behaviour: given a user with no stored weight, the weight key must be absent.
Rule 3: precedence, and a merge that cannot clobber
Once more than one thing can fill a field, you need a stated order. Ours, highest wins:
- What the user has currently typed — always, no exceptions
- An explicit one-shot prefill — they clicked "Open" on a suggestion carrying values
- Memory recall — the background thing we're discussing
- Context defaults — currency from locale, language from browser
And the merge is fill-empty-only. Never a spread, never a patch that assumes it arrived first:
const patch: Record<string, unknown> = {}
for (const [k, v] of Object.entries(recalled)) {
const cur = current[k]
const isEmpty = cur === undefined || cur === null ||
(typeof cur === 'string' && cur.trim() === '')
if (isEmpty && String(v ?? '').trim() !== '') patch[k] = v
}
The bug this prevents is not hypothetical and it is horrible when it happens: the user types their real weight, the recall lands 300 ms later, and the field changes under their cursor to a number from last year. They will not notice. They will submit it.
Which brings us to the timing.
Rule 4: recall races the user's first keystroke, and you lose that race in public
Recall is async and the form is already on screen. So every apply has to re-check the world at apply time, not at request time:
useEffect(() => {
if (!openId) return
const ctrl = new AbortController()
void recall(tool, ctrl.signal).then((values) => {
if (ctrl.signal.aborted) return
const s = store.getState()
if (s.openId !== openId) return // they already closed it or switched
applyFillEmptyOnly(s, openId, values) // re-reads current input, see Rule 3
})
return () => ctrl.abort()
}, [openId])
Three separate guards — aborted, still-the-same-surface, still-empty — and all three fire in practice. The middle one matters more than it looks: users open a tool, glance, close it, open a different one. A recall for the previous surface landing in the current one writes a stranger's-looking value into an unrelated form.
There's an honest limitation here. Views that hold field state in local useState at mount won't see a late-arriving recall at all. You either lift those fields to the store, or you fall back to the affordance in Rule 5 — which, it turns out, is the better answer anyway.
Rule 5: give the value the hedge it can't carry — and respect fact half-life
Back to the thesis. The value can't say "probably," so the interface has to.
The cheap version is a chip in the footer rather than a silent write:
Fill from memory: 173 cm, 70 kg [Apply]
One tap, same convenience, completely different accountability — the user chose it, so it's genuinely theirs now.
The question is when to spend that tap, and the answer nobody seems to write down is that personal facts decay at wildly different rates. Provenance discussions usually stop at "where did this come from." The more actionable axis is "how long is this true for":
| Fact | Practical half-life | Prefill silently? |
|---|---|---|
| Date of birth | Never expires | Yes |
| Name, email | Years | Yes |
| Height (adult) | Years | Yes |
| Home city | Months to years | Yes — but never overwrite a location they set by hand |
| Weight | Weeks | Offer, don't fill |
| Salary, job title | Steps at unpredictable moments | Offer, don't fill |
| "Current project," "what I'm working on" | Days | Don't store as a fact at all |
Date of birth is the ideal memory fact: stated once, true forever, tedious to type. In Xenition it silently fills the age calculator, the date-difference tool, and half a dozen forms, and it has never once been wrong. Weight is the worst: it changes silently, the user never thinks to update the assistant, and it feeds calculations that look authoritative. Same storage, same retrieval, entirely different write policy.
If you keep one thing from this post, keep that: the decay rate of a fact should decide whether it gets written or offered. Not the recall confidence score. The confidence is about whether they said it. It tells you nothing about whether it's still true.
Rule 6: "memory off" has to kill the read, not just the write
Every product with memory has a switch in settings. Two things to check, because the second is regularly missed:
- The switch must short-circuit recall, not just storage. A user who turns memory off and then sees a form politely fill itself with facts about them has learned something about your switch, and they're going to post about it.
- The client-side check is a courtesy that saves a round trip. The server-side check is the feature. Anything enforced only in the client is enforced nowhere.
And while you're there: recall must never throw and never block. If the memory service is down, the form opens empty and on time. {} on any failure, no spinner, no error toast. Memory is an enhancement, and an enhancement that can break the primary interaction isn't one.
Rule 7: the schema is a contract, and nothing tells you when it breaks
The client declares each field as { name, label, type }, and name has to exactly match the state key the view reads. That's the whole contract.
Which means renaming a state key — an ordinary, safe-looking refactor — silently disables prefill for that field. No error. No type failure, because it's a Record<string, unknown> crossing a network boundary. No test failure, because the endpoint still returns 200 with a perfectly-shaped body. The feature just quietly stops existing, and you find out in a month when someone asks whether prefill was always this bad.
The check I want is a build-time assertion that every declared field name appears in the state keys its view actually reads. I don't have it yet — it's the honest gap in this post. Right now what we have is one end-to-end test per prefillable surface, which is more work and less coverage than the static check would be.
What still doesn't work
- We don't store per-fact timestamps yet. So the half-life table above is a policy expressed in an extraction prompt, not something enforced by data. It should be a field on the memory record. It will be.
- There's no fact editor. Users can turn memory off entirely; they cannot open a list and fix the one number that's wrong. "Off" is a blunt instrument to hand someone whose only complaint is a stale weight.
-
Normalization is invisible from the client. "I'm 5'8"" →
173happens engine-side. When it's wrong, the client sees a plausible number and has no way to know it was derived rather than stated. - No static check on the field-name contract (Rule 7).
- We measure recall coverage, not recall correctness. Percentage of fields filled is easy and slightly dangerous — it goes up when the system gets more willing to guess, which is the direction you don't want.
The checklist
If you're about to let agent memory touch a form:
- Inject the user id server-side. The moment a body names a principal, stop using the generic passthrough.
- Extractive only. No stored fact → empty field. Test the absence, not the shape.
- State a precedence order and merge fill-empty-only — never overwrite what the user typed.
- Re-check at apply time, not request time: aborted, same surface, still empty.
- Sort facts by half-life, not by confidence. Slow-decaying facts fill; fast-decaying facts offer.
- Mark anything that came from memory and make clearing it one click.
- "Memory off" kills recall too, enforced on the server.
-
Recall never throws, never blocks, never spins.
{}and move on.
None of this makes memory smarter. It makes it accountable, which at the point where a remembered fact turns into a value someone is about to submit under their own name, matters considerably more.
If you've built per-fact expiry — real timestamps and a decay policy, not a prompt asking the model to be sensible about it — I'd like to read about it. That's the piece I'm missing.
I work on Xenition — one AI workspace for documents, decks, code, apps and media, with the chat and the tools sharing a single memory. Free to start, on web, desktop and both app stores. It remembers your date of birth. It asks about your weight.
Top comments (0)