Context Graph

The layer that turns a calendar into a personal operating system. A block in Flux is not just a time and a title: it involved people, happened somewhere, used a tool, and ran under conditions you were in. These components capture that, resolve messy real-world names onto durable entities, and hand a human the final say over anything the AI thinks it found.

Linking a block to the world

3 components

Flux calls them variables: the who, where, what and under-what-conditions of a block. There are exactly five kinds (person, place, tool, topic, condition), and every one of them is the same row in the same table, which is why a person can be typed into a calendar entry and show up on the People page a second later.

EntryVariablesPicker
The card inside the entry sheet where you say what a block actually involved. Search across every variable you own, filter by kind, and create one on the spot if the name you typed does not exist yet. This is the only place in the product where a user hand-feeds the context graph.
src/components/calendar/task-details-sheet.tsx
Empty · nothing linked

Add contextual variables to this entry for better analysis.

No variables linked yet.
Linked · condition is the only outline badge

Add contextual variables to this entry for better analysis.

Priya Raghavan Home office Figma Low energy
Picker open · filter All
Picker open · filter People
No candidates · no search text

No variables yet — type a name to create one.

Search with no exact match · offers to create

The kind filter doubles as the create target. Whatever kind is selected when you hit Create is the kind you get, and the label follows it: with a kind selected the button reads Create "X" (person), singularised by stripping a trailing "s" from the filter label. With All selected there is no kind to infer, so the code falls back to topic and the button says (tag). That is the one place in Flux where the user-facing word for topic is "tag", and the picker's own filter row calls it "Tags" while the underlying kind stays topic.

VariableKindCard
One card per kind on the Variables page: filter what you have, see what the AI added for you, and add one by hand. Five of these render side by side, and the person card grows two extra fields the others do not have.
src/app/(app)/variables/page.tsx
People · populated, with an AI-created variable
People
Individuals involved in your tasks. Can be identified by name, email, or phone.
Priya Raghavan Tomás Adeyemi Ingrid Halvorsen AI

Add new person

Loading · three fixed-width skeletons
Places
Locations where work gets done.
Empty · the string names both the plural and the singular
Conditions
The state or context of your work.

No conditions yet. Add your first condition below.

Non-person kind · no email or phone field

Add new tag

The filter searches fields the card does not show. Typing matches on name, email and phone, so a person can appear for a query that is nowhere on screen. Two more quirks worth knowing: the filter applies to the badge list but not to the empty state, which is keyed off the unfiltered count, so filtering everything out leaves a blank row rather than a "no matches" message. And the skeletons only show while loading && variables.length === 0, so a refresh with cached variables never flashes them.

Importing a conversation

3 components

The bulk route into the graph. A WhatsApp export is a text file full of names that mean nothing to a database, so the import is a wizard with a human in the middle: consent, parse, then a row-by-row decision about which name is which person. The raw file is parsed in the browser and never uploaded.

WhatsAppImportDialog
Turns a WhatsApp "Export chat" .txt into one conversation plus a set of linked people. A genuine six-state wizard: the consent checkbox gates the file input, the parse gates the review step, and the review step is where you decide whether each sender is someone you already know.
src/components/conversations/whatsapp-import-dialog.tsx
1 · No consent · file input disabled
Import a WhatsApp chat
Use WhatsApp's "Export chat" (without media) to get a .txt file. It is parsed entirely in your browser — the raw file is never uploaded.

Confirm consent above to choose a file.

2 · Consented · no file chosen yet
3 · Parse error · zero messages detected

Couldn't detect any messages in this file. Make sure it's a WhatsApp "Export chat" .txt (without media).

4 · Parse error · the file could not be read

Failed to read the file. Please try again.

5 · Parsed · review and map every participant
1284 messages 2026-01-14 – 2026-07-09 3 participants
Priya Raghavan
Priya Raghavan
Tomás
Create new person
+1 555 0142
Don't link
6 · Committing · everything locked

Submit needs all three: consent && parsed && !committing. Each participant's Select is a union of three option groups: __new__ ("Create new person"), __skip__ ("Don't link"), and one entry per person you already have. On parse, senders are pre-matched to existing people by exact case-insensitive name and everything else defaults to Create new person, so an unreviewed import creates people rather than dropping them. Note the badge row and the summary use an en dash between dates while the dialog description uses an em dash: reproduced as shipped.

AddPersonDialog
The smallest dialog in Flux: one field. It exists because creating a person needs to be cheap, and everything else about them can be filled in later from the detail dialog it opens straight into.
src/app/(app)/people/page.tsx
Empty · Add disabled
Add a person
Create a person to track conversations, cadence, and notes.
Named · Add enabled
Add a person
Create a person to track conversations, cadence, and notes.

Enter submits, but only when the name is non-blank. On success the dialog closes and immediately opens PersonDetailDialog for the person it just made, so "add someone" and "tell me about them" are one gesture rather than two.

PersonDetailDialog
Everything Flux knows about one person: how to reach them, when their birthday is, what you wrote down, and every conversation they appear in. The timeline at the bottom is not stored on the person; it is computed by asking which conversations list them as a participant.
src/app/(app)/people/page.tsx
With an interactions timeline
Priya Raghavan
Last contact: 2026-07-09

Interactions (2)

WhatsApp: Priya Raghavan 2026-07-09

1284 messages, 2026-01-14–2026-07-09

Reporting rewrite kickoff 2026-06-02
Never contacted · no conversations linked
Ingrid Halvorsen
Last contact: never

Interactions (0)

No conversations linked to Ingrid Halvorsen yet.

Saving rewrites only two of the handles. Phone and email are rebuilt from the two inputs; every other handle kind is filtered out and re-appended untouched, which is what stops a WhatsApp import's sender name from being destroyed the first time someone types a phone number. Blank fields are dropped rather than stored empty, so clearing the email field removes the handle instead of saving "".

Arbitrating what the AI found

2 components

The pipeline reads your journals, conversations and check-ins and proposes entities. It does not get to create them. Everything it proposes lands here as a candidate with a confidence score and the sentence it came from, and a human picks one of three doors. The decision is recorded on the row either way: a rejection is training data too.

CandidateCard
One thing the AI thinks it found, with the evidence quoted underneath it. Accept creates the real entity and a provenance link back to the source. Merge teaches an existing variable a new alias, so the same person spelled three ways stops being three people. Reject records the no.
src/app/(app)/context-inbox/page.tsx
Variable candidate · all three doors
Tomas Adeyemi
journal entry 91%

“Caught up with Tomas about the reporting rewrite, he wants the first cut by Friday.”

Merge into…
Merge target chosen · Merge enabled
Tomas Adeyemi
journal entry 91%

“Caught up with Tomas about the reporting rewrite, he wants the first cut by Friday.”

Tomás Adeyemi
Non-variable candidate · no Merge control at all
Mornings are unusable after a late call
mental snapshot 64%

Every time a call runs past 21:00 the next morning is written off.

“Third week running I have lost the morning after a late call. Something has to give.”

Profile fact · renders a Value row
preferred_focus_window
journal entry 42%

Value: 09:00-11:30

“The only hours I get anything real done are before lunch.”

Busy · every action disabled mid-decision
Studio
conversation 73%
No evidence · the quote block disappears
(untitled)
n/a

Merge is not just deduplication, it is teaching. It appends the candidate's spelling to the target's alias list, bumps usageCount and stamps lastSeenAt, so the next extraction resolves that spelling without asking. If the alias is already known it records the mention and says so instead ("Recorded mention of X."). Three things worth knowing: the Merge control only appears for variable_* candidates that already have at least one target of the same kind, so the first person you ever extract can only be Accepted or Rejected; the source badge is the raw source_type with underscores swapped for spaces, which is why it reads "journal entry" in lower case; and the title falls back through name → title → key before landing on the literal string (untitled).

ConfidenceBadge
How sure the extractor is, as a percentage with a variant attached. Small enough to miss, but it is the only thing on a candidate card telling you how hard to look before you click Accept.
src/app/(app)/context-inbox/page.tsx
All four branches of the union
91%85 and above → default
64%50 to 84 → secondary
42%below 50 → outline
n/anull → outline, literal "n/a"

A null score and a low score look identical. Both render as an outline badge, so "the model was 42% sure" and "nobody recorded a score" are the same pixels. The percentage is rounded from a 0 to 1 float at render time and is never stored as a percentage.