Skip to content

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 → Profile
PATCH /api/twin/profile → Profile (partial; replaces facts/preferences/etc. wholesale per key given)

Who reads it

  • The omnibar’s ask intent pulls up to eight facts entries 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) reads routines to compute nextRoutine and timezone to compute the time of day for the greeting.
  • Muse reads tone to 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:

  • facts and preferences — 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.