The profile
Where memory is a long, searchable list of individual sentences, the
Profile is the one small object every other part of Novaterra reads when it needs to know you
right now, without doing a search:
{ beingId, facts: Record<string, Json>, // "city": "Lisbon", "code editor": "VS Code" goals: string[], routines: Array<{ name: string, when: string, detail: string }>, preferences: Record<string, Json>, tone: string, // default "warm, concise" — how things should sound, addressed to you timezone: string, // default "UTC" updatedAt: ISODate,}Every being gets a profile row the moment anything asks for it — getOrCreateProfileRow() creates
one lazily with sane empty defaults rather than requiring a setup step.
GET /api/twin/profile → ProfilePATCH /api/twin/profile → Profile (partial; replaces facts/preferences/etc. wholesale per key given)Who reads it
- The omnibar’s
askintent pulls up to eightfactsentries straight into the system prompt so a quick question can use what you know without a memory search — see Signals and the omnibar. - The Now widget (
GET /api/desktop/now) readsroutinesto computenextRoutineandtimezoneto compute the time of day for the greeting. - Muse reads
toneto shape how it writes back to you, and the full profile is folded into its system prompt alongside the soul — see Muse. - The learning loop’s reflection pass reads the whole profile as context before deciding what’s worth surfacing, and writes back into it when it’s confident about something new (see The learning loop).
How it’s edited
There is deliberately no dedicated “edit profile” form beyond the raw PATCH route and the panel on
/twin that wraps it (ProfilePanel.tsx) — the profile is meant to be mostly written by the
learning loop, not filled in like a job application. Direct edits are logged the same way
learned ones are: a PATCH records a learned-kind Twin activity
reading “You edited your profile by hand.”
Merge semantics for automatic updates
When the learning loop produces a ProfileDelta ({ facts, goals, preferences, routines, tone? }),
mergeProfile() applies it with different rules per field, so accumulation never becomes noise:
factsandpreferences— merged key-by-key; a new value for an existing key overwrites it (the model is trusted to correct stale facts, e.g. a city that changed).goals— appended and de-duplicated case-insensitively, capped at 40.routines— matched by name (case-insensitive); an existing routine is updated in place rather than duplicated, also capped at 40.tone— only overwritten when the delta actually supplies a non-empty string; otherwise the current tone is kept.
A concrete profile
The Chrome-history import (see Connecting Chrome history) is a
good example of a source that writes real facts/preferences — a clustering call over your top
30 days of domains might merge in:
{ "facts": { "code editor": "VS Code" }, "preferences": { "reads": "Hacker News, r/programming" }, "goals": [], "routines": []}GET /api/twin/profile afterwards:
{ "beingId": "b_owner...", "facts": { "code editor": "VS Code" }, "goals": [], "routines": [{ "name": "Morning check-in", "when": "08:30", "detail": "Coffee and the Now widget" }], "preferences": { "reads": "Hacker News, r/programming" }, "tone": "warm, concise", "timezone": "UTC", "updatedAt": "2026-09-06T09:01:44.000Z"}profileSummary(profile) (apps/api/src/twin/memory.ts) is the compact, prompt-ready rendering of
this object used everywhere an LLM call needs “who is this” in a few lines rather than the raw JSON.