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.
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.
Add contextual variables to this entry for better analysis.
Add contextual variables to this entry for better analysis.
No variables yet — type a name to create one.
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.
Add new person
No conditions yet. Add your first condition below.
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.
No goals yet. Create goals to link them here.
The empty state is written as goals.length === 0 && rather than a ternary, so it is a sibling of the list rather than a replacement for it. With zero goals that reads the same; it is only worth noting because the pattern differs from every other empty state on this page.
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.
Confirm consent above to choose a file.
Couldn't detect any messages in this file. Make sure it's a WhatsApp "Export chat" .txt (without media).
Failed to read the file. Please try again.
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.
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.
1284 messages, 2026-01-14–2026-07-09
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 "".
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.
“Caught up with Tomas about the reporting rewrite, he wants the first cut by Friday.”
“Caught up with Tomas about the reporting rewrite, he wants the first cut by Friday.”
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.”
Value: 09:00-11:30
“The only hours I get anything real done are before lunch.”
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).
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.