Knotter Documentation
Knotter is a local-first relationship tracker for staying in touch with people. It focuses on privacy, predictable workflows, and transparent data storage.
This book documents the design, data model, and user-facing behavior. For a quickstart, see the repository README.
Start here
- Architecture for the system layout and boundaries.
- Database Schema for the SQLite schema and migration rules.
- Configuration for setup-specific config examples.
- CLI Output for the stable JSON/human output contract.
- Shell Completions for installing tab completion scripts.
- Scheduling for reminder automation.
- Import/Export for vCard/ICS/JSON snapshot behavior.
- Keybindings for TUI navigation.
- Packaging for release and local build notes.
Knotter Architecture
knotter is a personal CRM / friendship-tracking tool designed for terminal-first workflows. It is built as an offline-first Rust application with a CLI and a TUI, backed by a portable SQLite database, with standards-based import/export.
This document describes:
- the workspace layout and module boundaries
- the core domain model and invariants
- storage (SQLite) schema and migrations
- error handling conventions
- filtering/query semantics
- import/export design
- reminders/notifications architecture
- testing and operational expectations
1. Design goals
Primary goals
- System-agnostic: knotter should run on most Linux/Unix machines with minimal assumptions.
- Offline-first: local database is the source of truth; the app remains useful with no network.
- Terminal-first UX: fully usable via CLI; TUI provides fast browsing/editing.
- Personal CRM features:
- contacts (people)
- interactions/notes (relationship history)
- scheduling next touchpoint (future intent)
- Tags + filtering: quickly list contacts by category and due state.
- Interchange:
- export/import contacts via vCard (.vcf)
- export touchpoints via iCalendar (.ics)
- Optional syncing with macOS apps (post-MVP): via standards-based CardDAV/CalDAV, not by poking at Apple local databases.
Non-goals (MVP)
- Full bidirectional sync with iCloud “local” stores.
- Running a mandatory background daemon.
- AI features or “relationship scoring” heuristics.
- Complex recurring schedules (beyond basic cadence + next date).
2. High-level architecture
knotter is split into layers to keep UI and persistence separate from business logic.
Layer overview
- knotter-core
- Pure domain types and business rules.
- No SQLite, no terminal, no file I/O.
- knotter-config
- Loads config from XDG paths or
--config. - Validates values and enforces strict permissions (where supported).
- Loads config from XDG paths or
- knotter-store
- SQLite persistence, migrations, and repositories.
- Converts between DB rows and core domain types.
- knotter-sync
- Import/export adapters:
- vCard (.vcf) for contacts
- iCalendar (.ics) for touchpoints
- Telegram 1:1 sync (snippets only)
- Future: CardDAV/CalDAV sync (optional).
- Import/export adapters:
- knotter-cli
- CLI frontend (commands, argument parsing).
- Calls core/store/sync; prints output; exit codes.
- knotter-tui
- TUI frontend using Ratatui + Crossterm.
- Maintains application state and renders views.
- knotter (bin)
- Small binary crate that wires everything together.
- Can expose both CLI and
knotter tui.
Dependency direction (must not be violated)
- knotter-core: depends on (almost) nothing; ideally only
uuid,serde(optional), and a time crate. - knotter-config: depends on core for validation helpers.
- knotter-store: depends on core + SQLite libs.
- knotter-sync: depends on core + parsing/generation libs; may depend on store when import wants to upsert.
- knotter-cli / knotter-tui: depend on core/config/store/sync; never the other way around.
3. Workspace layout
Recommended structure:
Cargo.toml(workspace root)crates/knotter-core/src/lib.rsdomain/(contact.rs, interaction.rs, tag.rs)rules/(due.rs, cadence.rs)filter/(parser.rs, ast.rs)error.rs
knotter-config/src/lib.rs(XDG config lookup + TOML parsing)
knotter-store/src/lib.rsdb.rs(connection/open, pragmas)migrate.rsrepo/(contacts.rs, tags.rs, interactions.rs)error.rs
migrations/001_init.sql002_...sql
knotter-sync/src/lib.rsvcf/(import.rs, export.rs, mapping.rs)ics/(export.rs, mapping.rs, uid.rs)error.rs
knotter-cli/src/main.rs(or lib+bin style)commands/(add_contact.rs, list.rs, remind.rs, import.rs, export.rs, ...)output/(human.rs, json.rs)
knotter-tui/src/main.rs(or lib+bin style)app.rs(App state)ui/(render.rs, widgets.rs)modes/(list.rs, detail.rs, modals/)events.rs(input mapping)error.rs
knotter/(optional “umbrella” bin)src/main.rs
The key is: core is reusable and UI crates are replaceable.
4. Core domain model (knotter-core)
4.1 Identity and time
IDs
Use UUIDs for portability and for stable export identifiers. Define newtypes for IDs to prevent mixing them up:
ContactId(Uuid)InteractionId(Uuid)TagId(Uuid)ContactDateId(Uuid)
Time representation
Use UTC timestamps in storage and business logic. Recommended:
- store timestamps as
i64unix seconds (UTC) in SQLite - convert to/from a Rust datetime type at the edges
Define in core:
Timestampwrapper or use a time crate type- always treat DB timestamps as UTC
- “today/due soon” computations use the local machine timezone (MVP) unless contact timezone is explicitly supported later
4.2 Entities
Contact
A contact represents a person you want to keep in touch with.
Core fields:
id: ContactIddisplay_name: String(required, non-empty)email: Option<String>(primary email; additional emails live incontact_emails)phone: Option<String>handle: Option<String>(free text: Discord/IG/etc.)timezone: Option<String>(IANA TZ string; optional MVP)created_at: i64(unix seconds UTC)updated_at: i64(unix seconds UTC)next_touchpoint_at: Option<i64>(unix seconds UTC)cadence_days: Option<i32>(e.g. 7, 14, 30)archived_at: Option<i64>(optional; included in schema but UI support can be post-MVP)
Invariants:
display_name.trim()must not be empty.cadence_days, if set, must be > 0 and within a reasonable range (e.g. <= 3650).next_touchpoint_at, if set, should be a valid timestamp (>= 0 recommended).- Contact emails are normalized lowercase; exactly one may be marked primary.
contact_emails.sourcetracks provenance (e.g., cli/tui/vcf or email account name).
Interaction
An interaction is a timestamped note/history entry for a contact.
Core fields:
id: InteractionIdcontact_id: ContactIdoccurred_at: i64(when the interaction happened; default “now”)created_at: i64(when it was logged; default “now”)kind: InteractionKindnote: String(can be empty, but usually non-empty is better)follow_up_at: Option<i64>(optional per-interaction follow-up date)
InteractionKind:
CallTextHangoutEmailTelegramOther(String)(must be normalized/trimmed)
Invariants:
Other(s)should be stored as trimmed; reject empty.occurred_atshould not be wildly in the future (soft validation; warning not hard error).
Tag
Tags are categories, like “designer”, “family”, “school”, “soccer”, etc.
Core fields:
id: TagIdname: String(normalized)
Normalization rules (must be identical everywhere):
- trim
- lowercase
- replace spaces with
- - collapse repeated
- - reject empty after normalization
ContactDate
Contact dates capture birthdays and other annual milestones.
Core fields:
id: ContactDateIdcontact_id: ContactIdkind: ContactDateKind(birthday,name_day,custom)label: Option<String>(required forcustom)month: u8(1-12)day: u8(1-31, validated against month)year: Option<i32>(optional, used for birthdays/notes)created_at: i64updated_at: i64source: Option<String>(cli/tui/vcf/etc)
Invariants:
customdates require a non-empty label.- Month/day must be a valid calendar day;
Feb 29is allowed without a year. - Date occurrences are evaluated in the local machine timezone (MVP).
- On non-leap years,
Feb 29occurrences are surfaced onFeb 28.
4.3 Business rules
Due state
Given now (UTC) and local timezone rules for “today”:
- Overdue:
next_touchpoint_at < now - Due today: same local date as now
- Due soon: within N days (configurable, e.g. 7)
- Scheduled: anything later
- Unscheduled:
next_touchpoint_at == None
This logic lives in core so both CLI and TUI behave identically.
Cadence helper
If a contact has cadence_days:
- after a “touch” action (or interaction with rescheduling enabled), set
next_touchpoint_at = max(now, occurred_at) + cadence_days(never in the past)
Optional rule (MVP decision):
- If
next_touchpoint_atalready exists and is later thannow, only reschedule if user explicitly requests (e.g., via CLI flags orinteractions.auto_reschedule).
Scheduling guard:
- User-provided
next_touchpoint_atinputs must benowor later. - Date-only inputs are interpreted as end-of-day local time (so "today" remains scheduled).
5. Filter/query language (knotter-core::filter)
Filtering is used by both CLI and TUI. knotter defines a minimal filter string that compiles into a query AST.
5.1 Supported syntax (MVP)
- Plain text token:
- matches
display_name,email,phone,handle - optionally matches recent interaction notes (post-MVP, because it’s heavier)
- matches
- Tag tokens:
#designer(require tag “designer”)
- Due tokens:
due:overduedue:todaydue:soondue:any(any scheduled, including overdue/today/soon/later)due:none(unscheduled)
- Archived tokens:
archived:true(only archived contacts)archived:false(only active contacts)
Combining:
- Default combination is AND across tokens.
- Multiple tags are AND by default:
#designer #foundermeans must have both tags.
- (Optional later) OR groups:
#designer,#engineermeans either tag
Default UI behavior:
- CLI/TUI list views exclude archived contacts unless explicitly included via flags or
archived:true.
5.2 AST types
FilterExprText(String)Tag(String)(normalized)Due(DueSelector)Archived(ArchivedSelector)And(Vec<FilterExpr>)- (Later)
Or(Vec<FilterExpr>)
5.3 Parser behavior
- Tokenize on whitespace.
- Tokens starting with
#become Tag filters. - Tokens starting with
due:become Due filters. - Tokens starting with
archived:become Archived filters. - Everything else becomes Text filters.
- Invalid tokens:
- unknown
due:value -> return parse error - unknown
archived:value -> return parse error - empty tag after
#-> parse error
- unknown
The parser returns:
Result<ContactFilter, FilterParseError>
6. Storage architecture (knotter-store)
knotter-store is the only layer that touches SQLite. It provides repositories that operate on core types and filter ASTs.
6.1 SQLite connection + pragmas
Open DB at XDG data path:
$XDG_DATA_HOME/knotter/knotter.sqlite3- fallback to
~/.local/share/knotter/knotter.sqlite3
Recommended pragmas (document + test):
PRAGMA foreign_keys = ON;PRAGMA journal_mode = WAL;(improves concurrency; safe default for a local app)PRAGMA synchronous = NORMAL;(balance performance/safety)PRAGMA busy_timeout = 2000;(avoid “database is locked” on short contention)
6.2 Migrations
knotter uses numbered SQL migrations stored in crates/knotter-store/migrations/.
Migration requirements:
- A schema version table:
knotter_schema(version INTEGER NOT NULL)
- On startup:
- open DB
- apply migrations in order inside a transaction
- update schema version
- Migrations must be idempotent in the sense that:
- they only run once each
- schema version ensures ordering
6.3 Schema (MVP)
The authoritative SQL schema lives in Database Schema. Keep this document aligned with it.
Summary of MVP tables/indexes:
knotter_schema(version)for migration tracking.contactswitharchived_atincluded for future archiving (unused in MVP UI).tags(normalized),contact_tagsjoin table.interactionswithkindstored as a normalized string.- Indexes on
contacts.display_name,contacts.next_touchpoint_at,contacts.archived_at,tags.name,contact_tagsforeign keys, andinteractions(contact_id, occurred_at DESC).
Notes:
- IDs are stored as TEXT UUIDs.
- Timestamps are INTEGER unix seconds UTC.
6.4 Repository boundaries
Expose repositories as traits in knotter-store (or as concrete structs with a stable API). Avoid leaking SQL details to callers.
ContactsRepository
create_contact(...) -> Contactupdate_contact(...) -> Contactget_contact(id) -> Option<Contact>delete_contact(id) -> ()(hard delete MVP)archive_contact(id) -> Contactunarchive_contact(id) -> Contactlist_contacts(query: ContactQuery) -> Vec<ContactListItem>
ContactListItem is a lightweight projection for list views:
- id
- display_name
- next_touchpoint_at
- due_state (computed in core, not stored)
- tags (either eager-loaded or loaded separately; choose based on performance)
TagsRepository
upsert_tag(name) -> Tag(normalize before upsert)list_tags_with_counts() -> Vec<(Tag, count)>set_contact_tags(contact_id, tags: Vec<TagName>)(replace set)add_tag(contact_id, tag)remove_tag(contact_id, tag)list_tags_for_contact(contact_id) -> Vec<Tag>list_names_for_contacts(contact_ids: &[ContactId]) -> Map<ContactId, Vec<String>>(bulk tag lookup for list views; uses per-call temp table to avoid collisions)
InteractionsRepository
add_interaction(...) -> Interactionlist_interactions(contact_id, limit, offset) -> Vec<Interaction>delete_interaction(interaction_id)(optional MVP)touch(contact_id, occurred_at, kind, note, reschedule: bool)(convenience)
6.5 Query compilation strategy
knotter-core provides a parsed filter AST. knotter-store translates AST -> SQL WHERE + bind parameters.
Rules:
-
Always use bound parameters, never string interpolation (avoid SQL injection even in local tools).
-
For tag filters, use EXISTS subqueries:
- require all tags -> multiple EXISTS clauses
-
For due filters:
- compare
next_touchpoint_atto now and to “today boundaries” computed in Rust
- compare
Implementation note:
-
Because “today” boundaries depend on local timezone, compute:
- start_of_today_local -> convert to UTC timestamp
- start_of_tomorrow_local -> convert to UTC timestamp Then query ranges in UTC.
7. Import/export architecture (knotter-sync)
knotter-sync contains adapters that map between external formats and core types.
7.1 vCard (.vcf)
Import strategy (MVP)
-
Parse each card into an intermediate
VCardContactstructure. -
Map into knotter
ContactCreate+ tags:- FN -> display_name
- EMAIL (all) -> contact_emails (first becomes primary)
- first TEL -> phone
- CATEGORIES -> tags (normalized)
-
Deduplication:
- If email matches an existing contact, update that contact.
- When phone+name matching is enabled, normalize the phone and match by display name + phone.
- Ambiguous matches create merge candidates for manual resolution.
- Archived-only matches are skipped with a warning.
Manual merge candidates are created when imports/sync encounter ambiguous matches
(e.g., multiple name matches or duplicate emails). Candidates are resolved via
knotter merge or the TUI merge list.
Applying a merge marks the chosen candidate as merged and dismisses any other
open candidates that referenced the removed contact.
Some candidate reasons are marked auto-merge safe (currently duplicate-email and
vcf-ambiguous-phone-name), which enables bulk apply workflows.
Import should return a report:
- created_count
- updated_count
- skipped_count
- warnings (invalid tags, missing FN, etc.)
Contact sources (macOS + CardDAV)
Additional sources should convert their data into vCard payloads and reuse the existing vCard import pipeline. This keeps dedupe logic and mapping consistent.
- macOS Contacts: fetch vCards via the Contacts app (AppleScript / Contacts framework); import enables phone+name matching by default to reduce duplicates when emails are missing.
- CardDAV providers (Gmail, iCloud, etc.): fetch addressbook vCards via CardDAV REPORT.
Export strategy (MVP)
-
For each contact:
- emit FN
- emit EMAIL/TEL if present
- emit CATEGORIES from tags
-
Optional: include custom
X-fields for knotter metadata:X-KNOTTER-NEXT-TOUCHPOINT: <unix or iso datetime>X-KNOTTER-CADENCE-DAYS: <int>BDAY: <YYYY-MM-DD, YYYYMMDD, --MMDD, or --MM-DD>(birthday when available)X-KNOTTER-DATE: <kind>|<date>|<label>(name-day/custom dates and extra/labeled birthdays)
Round-trip expectations must be documented:
- Other apps may ignore X- fields (fine).
- knotter should preserve its own X- fields when re-importing its own export.
7.3 Email account sync (IMAP, post-MVP)
Email sync ingests headers from configured IMAP inboxes and maps them into contact emails + interaction history:
- If an email address matches an existing contact, attach it (and record an email touch).
- If it matches none, create a new contact.
- If it matches a unique display name, merge by adding the email to that contact.
- If it matches multiple display names, stage an archived contact and create merge candidates.
- Duplicate-email conflicts create merge candidates for manual resolution.
- Each new message creates an
InteractionKind::Emailentry. - Sync is incremental using
email_sync_state(account/mailbox, UIDVALIDITY, last UID).
7.4 Telegram 1:1 sync (snippets-only)
Telegram sync ingests 1:1 user chats (no groups) and stores snippets only:
- Each Telegram user maps to a contact via
contact_telegram_accounts(telegram user id, username, phone, names). - If a telegram user id is already linked, update metadata + record interactions.
- If no link exists:
- match by username when available (including contact handles)
- otherwise (and only when enabled) match by display name
- ambiguous matches create merge candidates; a staged archived contact holds the telegram id
--messages-onlyskips staging and only attaches to unambiguous matches
- Each imported message inserts:
telegram_messagesrow for dedupeInteractionKind::Telegramwith a snippet note
- Sync state is tracked per account + peer via
telegram_sync_state(last_message_id). - First-time authentication requires a login code; non-interactive runs can provide
KNOTTER_TELEGRAM_CODEand (for 2FA)KNOTTER_TELEGRAM_PASSWORD.
7.2 iCalendar (.ics) for touchpoints
knotter uses calendar events as an export mechanism for scheduled touchpoints.
Event generation rules
-
One event per contact that has
next_touchpoint_at. -
Summary:
Reach out to {display_name}
-
DTSTART:
next_touchpoint_atas UTC or local-floating time (choose one; UTC recommended for simplicity)
-
Description:
- tags and/or a short “last interaction” snippet (optional)
-
UID:
-
stable and deterministic so repeated exports update the same event:
knotter-{contact_uuid}@local(or similar)
-
Export options:
- export window (e.g. next 60 days)
- export due-only
7.3 JSON snapshot export
knotter also supports a JSON snapshot export for portability and backups.
Snapshot rules:
- include metadata (export timestamp, app version, schema version, format version)
- include all contacts with tags and full interaction history
- interactions are ordered with most recent first
- archived contacts are included by default with an
--exclude-archivedescape hatch
Import of ICS back into knotter is post-MVP.
8. Reminders and notifications
knotter supports reminders without requiring a daemon.
8.1 Reminder computation
-
knotter remindqueries scheduled contacts and groups by:- overdue
- due today
- due soon (configurable days)
- dates today (birthdays/custom dates that occur on the local date)
-
“Due soon” threshold is config-driven (default 7).
8.2 Notification interface
Define a small trait in a shared place (either core or a small knotter-notify module, but keep core free of OS calls):
Notifier::send(title: &str, body: &str) -> Result<()>
Backends:
- Stdout (always available)
- Desktop notification (optional feature flag)
- Email (optional feature flag, SMTP via config/env)
Behavior:
- If desktop notification fails, fall back to stdout (do not crash).
- CLI decides whether to notify (
--notify) or just print.
8.3 System scheduling
knotter intentionally relies on external schedulers:
- cron
- systemd user timers
- (optional) macOS launchd for reminders on macOS
knotter provides stable, script-friendly outputs:
--jsonmode for automation- exit codes that reflect success/failure
9. CLI architecture (knotter-cli)
knotter-cli is a thin coordinator.
Responsibilities:
- parse args into command structs
- open DB + run migrations
- call repositories and core functions
- format output (human or JSON)
- set exit codes
Conventions:
- Human output is readable and stable enough for casual scripting.
- JSON output is versioned or at least documented to avoid breaking users.
Error handling:
- Validate obvious bad inputs at the CLI layer (e.g., invalid date format).
- Let store/core return typed errors; convert to friendly messages.
10. TUI architecture (knotter-tui)
The TUI is a state machine with explicit modes.
10.1 Application state model
App holds:
mode: Modefilter_input: Stringparsed_filter: Option<ContactFilter>list: Vec<ContactListItem>selected_index: usizedetail: Option<ContactDetail>(selected contact, tags, recent interactions)status_message: Option<String>error_message: Option<String>- config values (soon window, etc.)
10.2 Modes
ListDetail(contact_id)FilterEditingModalAddContactModalEditContact(contact_id)ModalAddNote(contact_id)ModalEditTags(contact_id)ModalSchedule(contact_id)
Each mode defines:
- allowed keybindings
- how input is interpreted
- which components are rendered
- what side effects occur (DB writes)
10.3 Event loop + side effects
Key rules:
-
Never block the render loop for “long” operations.
-
Use a simple command queue pattern:
- UI produces
Actions - An executor runs actions (DB calls) and returns results
- App state updates with results
- UI produces
For MVP, DB ops are usually fast; still, structure code so you can move DB work to a worker thread if needed.
10.4 Terminal safety
Always restore terminal state:
- on normal exit
- on panic (install panic hook)
- on ctrl-c
11. Error handling conventions
11.1 Typed errors in libraries
Use thiserror in:
- knotter-core
- knotter-store
- knotter-sync
Examples:
FilterParseErrorDomainError(invalid tag, invalid name)StoreError(sqlite error, migration error, not found)SyncError(parse failure, unsupported fields)
11.2 Contextual errors at the edges
In knotter-cli and knotter-tui:
- use
anyhow(or equivalent) for top-level error aggregation and context - convert typed errors to user-friendly messages
11.3 Error message policy
- core/store/sync errors should be actionable but not overly technical
- include debug details only when verbose logging is enabled
12. Configuration and paths
12.1 XDG paths (Linux/Unix)
-
Data:
$XDG_DATA_HOME/knotter/- DB:
knotter.sqlite3
-
Config:
$XDG_CONFIG_HOME/knotter/config.toml
-
Cache:
$XDG_CACHE_HOME/knotter/
Fallbacks:
- if XDG env vars are missing, use standard defaults under
~/.local/share,~/.config,~/.cache.
12.2 Config file (TOML)
Config keys (MVP):
due_soon_days = 7default_cadence_days = 30(optional)notifications.enabled = true/falsenotifications.backend = "stdout" | "desktop" | "email"(email requiresemail-notify)notifications.random_contacts_if_no_reminders = 10(optional; when >0 and reminders are otherwise empty, include random contacts in notifications; max 100)notifications.email.from = "Knotter <knotter@example.com>"notifications.email.to = ["you@example.com"]notifications.email.smtp_host = "smtp.example.com"notifications.email.smtp_port = 587(optional)notifications.email.username = "user@example.com"(optional)notifications.email.password_env = "KNOTTER_SMTP_PASSWORD"(required if username set)notifications.email.subject_prefix = "knotter reminders"(optional)notifications.email.tls = "start-tls" | "tls" | "none"notifications.email.timeout_seconds = 20(optional)interactions.auto_reschedule = true/false(auto-reschedule on interaction add)loops.default_cadence_days = <int>(optional, fallback cadence when no tag matches)loops.strategy = "shortest" | "priority"(how to resolve multiple tag matches)loops.schedule_missing = true/false(schedule when nonext_touchpoint_at)loops.anchor = "now" | "created-at" | "last-interaction"loops.apply_on_tag_change = true/falseloops.override_existing = true/false[[loops.tags]]withtag,cadence_days, optionalpriority
Full config example (all sections + optional fields):
due_soon_days = 7
default_cadence_days = 30
[notifications]
enabled = false
backend = "stdout"
random_contacts_if_no_reminders = 0
[notifications.email]
from = "Knotter <knotter@example.com>"
to = ["you@example.com"]
subject_prefix = "knotter reminders"
smtp_host = "smtp.example.com"
smtp_port = 587
username = "user@example.com"
password_env = "KNOTTER_SMTP_PASSWORD"
tls = "start-tls"
timeout_seconds = 20
[interactions]
auto_reschedule = false
[loops]
default_cadence_days = 180
strategy = "shortest"
schedule_missing = true
anchor = "created-at"
apply_on_tag_change = false
override_existing = false
[[loops.tags]]
tag = "friend"
cadence_days = 90
[[loops.tags]]
tag = "family"
cadence_days = 30
priority = 10
[contacts]
[[contacts.sources]]
name = "gmail"
type = "carddav"
url = "https://example.test/carddav/addressbook/"
username = "user@example.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
tag = "gmail"
[[contacts.sources]]
name = "macos"
type = "macos"
# Optional: import only a named Contacts group (must already exist).
# group = "Friends"
tag = "personal"
[[contacts.email_accounts]]
name = "gmail"
host = "imap.gmail.com"
port = 993
username = "user@gmail.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
mailboxes = ["INBOX", "[Gmail]/Sent Mail"]
identities = ["user@gmail.com"]
merge_policy = "name-or-email"
tls = "tls"
tag = "gmail"
[[contacts.telegram_accounts]]
name = "primary"
api_id = 123456
api_hash_env = "KNOTTER_TELEGRAM_API_HASH"
phone = "+15551234567"
session_path = "/home/user/.local/share/knotter/telegram/primary.session"
merge_policy = "name-or-username"
allowlist_user_ids = [123456789]
snippet_len = 160
tag = "telegram"
Defaults and validation notes:
- When
notifications.enabled = true,notifications.backend = "email"requires a[notifications.email]block and theemail-notifyfeature. - When
notifications.enabled = true,notifications.backend = "desktop"requires thedesktop-notifyfeature. notifications.email.usernameandnotifications.email.password_envmust be set together.- CardDAV sources require
urlandusername;password_envandtagare optional. - Email accounts default to
port = 993,mailboxes = ["INBOX"], andidentities = [username]whenusernameis an email address. - Telegram accounts require
api_id,api_hash_env, andphone.session_pathis optional. - Telegram
merge_policydefaults toname-or-username;snippet_lendefaults to160. - Source/account names are normalized to lowercase and must be unique.
Example loop policy:
[loops]
default_cadence_days = 180
strategy = "shortest"
schedule_missing = true
anchor = "created-at"
apply_on_tag_change = false
override_existing = false
[[loops.tags]]
tag = "friend"
cadence_days = 90
[[loops.tags]]
tag = "family"
cadence_days = 30
priority = 10
Loop precedence:
- Explicit
cadence_dayson a contact takes precedence unlessloops.override_existing = true. - When
cadence_daysis unset, tag rules apply first; the loop default applies when no tag matches. - When
anchor = "last-interaction", scheduling occurs only after an interaction exists. loops.schedule_missing = trueonly schedules contacts that have nonext_touchpoint_at.
Contact source config (optional):
[contacts]
[[contacts.sources]]
name = "gmail"
type = "carddav"
url = "https://example.test/carddav/addressbook/"
username = "user@example.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
tag = "gmail"
[[contacts.sources]]
name = "local"
type = "macos"
# Optional: import only a named Contacts group (must already exist).
# group = "Friends"
tag = "personal"
Notes:
password_envpoints to an environment variable so passwords are not stored in plaintext.nameis case-insensitive and must be unique.
Email account sync config (optional):
[contacts]
[[contacts.email_accounts]]
name = "gmail"
host = "imap.gmail.com"
port = 993
username = "user@gmail.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
mailboxes = ["INBOX", "[Gmail]/Sent Mail"]
identities = ["user@gmail.com"]
merge_policy = "name-or-email" # or "email-only"
tls = "tls" # tls | start-tls | none
tag = "gmail"
Telegram account sync config (optional):
[contacts]
[[contacts.telegram_accounts]]
name = "primary"
api_id = 123456
api_hash_env = "KNOTTER_TELEGRAM_API_HASH"
phone = "+15551234567"
session_path = "/home/user/.local/share/knotter/telegram/primary.session"
merge_policy = "name-or-username" # or "username-only"
allowlist_user_ids = [123456789]
snippet_len = 160
tag = "telegram"
On Unix, config files must be user-readable only (e.g., chmod 600).
Config parsing lives outside core (store/cli/tui).
13. Privacy and security
knotter stores personal notes and contact info. Minimum expectations:
-
DB file should be created with user-only permissions where possible.
-
Do not log full notes by default.
-
Avoid printing private data in error logs.
-
Provide a backup command that uses SQLite's online backup API for a consistent snapshot (safe with WAL).
-
If DAV sync is added:
- never store credentials in plaintext unless explicitly allowed
- prefer OS keyring integration (post-MVP)
14. Testing strategy
14.1 knotter-core tests
- tag normalization tests
- due-state computation tests (today boundaries)
- filter parser tests (valid and invalid cases)
14.2 knotter-store tests
- migration applies from scratch
- CRUD operations
- tag attachment/detachment
- filter query correctness
- due filtering correctness with known timestamps
14.3 knotter-sync tests
- vCard parse + map to core structs
- export vCard is parseable and contains expected fields
- ICS export includes stable UIDs and correct timestamps
- Telegram sync mapping (username normalization + snippet formatting)
14.4 CLI/TUI smoke tests (optional MVP)
-
CLI integration tests for core flows:
- add contact -> tag -> schedule -> remind output contains it
15. Feature flags (recommended)
Feature flags keep optional integrations isolated. Default builds enable CardDAV, email, and Telegram sync, while notification backends remain opt-in:
-
desktop-notifyfeature:- enables desktop notifications backend
-
email-notifyfeature:- enables SMTP notifications backend
-
dav-syncfeature:- enables CardDAV import code (post-MVP sync)
-
email-syncfeature:- enables IMAP email import/sync
-
telegram-syncfeature:- enables Telegram 1:1 import/sync
Use --no-default-features for a no-sync build and re-enable features explicitly.
16. Future: CardDAV/CalDAV sync (post-MVP)
knotter’s sync design should fit this pattern:
-
CardDAV import exists (one-way) behind
dav-sync; full bidirectional sync remains post-MVP. -
A
Sourceabstraction for contacts/events:pull()-> remote itemspush()-> upload local dirty items
-
Local DB remains the source of truth.
-
Sync is explicit (manual command) before adding any background behavior.
-
Conflict handling policy must be deterministic:
- “last updated wins” (simple) or
- mark conflicts and require manual resolution (better, later)
17. Summary of invariants (quick checklist)
- Contact name is non-empty.
- Tags are normalized identically in all layers.
- Timestamps in DB are UTC unix seconds.
- Filter parsing behavior is identical in CLI/TUI.
- Store uses bound parameters only.
- UI never leaves terminal in a broken state.
- Import/export is deterministic and stable (stable ICS UID, consistent VCF mapping).
Appendix: Suggested “kind” string encoding in DB
To avoid schema churn:
-
Store kinds as lowercase strings:
call,text,hangout,email,telegram
-
For
Other(s):- store
other:<normalized>where<normalized>is trimmed and lowercased
- store
-
When reading:
- parse known literals into enum variants
- parse
other:prefix intoOther(String) - unknown values map to
Other(raw)as a forward-compat fallback
Database Schema
Overview
knotter stores all data locally in a single SQLite database file. The database is the source of truth (offline-first). This document is the authoritative schema reference for the project. The schema is designed to be:
- portable across Linux/Unix machines
- easy to back up (single DB file + possible WAL side files)
- efficient for the most common queries (due touchpoints, name search, tag filtering, interaction history)
knotter intentionally keeps the schema small and stable. Most “behavior” lives in knotter-core business rules, not in triggers.
For the broader design context, see Architecture.
Storage location
By default, knotter uses XDG base directories:
- Data dir:
$XDG_DATA_HOME/knotter/- fallback:
~/.local/share/knotter/
- fallback:
- DB file:
knotter.sqlite3
So the full default path is typically:
~/.local/share/knotter/knotter.sqlite3
Notes:
- With
PRAGMA journal_mode=WAL, SQLite will also create:knotter.sqlite3-walknotter.sqlite3-shmThese are normal; backups should consider the entire set (or use SQLite’s backup API via code).
Backups
knotter’s backup command creates a consistent SQLite snapshot using the
SQLite online backup API. This is safe with WAL enabled and does not require
closing the database.
Connection pragmas (recommended)
knotter-store sets pragmatic defaults for local-app usage:
PRAGMA foreign_keys = ON;- ensures cascading deletes work as intended
PRAGMA journal_mode = WAL;- better responsiveness for reads while writing
PRAGMA synchronous = NORMAL;- good balance for local apps
PRAGMA busy_timeout = 2000;- reduces “database is locked” errors when the app briefly contends with itself (e.g., two processes)
These are not strictly part of “schema,” but they matter for behavior.
Migration model
knotter uses numbered SQL migrations in:
crates/knotter-store/migrations/
Example:
001_init.sql002_add_whatever.sql003_more_changes.sql
Schema version tracking
A simple schema version table is used:
knotter_schema(version INTEGER NOT NULL)
The migration runner is responsible for:
- creating
knotter_schemaif missing - inserting an initial version row if needed
- applying migrations in numeric order inside a transaction
- updating
knotter_schema.versionafter each applied migration
Migration rules (knotter conventions)
- Prefer additive changes (new columns/tables) over destructive ones.
- Avoid “rewrite everything” migrations.
- Keep data transformations explicit and testable.
- Always add indexes if a new query path is introduced.
- When changing semantics, update Architecture and this doc.
Migration: 001_init.sql
This is the MVP schema. It includes:
- contacts
- tags
- contact↔tag links
- interactions (notes/history)
- schema version table
-- 001_init.sql
-- knotter database schema (initial)
-- Schema version table
CREATE TABLE IF NOT EXISTS knotter_schema (
version INTEGER NOT NULL
);
-- Contacts
CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY, -- UUID string
display_name TEXT NOT NULL,
email TEXT, -- primary email (optional)
phone TEXT,
handle TEXT,
timezone TEXT, -- IANA TZ string (optional)
next_touchpoint_at INTEGER, -- unix seconds UTC
cadence_days INTEGER, -- integer days (optional)
created_at INTEGER NOT NULL, -- unix seconds UTC
updated_at INTEGER NOT NULL, -- unix seconds UTC
archived_at INTEGER -- unix seconds UTC (optional; may be unused in MVP)
);
CREATE INDEX IF NOT EXISTS idx_contacts_display_name
ON contacts(display_name);
CREATE INDEX IF NOT EXISTS idx_contacts_next_touchpoint_at
ON contacts(next_touchpoint_at);
CREATE INDEX IF NOT EXISTS idx_contacts_archived_at
ON contacts(archived_at);
-- Tags (normalized)
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY, -- UUID string
name TEXT NOT NULL UNIQUE -- normalized lowercase
);
CREATE INDEX IF NOT EXISTS idx_tags_name
ON tags(name);
-- Contact <-> Tag join
CREATE TABLE IF NOT EXISTS contact_tags (
contact_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (contact_id, tag_id),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_contact_tags_contact_id
ON contact_tags(contact_id);
CREATE INDEX IF NOT EXISTS idx_contact_tags_tag_id
ON contact_tags(tag_id);
-- Interactions (relationship history)
CREATE TABLE IF NOT EXISTS interactions (
id TEXT PRIMARY KEY, -- UUID string
contact_id TEXT NOT NULL,
occurred_at INTEGER NOT NULL, -- unix seconds UTC
created_at INTEGER NOT NULL, -- unix seconds UTC
kind TEXT NOT NULL, -- "call"|"text"|"hangout"|"email"|"telegram"|"other:<label>"
note TEXT NOT NULL,
follow_up_at INTEGER, -- unix seconds UTC (optional)
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_interactions_contact_occurred
ON interactions(contact_id, occurred_at DESC);
Migration: 002_email_sync.sql
Adds multi-email support and email sync metadata.
-- 002_email_sync.sql
-- Contact emails (normalized lowercase)
CREATE TABLE IF NOT EXISTS contact_emails (
contact_id TEXT NOT NULL,
email TEXT NOT NULL,
is_primary INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
source TEXT, -- provenance (cli/tui/vcf/<account>/primary)
PRIMARY KEY (contact_id, email),
UNIQUE (email),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_contact_emails_contact_id
ON contact_emails(contact_id);
CREATE INDEX IF NOT EXISTS idx_contact_emails_email
ON contact_emails(email);
-- Email sync state (per account/mailbox)
CREATE TABLE IF NOT EXISTS email_sync_state (
account TEXT NOT NULL,
mailbox TEXT NOT NULL,
uidvalidity INTEGER,
last_uid INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER,
PRIMARY KEY (account, mailbox)
);
-- Email messages (dedupe + touch history)
CREATE TABLE IF NOT EXISTS email_messages (
account TEXT NOT NULL,
mailbox TEXT NOT NULL,
uidvalidity INTEGER NOT NULL DEFAULT 0,
uid INTEGER NOT NULL,
message_id TEXT,
contact_id TEXT NOT NULL,
occurred_at INTEGER NOT NULL,
direction TEXT NOT NULL,
subject TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (account, mailbox, uidvalidity, uid),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_email_messages_contact_occurred
ON email_messages(contact_id, occurred_at DESC);
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_messages_account_message_id
ON email_messages(account, message_id)
WHERE message_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_messages_account_mailbox_uidvalidity_uid_null_message_id
ON email_messages(account, mailbox, uidvalidity, uid)
WHERE message_id IS NULL;
## Migration: 009_telegram_sync.sql
Adds Telegram identity mapping and sync state for 1:1 message snippets.
```sql
-- 009_telegram_sync.sql
CREATE TABLE IF NOT EXISTS contact_telegram_accounts (
contact_id TEXT NOT NULL,
telegram_user_id INTEGER NOT NULL,
username TEXT,
phone TEXT,
first_name TEXT,
last_name TEXT,
source TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (contact_id, telegram_user_id),
UNIQUE (telegram_user_id),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_contact_telegram_accounts_contact_id
ON contact_telegram_accounts(contact_id);
CREATE INDEX IF NOT EXISTS idx_contact_telegram_accounts_username
ON contact_telegram_accounts(username);
CREATE INDEX IF NOT EXISTS idx_contact_telegram_accounts_phone
ON contact_telegram_accounts(phone);
CREATE TABLE IF NOT EXISTS telegram_sync_state (
account TEXT NOT NULL,
peer_id INTEGER NOT NULL,
last_message_id INTEGER NOT NULL DEFAULT 0,
last_seen_at INTEGER,
PRIMARY KEY (account, peer_id)
);
CREATE TABLE IF NOT EXISTS telegram_messages (
account TEXT NOT NULL,
peer_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
contact_id TEXT NOT NULL,
occurred_at INTEGER NOT NULL,
direction TEXT NOT NULL,
snippet TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (account, peer_id, message_id),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_telegram_messages_contact_occurred
ON telegram_messages(contact_id, occurred_at DESC);
Migration: 010_contact_sources.sql
Adds contact source mappings for stable external ids (e.g., vCard UID).
-- 010_contact_sources.sql
CREATE TABLE IF NOT EXISTS contact_sources (
contact_id TEXT NOT NULL,
source TEXT NOT NULL,
external_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (source, external_id),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_contact_sources_contact_id
ON contact_sources(contact_id);
CREATE INDEX IF NOT EXISTS idx_contact_sources_source
ON contact_sources(source);
Migration: 011_contact_sources_external_id_norm.sql
Adds a normalized external id column for ASCII case-insensitive matching, plus a partial unique index.
-- 011_contact_sources_external_id_norm.sql
ALTER TABLE contact_sources ADD COLUMN external_id_norm TEXT;
UPDATE contact_sources
SET external_id_norm = lower(external_id);
WITH duplicate_norms AS (
SELECT source, external_id_norm
FROM contact_sources
WHERE external_id_norm IS NOT NULL
GROUP BY source, external_id_norm
HAVING COUNT(*) > 1
)
UPDATE contact_sources
SET external_id_norm = NULL
WHERE EXISTS (
SELECT 1
FROM duplicate_norms
WHERE duplicate_norms.source = contact_sources.source
AND duplicate_norms.external_id_norm = contact_sources.external_id_norm
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contact_sources_source_external_id_norm
ON contact_sources(source, external_id_norm)
WHERE external_id_norm IS NOT NULL;
Migration: 006_contact_merge_candidates.sql
Adds a table for manual merge candidates created during imports/sync.
-- 006_contact_merge_candidates.sql
CREATE TABLE IF NOT EXISTS contact_merge_candidates (
id TEXT PRIMARY KEY, -- UUID string
created_at INTEGER NOT NULL, -- unix seconds UTC
status TEXT NOT NULL, -- open|merged|dismissed
reason TEXT NOT NULL, -- import/email/etc
source TEXT, -- optional source label
contact_a_id TEXT NOT NULL,
contact_b_id TEXT NOT NULL,
preferred_contact_id TEXT, -- optional suggestion
resolved_at INTEGER, -- unix seconds UTC (optional)
CHECK (contact_a_id <> contact_b_id)
);
CREATE INDEX IF NOT EXISTS idx_contact_merge_candidates_status
ON contact_merge_candidates(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_contact_merge_candidates_contact_a
ON contact_merge_candidates(contact_a_id);
CREATE INDEX IF NOT EXISTS idx_contact_merge_candidates_contact_b
ON contact_merge_candidates(contact_b_id);
CREATE INDEX IF NOT EXISTS idx_contact_merge_candidates_preferred
ON contact_merge_candidates(preferred_contact_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_contact_merge_candidates_pair_open
ON contact_merge_candidates(contact_a_id, contact_b_id)
WHERE status = 'open';
Migration: 007_contact_dates.sql
Adds per-contact dates (birthdays, name days, custom reminders).
-- 007_contact_dates.sql
CREATE TABLE IF NOT EXISTS contact_dates (
id TEXT PRIMARY KEY, -- UUID string
contact_id TEXT NOT NULL,
kind TEXT NOT NULL, -- birthday|name_day|custom
label TEXT NOT NULL DEFAULT '',
month INTEGER NOT NULL,
day INTEGER NOT NULL,
year INTEGER, -- optional year
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
source TEXT,
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
UNIQUE (contact_id, kind, label, month, day),
CHECK (month >= 1 AND month <= 12),
CHECK (day >= 1 AND day <= 31)
);
CREATE INDEX IF NOT EXISTS idx_contact_dates_contact_id
ON contact_dates(contact_id);
CREATE INDEX IF NOT EXISTS idx_contact_dates_month_day
ON contact_dates(month, day);
Migration: 008_contact_dates_custom_label.sql
Enforces custom date labels at the database layer.
-- 008_contact_dates_custom_label.sql
CREATE TRIGGER IF NOT EXISTS contact_dates_custom_label_insert
BEFORE INSERT ON contact_dates
WHEN NEW.kind = 'custom' AND length(trim(NEW.label)) = 0
BEGIN
SELECT RAISE(ABORT, 'custom date label required');
END;
CREATE TRIGGER IF NOT EXISTS contact_dates_custom_label_update
BEFORE UPDATE ON contact_dates
WHEN NEW.kind = 'custom' AND length(trim(NEW.label)) = 0
BEGIN
SELECT RAISE(ABORT, 'custom date label required');
END;
Migration: 003_email_sync_uidvalidity.sql
Adds uidvalidity to the email message dedupe key and reconciles legacy duplicate emails.
-- 003_email_sync_uidvalidity.sql
CREATE TABLE IF NOT EXISTS email_messages_new (
account TEXT NOT NULL,
mailbox TEXT NOT NULL,
uidvalidity INTEGER NOT NULL DEFAULT 0,
uid INTEGER NOT NULL,
message_id TEXT,
contact_id TEXT NOT NULL,
occurred_at INTEGER NOT NULL,
direction TEXT NOT NULL,
subject TEXT,
created_at INTEGER NOT NULL,
PRIMARY KEY (account, mailbox, uidvalidity, uid),
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE
);
INSERT INTO email_messages_new
(account, mailbox, uidvalidity, uid, message_id, contact_id, occurred_at, direction, subject, created_at)
SELECT account, mailbox, 0, uid, message_id, contact_id, occurred_at, direction, subject, created_at
FROM email_messages;
DROP TABLE email_messages;
ALTER TABLE email_messages_new RENAME TO email_messages;
CREATE INDEX IF NOT EXISTS idx_email_messages_contact_occurred
ON email_messages(contact_id, occurred_at DESC);
CREATE INDEX IF NOT EXISTS idx_email_messages_message_id
ON email_messages(message_id);
UPDATE contacts
SET email = LOWER(TRIM(email))
WHERE email IS NOT NULL;
INSERT OR IGNORE INTO contact_emails (contact_id, email, is_primary, created_at, source)
SELECT id, email, 1, created_at, 'legacy'
FROM contacts
WHERE email IS NOT NULL
ORDER BY (archived_at IS NOT NULL) ASC, updated_at DESC;
UPDATE contacts
SET email = NULL
WHERE email IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM contact_emails ce
WHERE ce.contact_id = contacts.id AND ce.email = contacts.email
);
Migration: 004_email_message_dedupe_indexes.sql
Adds unique indexes for message-id dedupe across mailboxes; falls back to account+mailbox+uidvalidity+uid when message-id is missing.
-- 004_email_message_dedupe_indexes.sql
DELETE FROM email_messages
WHERE message_id IS NOT NULL
AND rowid NOT IN (
SELECT MIN(rowid)
FROM email_messages
WHERE message_id IS NOT NULL
GROUP BY account, message_id
);
DELETE FROM email_messages
WHERE message_id IS NULL
AND rowid NOT IN (
SELECT MIN(rowid)
FROM email_messages
WHERE message_id IS NULL
GROUP BY account, mailbox, uidvalidity, uid
);
DROP INDEX IF EXISTS idx_email_messages_message_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_messages_account_message_id
ON email_messages(account, message_id)
WHERE message_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_messages_account_mailbox_uidvalidity_uid_null_message_id
ON email_messages(account, mailbox, uidvalidity, uid)
WHERE message_id IS NULL;
Migration: 005_email_message_id_normalize.sql
Normalizes existing message_id values (trim/angle bracket removal/lowercase) and rebuilds dedupe indexes.
-- 005_email_message_id_normalize.sql
DROP INDEX IF EXISTS idx_email_messages_account_message_id;
DROP INDEX IF EXISTS idx_email_messages_account_mailbox_uidvalidity_uid_null_message_id;
UPDATE email_messages
SET message_id = LOWER(TRIM(TRIM(message_id), '<>'))
WHERE message_id IS NOT NULL;
UPDATE email_messages
SET message_id = NULL
WHERE message_id IS NOT NULL AND message_id = '';
DELETE FROM email_messages
WHERE message_id IS NOT NULL
AND rowid NOT IN (
SELECT MIN(rowid)
FROM email_messages
WHERE message_id IS NOT NULL
GROUP BY account, message_id
);
DELETE FROM email_messages
WHERE message_id IS NULL
AND rowid NOT IN (
SELECT MIN(rowid)
FROM email_messages
WHERE message_id IS NULL
GROUP BY account, mailbox, uidvalidity, uid
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_messages_account_message_id
ON email_messages(account, message_id)
WHERE message_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_messages_account_mailbox_uidvalidity_uid_null_message_id
ON email_messages(account, mailbox, uidvalidity, uid)
WHERE message_id IS NULL;
Configuration Examples
This page provides small, setup-focused config snippets. The full reference example
lives in README.md and docs/ARCHITECTURE.md.
Config file locations:
$XDG_CONFIG_HOME/knotter/config.toml- fallback:
~/.config/knotter/config.toml
On Unix, the config file must be user-readable only (e.g., chmod 600).
Minimal defaults
You can omit the file entirely. If you want just a couple defaults:
due_soon_days = 7
default_cadence_days = 30
Desktop notifications
Requires the desktop-notify feature.
[notifications]
enabled = true
backend = "desktop"
Email notifications (SMTP)
Requires the email-notify feature. Provide the password via env var (not in
config).
[notifications]
enabled = true
backend = "email"
[notifications.email]
from = "Knotter <knotter@example.com>"
to = ["you@example.com"]
subject_prefix = "knotter reminders"
smtp_host = "smtp.example.com"
smtp_port = 587
username = "user@example.com"
password_env = "KNOTTER_SMTP_PASSWORD"
tls = "start-tls"
Random contacts fallback in notifications
If reminders are otherwise empty, you can include N random active contacts in the notification:
[notifications]
random_contacts_if_no_reminders = 10
Max: 100.
Legacy: random_contacts_if_no_dates_today is still accepted (renamed to better match behavior).
Auto-reschedule on interactions
[interactions]
auto_reschedule = true
Tag-based loops
[loops]
default_cadence_days = 180
strategy = "shortest"
schedule_missing = true
anchor = "created-at"
apply_on_tag_change = false
override_existing = false
[[loops.tags]]
tag = "friend"
cadence_days = 90
[[loops.tags]]
tag = "family"
cadence_days = 30
priority = 10
CardDAV contact import
Requires the dav-sync feature.
[contacts]
[[contacts.sources]]
name = "gmail"
type = "carddav"
url = "https://example.test/carddav/addressbook/"
username = "user@example.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
tag = "gmail"
macOS Contacts import
[contacts]
[[contacts.sources]]
name = "macos"
type = "macos"
# Optional: import only a named Contacts group (must already exist).
# group = "Friends"
tag = "personal"
Email header sync (IMAP)
Requires the email-sync feature.
[contacts]
[[contacts.email_accounts]]
name = "gmail"
host = "imap.gmail.com"
port = 993
username = "user@gmail.com"
password_env = "KNOTTER_GMAIL_PASSWORD"
mailboxes = ["INBOX", "[Gmail]/Sent Mail"]
identities = ["user@gmail.com"]
merge_policy = "name-or-email"
tls = "tls"
tag = "gmail"
Telegram sync
Included in default builds. For a no-sync build from source, use
--no-default-features. To enable Telegram in a minimal build, add
--features telegram-sync. On first sync, set KNOTTER_TELEGRAM_CODE
(and KNOTTER_TELEGRAM_PASSWORD if you use 2FA) for non-interactive use.
[contacts]
[[contacts.telegram_accounts]]
name = "primary"
api_id = 123456
api_hash_env = "KNOTTER_TELEGRAM_API_HASH"
phone = "+15551234567"
merge_policy = "name-or-username"
allowlist_user_ids = [123456789]
snippet_len = 160
tag = "telegram"
Combined setups
If you want a single config that covers all sections at once, use the full
reference example in README.md or docs/ARCHITECTURE.md.
Knotter CLI Output
This document defines the stable output surface for the CLI.
General rules
- IDs are UUID strings (lowercase hex with dashes).
- Timestamps are unix seconds (UTC) in JSON output.
- Human output is intended for terminals and may evolve; JSON output is the stable interface.
- Diagnostics are written to stderr;
--verboseenables debug logs. Sensitive fields should not be logged.
Related docs:
- Scheduling for reminder automation.
- Import/Export for vCard/ICS/JSON commands.
JSON output
Enable JSON output with the global flag --json.
knotter list --json
Output: JSON array of contact list items.
Each item matches ContactListItemDto:
id(string UUID)display_name(string)due_state(string enum:unscheduled,overdue,today,soon,scheduled)next_touchpoint_at(number|null, unix seconds UTC)archived_at(number|null, unix seconds UTC)tags(array of strings)
Archived contacts are excluded by default. Use --include-archived or --only-archived
to change this behavior (or filter with archived:true|false).
knotter remind --json
Output: JSON object matching ReminderOutputDto:
overdue(array ofContactListItemDto)today(array ofContactListItemDto)soon(array ofContactListItemDto)dates_today(array ofDateReminderItemDto)
DateReminderItemDto fields:
contact_id(string UUID)display_name(string)kind(string enum:birthday,name_day,custom)label(string|null)month(number)day(number)year(number|null)
Note: due_state and reminder buckets depend on the current due_soon_days
setting (CLI flag or config default). In JSON mode, notifications only run when
--notify is provided explicitly. When notifications.backend = "stdout",
--notify --json returns a non-zero exit code because stdout notifications
cannot run without corrupting JSON output.
When notifications.backend = "email", --notify sends email and failures
return a non-zero exit code.
Reminder items include the archived_at field from ContactListItemDto, but it
will always be null because archived contacts are excluded from reminders.
Note: When notifications.random_contacts_if_no_reminders > 0, notifications may include
an additional "random contacts" section when reminders are otherwise empty. This is not
represented in ReminderOutputDto, so knotter remind --json will not include those
random picks.
knotter show <id> --json
Output: JSON object matching ContactDetailDto:
id,display_name,email(primary),emails(array),phone,handle,timezonenext_touchpoint_at,cadence_days,created_at,updated_at,archived_attags(array of strings)dates(array ofContactDateDto)recent_interactions(array ofInteractionDto)
InteractionDto fields:
id(string UUID)occurred_at(number)kind(string, one ofcall,text,hangout,email,telegram, orother:<label>)note(string)follow_up_at(number|null)
ContactDateDto fields:
id(string UUID)kind(string enum:birthday,name_day,custom)label(string|null)month(number)day(number)year(number|null)
knotter tag ls --json
Output: JSON array of tag counts:
name(string, normalized)count(number)
knotter tag add/rm --json
Output: JSON object containing:
id(string UUID)tag(string, normalized)
knotter date add --json
Output: JSON object matching ContactDateDto.
knotter date ls --json
Output: JSON array of ContactDateDto.
knotter date rm --json
Output: JSON object containing:
id(string UUID)
knotter loops apply --json
Output: JSON object containing:
matched(number of contacts that matched a loop rule or default)updated(number of contacts updated)scheduled(number of contacts scheduled from a missing touchpoint)skipped(number of contacts skipped)dry_run(boolean)changes(array of objects):id(string UUID)display_name(string)cadence_before(number|null)cadence_after(number|null)next_touchpoint_before(number|null)next_touchpoint_after(number|null)scheduled(boolean)
knotter sync
knotter sync runs all configured contact sources, email accounts, and
telegram accounts (unless --no-telegram), then
applies loops and runs reminders. It does not support --json; use individual
commands (import, loops apply, remind) if you need machine-readable output.
Sync is best-effort: it continues after failures, prints warnings to stderr, and
returns a non-zero exit code if any step fails.
JSON for mutating commands
For add-contact, edit-contact, archive-contact, unarchive-contact, schedule,
clear-schedule, add-note, and touch,
JSON output includes the created/updated entity:
- Contact mutations return a serialized
Contactobject. - Interaction mutations return a serialized
InteractionDtoobject.
Note: This output shape may be expanded in the future, but existing fields are stable.
When default_cadence_days is set in config, add-contact uses it if
--cadence-days is omitted. If loop rules are configured, they take precedence
over the default cadence when --cadence-days is omitted.
Note: add-note and touch only reschedule the next touchpoint when
--reschedule is used or interactions.auto_reschedule = true is set in
config.
Note: next_touchpoint_at values provided via add-contact, edit-contact,
or schedule must be now or later. Date-only inputs are treated as
day-precision (today or later) and are saved as the end of that day.
knotter import vcf --json
Output: JSON object matching ImportReport:
created(number)updated(number)skipped(number)merge_candidates_created(number)warnings(array of strings)dry_run(boolean)
The same output shape is used for import macos, import carddav, and import source.
knotter import email --json
Output: JSON object matching EmailImportReport:
accounts,mailboxesmessages_seen,messages_importedcontacts_created,contacts_merged,contacts_matchedmerge_candidates_createdtouches_recordedwarnings(array of strings)dry_run(boolean)
knotter import telegram --json
Output: JSON object matching TelegramImportReport:
accounts,users_seenmessages_seen,messages_importedcontacts_created,contacts_merged,contacts_matchedmerge_candidates_createdtouches_recordedwarnings(array of strings)dry_run(boolean)
knotter merge
Manual merge workflow for contact following and deduplication.
knotter merge list --jsonreturns an array of merge candidates:id,created_at,status,reason,auto_merge_safe,source,preferred_contact_id,resolved_atcontact_a,contact_bobjects withid,display_name,email,archived_at,updated_at
knotter merge show <id> --jsonreturns a single merge candidate object (same shape as list items).knotter merge apply <id> --jsonreturns the mergedContactobject.knotter merge apply-all --jsonreturns a bulk apply report:considered,selected,applied,skipped,failed(numbers)dry_run(boolean)resultsarray withid,status,reason,source,primary_id,secondary_id,merged_contact_id,error
knotter merge dismiss <id> --jsonreturns the merge candidate object after dismissal.knotter merge contacts <primary> <secondary> --jsonreturns the mergedContactobject.knotter merge scan-same-name --jsonscans the local DB for duplicate display names and creates manual merge candidates (reasonname-duplicate, sourcescan:same-name) for review:considered_contacts,skipped_empty_name_contacts,duplicate_groups,groups_scannedcandidates_created,pairs_skipped_existing_opendry_run(boolean)resultsarray withdisplay_name,normalized_name,preferred_contact_id, andpairscontainingprimary_id,secondary_id,status,merge_candidate_id- Preferred contact heuristic: active contacts are preferred; then the record with more identifiers (email/phone/handle); then the most recently updated; then the oldest created (stable canonical record).
Defaults: merges prefer the chosen primary contact for most fields, pick the earliest
next_touchpoint_at, and keep the contact active if either side is active.
knotter export vcf/ics --json
Note: --json requires --out to avoid mixing JSON with exported data.
Output: JSON object:
format(string:vcforics)count(number of exported entries)output(string path)
knotter export json
If --out is omitted, the snapshot JSON is written to stdout (regardless of --json).
If --out is provided, stdout contains a human message by default, or a JSON report
when --json is set (same shape as other export commands).
Snapshot JSON output:
metadataobject:exported_at(number, unix seconds UTC)app_version(string)schema_version(number)format_version(number)
contactsarray of objects:- contact fields:
id,display_name,email(primary),emails(array),phone,handle,timezone,next_touchpoint_at,cadence_days,created_at,updated_at,archived_at tags(array of strings)dates(array ofContactDateDto)interactions(array of objects):id,occurred_at,created_at,kind,note,follow_up_at- ordered by
occurred_atdescending
- contact fields:
Archived contacts are included by default. Use --exclude-archived to omit them.
knotter backup --json
If --out is omitted, the backup is written to the XDG data dir using a
timestamped filename.
Output: JSON object:
output(string path)size_bytes(number)
Exit codes (selected)
1for general failures (I/O, database, unexpected errors).2for missing resources (e.g., contact not found, missing TUI binary).3for invalid input (e.g., invalid filter syntax likedue:later, invalid dates, invalid flags).
Shell Completions
knotter can generate shell completion scripts using:
knotter completions <shell>
Supported shells: bash, zsh, fish, powershell, elvish.
Bash
User install (no root required):
mkdir -p ~/.local/share/bash-completion/completions
knotter completions bash > ~/.local/share/bash-completion/completions/knotter
Quick one-off session:
source <(knotter completions bash)
Zsh
User install (ensure the directory is on your fpath):
mkdir -p ~/.zsh/completions
knotter completions zsh > ~/.zsh/completions/_knotter
Then add to your ~/.zshrc if needed:
fpath=(~/.zsh/completions $fpath)
autoload -Uz compinit
compinit
Fish
mkdir -p ~/.config/fish/completions
knotter completions fish > ~/.config/fish/completions/knotter.fish
PowerShell
Add to your PowerShell profile:
knotter completions powershell | Out-String | Invoke-Expression
Persist by adding the line to $PROFILE.
Elvish
mkdir -p ~/.elvish/lib
knotter completions elvish > ~/.elvish/lib/knotter.elv
Then load it in ~/.elvish/rc.elv:
use knotter
Notes
- Completions are generated from the current CLI, so re-run after upgrades.
- Use
knotter completions --helpfor the full list of supported shells.
Scheduling Reminders
knotter does not run a background daemon. Use your system scheduler to run reminders.
For reminder output details, see Knotter CLI Output.
Cron (daily at 09:00)
0 9 * * * /path/to/knotter remind
systemd user timer
Create ~/.config/systemd/user/knotter-remind.service:
[Unit]
Description=knotter reminders
[Service]
Type=oneshot
ExecStart=/path/to/knotter remind
Create ~/.config/systemd/user/knotter-remind.timer:
[Unit]
Description=Run knotter reminders daily
[Timer]
OnCalendar=*-*-* 09:00:00
Persistent=true
[Install]
WantedBy=timers.target
Enable it:
systemctl --user daemon-reload
systemctl --user enable --now knotter-remind.timer
Use cases
1) Plain stdout list (no notifications)
No config required. knotter remind prints a human-readable list to stdout.
0 9 * * * /path/to/knotter remind
2) Desktop notifications
Build with the desktop-notify feature and enable the backend in config:
[notifications]
enabled = true
backend = "desktop"
Then schedule:
0 9 * * * /path/to/knotter remind
3) Email notifications (SMTP)
Build with the email-notify feature and configure SMTP in your config:
[notifications]
enabled = true
backend = "email"
[notifications.email]
from = "Knotter <knotter@example.com>"
to = ["you@example.com"]
subject_prefix = "knotter reminders"
smtp_host = "smtp.example.com"
smtp_port = 587
username = "user@example.com"
password_env = "KNOTTER_SMTP_PASSWORD"
tls = "start-tls" # start-tls | tls | none
timeout_seconds = 20
Provide the password via env var in your scheduler:
KNOTTER_SMTP_PASSWORD=your-app-password
0 9 * * * /path/to/knotter remind
For systemd, add an environment line to the service:
[Service]
Type=oneshot
Environment=KNOTTER_SMTP_PASSWORD=your-app-password
ExecStart=/path/to/knotter remind
Or use an environment file:
[Service]
Type=oneshot
EnvironmentFile=%h/.config/knotter/knotter.env
ExecStart=/path/to/knotter remind
4) JSON automation
Use JSON output for automation. Notifications only run when --notify is set:
/path/to/knotter remind --json
macOS: sync + loops + reminders on reconnect
If you want to sync contacts and email when the network is available, use
knotter sync and a LaunchAgent with a lightweight network check. This runs
all configured contact sources and email accounts, then applies loops and
runs knotter remind.
- Create a helper script at
~/.config/knotter/knotter-sync.sh:
#!/bin/zsh
set -euo pipefail
if ! /usr/sbin/scutil -r www.apple.com | /usr/bin/grep -q "^Reachable"; then
exit 0
fi
exec /path/to/knotter sync
Make it executable:
chmod +x ~/.config/knotter/knotter-sync.sh
- Create
~/Library/LaunchAgents/com.knotter.sync.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.knotter.sync</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/.config/knotter/knotter-sync.sh</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>StartInterval</key>
<integer>300</integer>
<key>StandardOutPath</key>
<string>/Users/you/Library/Logs/knotter-sync.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Library/Logs/knotter-sync.err.log</string>
</dict>
</plist>
Load it:
launchctl load ~/Library/LaunchAgents/com.knotter.sync.plist
Notes:
- This polls every 5 minutes and only runs when the network is reachable, so it effectively runs on the next connection without failing offline.
- Use
knotter sync --no-remindif you want to separate sync from reminders.
Notes
knotter remindprints human output to stdout unless--jsonis used. If notifications are enabled (via--notifyor config), it will notify instead of printing the list.knotter remind --jsonalways emits JSON to stdout. Notifications only run when--notifyis provided explicitly.knotter remind --notifywill use desktop notifications if built with thedesktop-notifyfeature; otherwise it falls back to a stdout summary.knotter remind --jsonemits JSON for automation; with--notify, notification failure returns a non-zero exit code to avoid silent misses.- Configure defaults in
~/.config/knotter/config.toml(see README) fordue_soon_daysand notification settings. - If notifications are enabled in config,
--notifyis optional for non-JSON runs. Use--notifyto force notifications even when config is disabled. - If notifications are enabled in config, pass
--no-notifyto suppress them for a single run. - If
notifications.backend = "stdout",--notifyprints the full reminder list (same as human output). This backend cannot be used with--json. - For email delivery, build with the
email-notifyfeature and configure[notifications.email]in your config; secrets should be provided viapassword_env(see README).
Import and Export
This document describes knotter's vCard (.vcf) and iCalendar (.ics) mappings. For stable CLI output expectations, see Knotter CLI Output.
vCard import
Command:
knotter import vcf <file>
Optional flags:
--dry-run # parse + dedupe, but do not write to the DB
--limit <N> # only process the first N contacts
--tag <tag> # add an extra tag to all imported contacts (repeatable)
--match-phone-name # match existing contacts by display name + phone when no email match is found
Mapping rules
FN→display_name(required)EMAIL(all) → contact emails (first becomes primary)TEL(first) →phoneCATEGORIES→ tags (normalized; comma-separated)UID/X-ABUID→ stored as an external id for stable imports (UUID-shaped values are lowercased andurn:uuid:is stripped; non-UUID values preserve case but strip a leadingurn:uuid:if present)X-KNOTTER-NEXT-TOUCHPOINT→next_touchpoint_at(unix seconds UTC)X-KNOTTER-CADENCE-DAYS→cadence_days
Dedupe policy
- If a vCard
UID/X-ABUIDmatches a previously imported contact from the same source, update that contact. Matching is ASCII case-insensitive; if multiple contacts share the same UID ignoring case, knotter ignores UID matching, emits a warning, and falls back to other dedupe rules. If duplicates differ only by case but map to the same contact, knotter collapses them to a single mapping and warns. - If
EMAILis present and matches exactly one active contact (case-insensitive), update that contact. - If
EMAILis missing, create a new contact unless--match-phone-namefinds a display-name + phone match. - When
--match-phone-nameis set, knotter normalizes phone numbers (digits-only, leading+preserved) and matches by display name + phone. - If multiple contacts share the same email, knotter stages an archived contact and creates merge candidates.
- If multiple contacts match by display name + phone, knotter creates merge candidates between existing contacts.
- Staged contacts only include emails that are not already assigned to other contacts (to satisfy uniqueness).
- If the only match is archived, the import skips the entry and emits a warning.
- Imported tags are merged with existing tags when updating.
Resolve merge candidates via
knotter mergeor the TUI merge list. Duplicate-email and vcf-ambiguous-phone-name candidates are marked auto-merge safe and can be bulk-applied viaknotter merge apply-all.
Warnings
Import reports include warnings for:
- missing
FN - invalid tag values
- invalid
X-KNOTTER-*values
macOS Contacts import
Command:
knotter import macos
Optional flags:
--group <name> # only import contacts from a specific Contacts.app group
--dry-run
--limit <N>
--tag <tag>
Notes:
- The first run will prompt for Contacts access on macOS.
- If
--groupis set, the group must already exist in Contacts; omit it to import all contacts. - The import uses the same vCard mapping rules and dedupe policy as
import vcf, with phone+name matching enabled by default.
CardDAV import (Gmail, iCloud, and other providers)
Command:
knotter import carddav --url <addressbook-url> --username <user> --password-env <ENV>
Alternative password input:
echo "app-password" | knotter import carddav --url <addressbook-url> --username <user> --password-stdin
Optional flags:
--dry-run
--limit <N>
--force-uidvalidity-resync
--retry-skipped
--tag <tag>
Notes:
- Use the provider’s CardDAV addressbook URL (often listed in their settings docs).
- Some providers require an app-specific password when 2FA is enabled.
- CardDAV import is enabled by default (v0.2.1+). Disable with
--no-default-featuresor re-enable with--features dav-sync.
Email account sync (IMAP)
Sync email headers from configured IMAP accounts and record email touches:
knotter import email --account gmail
Notes:
- Email sync is enabled by default (v0.2.1+). Disable with
--no-default-featuresor re-enable with--features email-sync. - Sync reads headers only (From/To/Date/Subject/Message-ID) and does not store bodies.
- If the sender email matches an existing contact, it attaches the email and records an email touch.
- If no match exists, a new contact is created.
- If multiple name matches exist, knotter stages an archived contact and creates merge candidates.
--retry-skippedstops the import run when a header is skipped so you can retry after fixing config or un-archiving contacts.- If UIDVALIDITY changes and the mailbox contains messages without Message-ID, import will skip the resync (and not update state) to avoid duplicate touches. Use
--force-uidvalidity-resyncto override.
Telegram sync (1:1, snippets only)
Sync Telegram 1:1 chats and store short snippets:
knotter import telegram --account primary
Optional flags:
--dry-run
--limit <N> # max messages per user (after last synced)
--contacts-only
--messages-only
--retry-skipped
--tag <tag>
Notes:
- Telegram sync is included in default builds. For a no-sync build from source, use
--no-default-features. To enable Telegram in a minimal build, add--features telegram-sync(plus any other sync features you want). - Only 1:1 chats are imported; group chats are ignored.
- Snippets are stored (collapsed to a single line); full message bodies are not stored.
- On first sync, knotter will request a login code. Set
KNOTTER_TELEGRAM_CODEand (if you use 2FA)KNOTTER_TELEGRAM_PASSWORDto run non-interactively. - If a Telegram user id is already linked, knotter updates metadata and records touches.
- If no link exists, knotter matches by username (including matching contact handles), then display name;
ambiguous matches create merge candidates unless
--messages-onlyis used. allowlist_user_idsin config limits sync to specific Telegram user ids.--messages-onlynever creates or stages contacts; it only attaches messages to unambiguous matches, otherwise it skips the user with a warning.
Import sources from config
When you configure contact sources in config.toml, you can run:
knotter import source <name>
The config source can be carddav or macos, and may include a default tag.
See the configuration section in docs/ARCHITECTURE.md for the schema.
vCard export
Command:
knotter export vcf [--out <file>]
Output
- Version: vCard 3.0
- Fields:
FN,EMAIL,TEL,CATEGORIES - Optional metadata:
X-KNOTTER-NEXT-TOUCHPOINT(unix seconds UTC)X-KNOTTER-CADENCE-DAYSBDAY(birthday,YYYY-MM-DD,YYYYMMDD,--MMDD, or--MM-DD)X-KNOTTER-DATE(kind|date|labelfor name-day/custom dates and extra/labeled birthdays)
Archived contacts are excluded from exports.
Round-trip notes
- Only
FN,EMAIL,TEL,CATEGORIES,BDAY, andX-KNOTTER-DATEare exported; other vCard fields are ignored. X-KNOTTER-*fields are specific to knotter and may be dropped by other apps.
JSON export (full snapshot)
Command:
knotter export json [--out <file>] [--exclude-archived]
Output
- JSON snapshot containing metadata and all contacts.
- Includes tags and full interaction history per contact.
- Interactions are ordered by most recent first.
Notes
- Archived contacts are included by default;
--exclude-archivedomits them. metadata.format_versioncan be used to handle future schema changes.
iCalendar export (touchpoints)
Command:
knotter export ics [--out <file>] [--window-days N]
Output
- One event per contact with
next_touchpoint_at UIDis stable and derived from the contact UUIDSUMMARY:Reach out to {name}DTSTART: UTC timestamp fromnext_touchpoint_atDESCRIPTION: tags if present
Window filtering
When --window-days is provided, only events between now and now + N days
are exported (overdue items are skipped). If --window-days is omitted,
all contacts with a next_touchpoint_at are exported.
Archived contacts are excluded from exports.
Round-trip notes
- Exported events are one-way snapshots; editing them in a calendar does not update knotter.
Keybindings
Overview
knotter’s TUI is built around an explicit mode/state machine:
Mode::ListMode::FilterEditingMode::Detail(ContactId)Mode::MergeListMode::ModalAddContactMode::ModalEditContact(ContactId)Mode::ModalAddNote(ContactId)Mode::ModalEditTags(ContactId)Mode::ModalSchedule(ContactId)
Keybindings are designed to be:
- fast (single-key actions where safe)
- predictable (same keys mean the same thing across modes)
- accessible (arrow keys work everywhere; vim keys are optional)
The TUI should show a small “hint footer” with the most relevant keys for the current mode.
For manual validation after UI changes, see TUI Smoke Checklist.
Launch the TUI with:
knotter tui
Or run the binary directly:
knotter-tui
Global keys (work in all modes)
-
?
Toggle help overlay (shows this cheat sheet in-app). -
Ctrl+C
Quit immediately (must restore terminal state).
If a modal has unsaved changes, knotter may confirm before exiting. -
q
Quit (may confirm if there are unsaved changes). -
Esc
“Back / cancel” depending on context:- in modals: cancel/close modal
- in detail: back to list
- in list: if no modal is open,
Escdoes nothing (filter clearing is explicit; see below)
-
r
Refresh list/detail from the database (safe “get me back to known good state”).
Common navigation keys
These apply in list-like panels (contact list, tag list, interaction list):
-
↑/↓
Move selection up/down. -
j/k
Vim-style move down/up. -
PageUp/PageDown
Scroll one page. -
g
Jump to top. -
G
Jump to bottom. -
Home/End
Alternative jump to top/bottom.
Mode: List (Mode::List)
This is the default view: a scrollable contact list, with due indicators and tags.
Navigation
↑/↓,j/kmove selectionPageUp/PageDownscrollg/Gjump top/bottom
Open detail
Enter
Open selected contact detail (Mode::Detail(contact_id)).
Filtering
/
Enter filter editing (Mode::FilterEditing) with the current filter string.c
Clear filter (sets filter string to empty and reloads list).
Contact actions
a
Open “Add Contact” modal (Mode::ModalAddContact).e
Open “Edit Contact” modal for selected (Mode::ModalEditContact).n
Add note for selected (Mode::ModalAddNote).t
Edit tags for selected (Mode::ModalEditTags).s
Schedule next touchpoint (Mode::ModalSchedule).x
Clear scheduled next touchpoint for selected (should confirm).A
Archive/unarchive selected contact (confirm required).v
Toggle showing archived contacts in the list.m
Open merge candidate list (Mode::MergeList).M
Open merge picker for selected contact (Mode::ModalMergePicker).
Optional (only if implemented)
d
Delete contact (dangerous; must confirm).
Mode: Filter editing (Mode::FilterEditing)
This is a single-line editor for the filter query.
Editing
- Type characters to edit the filter
Backspacedelete characterCtrl+Uclear the entire lineCtrl+Wdelete previous word (optional, but very nice)
Apply / cancel
Enter
Apply filter (parse in core; if parse error, stay in FilterEditing and show error).Esc
Cancel filter editing (revert to previous filter string, return to list).
Quick reference: filter syntax (MVP)
#designer→ require tag designerdue:overdue|due:today|due:soon|due:any|due:none- plain words match name/email/phone/handle
Mode: Contact detail (Mode::Detail(contact_id))
The detail view shows:
- contact fields
- tags
- next touchpoint + cadence
- recent interactions (scrollable)
Navigation inside detail
↑/↓,j/kscroll interactions listPageUp/PageDownscroll fasterg/Gtop/bottom of interactions
Back
EscorBackspace
Return to list (Mode::List).
Actions
e
Edit contact (Mode::ModalEditContact).n
Add note (Mode::ModalAddNote).t
Edit tags (Mode::ModalEditTags).s
Schedule next touchpoint (Mode::ModalSchedule).x
Clear schedule (confirm).m
Open merge candidate list (Mode::MergeList).M
Open merge picker for this contact (Mode::ModalMergePicker).
Optional
ddelete contact (confirm)Ddelete selected interaction (confirm) if interaction deletion exists
Mode: Merge list (Mode::MergeList)
Shows open merge candidates created during import/sync.
Navigation
↑/↓,j/kmove selectionPageUp/PageDownscrollg/Gjump top/bottom
Actions
Enter
Merge selected candidate (confirm required).p
Toggle which contact is preferred for merge.a/A
Apply all auto-merge safe candidates (confirm required).d
Dismiss selected candidate (confirm required).r
Refresh merge list.Esc
Return to contact list.
Mode: Merge picker (Mode::ModalMergePicker)
Pick a contact to merge into the selected primary contact.
Navigation
Tab/Shift+Tabmove focus (filter → list → buttons)↑/↓,j/kmove selection (when list is focused)PageUp/PageDownscrollg/Gjump top/bottom
Actions
Enter
Merge selected contact into primary (confirm required).Ctrl+R
Refresh contact list.Esc
Return to the previous view.
Modal conventions (all modal modes)
All modals share consistent behavior.
Universal modal keys
Tab
Move focus to next field/control.Shift+Tab
Move focus to previous field/control.Ctrl+N
Set date/time fields to “now” in contact/schedule modals.Esc
Cancel/close (confirm if there are unsaved changes).Enter- if focus is on a button: activate it (
Save,Cancel) - if focus is on a single-line input: may move to next field (implementation choice)
- if focus is on a button: activate it (
- Arrow keys move within lists when a list control is focused.
Buttons pattern (recommended)
Every modal has explicit buttons at the bottom:
[Save] [Cancel]This avoids unreliable “Ctrl+S” behavior across terminals.
Mode: Add contact (Mode::ModalAddContact)
Fields (recommended)
- Name (required)
- Email (optional)
- Phone (optional)
- Handle (optional)
- Cadence days (optional)
- Next touchpoint date/time (optional)
Keys
Tab/Shift+Tabnavigate fields and buttonsEnteron[Save]savesEnteron[Cancel]cancelsEsccancels
Validation behavior:
- If name is empty, show inline error and keep the modal open.
Mode: Edit contact (Mode::ModalEditContact(contact_id))
Same keys as Add Contact.
Additional behavior:
- Show current values prefilled
- Keep “save” disabled until a change is made (optional)
Mode: Add note (Mode::ModalAddNote(contact_id))
This modal adds an Interaction entry.
Controls (recommended)
- Kind selector (call/text/hangout/email/other)
- Occurred-at timestamp (defaults to now)
- Optional follow-up date/time
- Note editor (multi-line)
Keys
Tab/Shift+Tabmove focus among:- kind
- occurred-at
- follow-up-at
- note editor
- buttons
- In the multi-line note editor:
Enterinserts newlineBackspacedeletesPageUp/PageDownscroll note if needed
- To save:
Tabto[Save], thenEnter
Esccancels (confirm if note has content)
Mode: Edit tags (Mode::ModalEditTags(contact_id))
This modal manages the tag set for a contact.
Layout (recommended)
- Left: existing tags list (with optional counts)
- Top or bottom: tag search/create input
- Bottom: buttons
[Save] [Cancel]
Keys
- Navigation:
↑/↓,j/kmove in the tag list
- Toggle tag attachment:
SpaceorEntertoggles the selected tag on/off for this contact
- Create new tag:
- Type in tag input
Entercreates the tag (normalized) and toggles it on- Invalid tags surface an error and leave the input intact
- Save:
Tabto[Save],Enter
- Cancel:
Escor[Cancel]
Behavior rules:
- Tags are normalized the same way as everywhere else.
- Saving should perform “replace tag set” (set_contact_tags) to keep semantics simple.
Mode: Schedule touchpoint (Mode::ModalSchedule(contact_id))
This modal edits next_touchpoint_at (and optionally cadence).
Controls (recommended)
- Date input (required for scheduling)
- Optional time input (defaults to a sensible time or “all-day-ish”)
- Optional cadence days (if you want to set it here too)
- Quick picks (optional):
+7d,+30d
Keys
Tab/Shift+Tabmove between inputs and buttonsEnteron[Save]setsnext_touchpoint_atEsccancels
Optional quick picks (if implemented):
1→ schedule +7 days from today2→ schedule +30 days from today
Suggested on-screen hint footer (by mode)
knotter should display mode-appropriate hints such as:
- List:
Enter: Detail /: Filter a: Add e: Edit n: Note t: Tags s: Schedule m: Merges q: Quit
- Detail:
Esc: Back n: Note t: Tags s: Schedule e: Edit m: Merges
- Filter:
Enter: Apply Esc: Cancel
- Merge:
Enter: Merge p: Prefer d: Dismiss r: Refresh Esc: Back
- Modals:
Tab: Next Shift+Tab: Prev Enter: Activate Esc: Cancel
Notes on portability
- Avoid making critical actions depend on terminal-specific combos like
Ctrl+Enter. - Prefer explicit
[Save]/[Cancel]buttons withTabnavigation. - Always restore terminal state on exit, panic, or ctrl-c.
TUI smoke checklist
Run these after significant TUI changes.
-
Launch TUI:
knotter-tui - Add contact (name only)
- Add tag via tag editor and verify it shows in list
- Add note via modal and verify it shows in detail view
- Schedule touchpoint and confirm due badge updates
-
Filter by
#taganddue:soon - Archive a contact and toggle archived visibility
- Quit and confirm terminal state is restored
Packaging
Releases publish macOS/Linux tarballs plus Linux .deb packages. These notes describe local builds for macOS Homebrew (single-repo tap), Debian/Ubuntu, and generic Unix systems.
Homebrew (macOS, single-repo tap)
The formula lives at Formula/knotter.rb. Install from this repo:
brew tap tomatyss/knotter https://github.com/tomatyss/knotter
brew install tomatyss/knotter/knotter
Formula files must point at a tagged release tarball and include a SHA256.
Release tagging and assets
Releases are tag-driven. Push a tag like v0.1.0 to trigger the release
workflow, which builds multi-arch Linux (gnu + musl) and macOS tarballs, Linux
.deb packages for x86_64, and publishes checksums to GitHub Releases.
Debian/Ubuntu (.deb)
We rely on cargo-deb for local package builds.
Install the tool (one-time):
cargo install cargo-deb
Build packages:
cargo deb -p knotter-cli
cargo deb -p knotter-tui
Packages are written under target/debian/. Install them with:
sudo dpkg -i target/debian/knotter-cli_*.deb
sudo dpkg -i target/debian/knotter-tui_*.deb
Generic Unix install
Build release binaries and copy them into your PATH:
cargo build --release -p knotter-cli -p knotter-tui
install -m 755 target/release/knotter /usr/local/bin/knotter
install -m 755 target/release/knotter-tui /usr/local/bin/knotter-tui
Linux musl (static) local build
For a static binary suitable for minimal distros or containers, use musl with a
cross build tool like cross:
cross build --release -p knotter-cli -p knotter-tui --target x86_64-unknown-linux-musl