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

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).
  • 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).
  • 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.rs
        • domain/ (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.rs
        • db.rs (connection/open, pragmas)
        • migrate.rs
        • repo/ (contacts.rs, tags.rs, interactions.rs)
        • error.rs
      • migrations/
        • 001_init.sql
        • 002_...sql
    • knotter-sync/
      • src/
        • lib.rs
        • vcf/ (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 i64 unix seconds (UTC) in SQLite
  • convert to/from a Rust datetime type at the edges

Define in core:

  • Timestamp wrapper 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: ContactId
  • display_name: String (required, non-empty)
  • email: Option<String> (primary email; additional emails live in contact_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.source tracks 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: InteractionId
  • contact_id: ContactId
  • occurred_at: i64 (when the interaction happened; default “now”)
  • created_at: i64 (when it was logged; default “now”)
  • kind: InteractionKind
  • note: String (can be empty, but usually non-empty is better)
  • follow_up_at: Option<i64> (optional per-interaction follow-up date)

InteractionKind:

  • Call
  • Text
  • Hangout
  • Email
  • Telegram
  • Other(String) (must be normalized/trimmed)

Invariants:

  • Other(s) should be stored as trimmed; reject empty.
  • occurred_at should 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: TagId
  • name: 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: ContactDateId
  • contact_id: ContactId
  • kind: ContactDateKind (birthday, name_day, custom)
  • label: Option<String> (required for custom)
  • month: u8 (1-12)
  • day: u8 (1-31, validated against month)
  • year: Option<i32> (optional, used for birthdays/notes)
  • created_at: i64
  • updated_at: i64
  • source: Option<String> (cli/tui/vcf/etc)

Invariants:

  • custom dates require a non-empty label.
  • Month/day must be a valid calendar day; Feb 29 is allowed without a year.
  • Date occurrences are evaluated in the local machine timezone (MVP).
  • On non-leap years, Feb 29 occurrences are surfaced on Feb 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_at already exists and is later than now, only reschedule if user explicitly requests (e.g., via CLI flags or interactions.auto_reschedule).

Scheduling guard:

  • User-provided next_touchpoint_at inputs must be now or 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)
  • Tag tokens:
    • #designer (require tag “designer”)
  • Due tokens:
    • due:overdue
    • due:today
    • due:soon
    • due: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 #founder means must have both tags.
  • (Optional later) OR groups:
    • #designer,#engineer means either tag

Default UI behavior:

  • CLI/TUI list views exclude archived contacts unless explicitly included via flags or archived:true.

5.2 AST types

  • FilterExpr
    • Text(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

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.
  • contacts with archived_at included for future archiving (unused in MVP UI).
  • tags (normalized), contact_tags join table.
  • interactions with kind stored as a normalized string.
  • Indexes on contacts.display_name, contacts.next_touchpoint_at, contacts.archived_at, tags.name, contact_tags foreign keys, and interactions(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(...) -> Contact
  • update_contact(...) -> Contact
  • get_contact(id) -> Option<Contact>
  • delete_contact(id) -> () (hard delete MVP)
  • archive_contact(id) -> Contact
  • unarchive_contact(id) -> Contact
  • list_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(...) -> Interaction
  • list_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_at to now and to “today boundaries” computed in Rust

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 VCardContact structure.

  • 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::Email entry.
  • 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-only skips staging and only attaches to unambiguous matches
  • Each imported message inserts:
    • telegram_messages row for dedupe
    • InteractionKind::Telegram with 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_CODE and (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_at as 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-archived escape 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 remind queries 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:

  • --json mode 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: Mode
  • filter_input: String
  • parsed_filter: Option<ContactFilter>
  • list: Vec<ContactListItem>
  • selected_index: usize
  • detail: Option<ContactDetail> (selected contact, tags, recent interactions)
  • status_message: Option<String>
  • error_message: Option<String>
  • config values (soon window, etc.)

10.2 Modes

  • List
  • Detail(contact_id)
  • FilterEditing
  • ModalAddContact
  • ModalEditContact(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

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:

  • FilterParseError
  • DomainError (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 = 7
  • default_cadence_days = 30 (optional)
  • notifications.enabled = true/false
  • notifications.backend = "stdout" | "desktop" | "email" (email requires email-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 no next_touchpoint_at)
  • loops.anchor = "now" | "created-at" | "last-interaction"
  • loops.apply_on_tag_change = true/false
  • loops.override_existing = true/false
  • [[loops.tags]] with tag, cadence_days, optional priority

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 the email-notify feature.
  • When notifications.enabled = true, notifications.backend = "desktop" requires the desktop-notify feature.
  • notifications.email.username and notifications.email.password_env must be set together.
  • CardDAV sources require url and username; password_env and tag are optional.
  • Email accounts default to port = 993, mailboxes = ["INBOX"], and identities = [username] when username is an email address.
  • Telegram accounts require api_id, api_hash_env, and phone. session_path is optional.
  • Telegram merge_policy defaults to name-or-username; snippet_len defaults to 160.
  • 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_days on a contact takes precedence unless loops.override_existing = true.
  • When cadence_days is 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 = true only schedules contacts that have no next_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_env points to an environment variable so passwords are not stored in plaintext.
  • name is 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

Feature flags keep optional integrations isolated. Default builds enable CardDAV, email, and Telegram sync, while notification backends remain opt-in:

  • desktop-notify feature:

    • enables desktop notifications backend
  • email-notify feature:

    • enables SMTP notifications backend
  • dav-sync feature:

    • enables CardDAV import code (post-MVP sync)
  • email-sync feature:

    • enables IMAP email import/sync
  • telegram-sync feature:

    • 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 Source abstraction for contacts/events:

    • pull() -> remote items
    • push() -> 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
  • When reading:

    • parse known literals into enum variants
    • parse other: prefix into Other(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/
  • 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-wal
    • knotter.sqlite3-shm These 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.


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.sql
  • 002_add_whatever.sql
  • 003_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_schema if missing
  • inserting an initial version row if needed
  • applying migrations in numeric order inside a transaction
  • updating knotter_schema.version after 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; --verbose enables debug logs. Sensitive fields should not be logged.

Related docs:

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 of ContactListItemDto)
  • today (array of ContactListItemDto)
  • soon (array of ContactListItemDto)
  • dates_today (array of DateReminderItemDto)

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, timezone
  • next_touchpoint_at, cadence_days, created_at, updated_at, archived_at
  • tags (array of strings)
  • dates (array of ContactDateDto)
  • recent_interactions (array of InteractionDto)

InteractionDto fields:

  • id (string UUID)
  • occurred_at (number)
  • kind (string, one of call, text, hangout, email, telegram, or other:<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 Contact object.
  • Interaction mutations return a serialized InteractionDto object.

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, mailboxes
  • messages_seen, messages_imported
  • contacts_created, contacts_merged, contacts_matched
  • merge_candidates_created
  • touches_recorded
  • warnings (array of strings)
  • dry_run (boolean)

knotter import telegram --json

Output: JSON object matching TelegramImportReport:

  • accounts, users_seen
  • messages_seen, messages_imported
  • contacts_created, contacts_merged, contacts_matched
  • merge_candidates_created
  • touches_recorded
  • warnings (array of strings)
  • dry_run (boolean)

knotter merge

Manual merge workflow for contact following and deduplication.

  • knotter merge list --json returns an array of merge candidates:
    • id, created_at, status, reason, auto_merge_safe, source, preferred_contact_id, resolved_at
    • contact_a, contact_b objects with id, display_name, email, archived_at, updated_at
  • knotter merge show <id> --json returns a single merge candidate object (same shape as list items).
  • knotter merge apply <id> --json returns the merged Contact object.
  • knotter merge apply-all --json returns a bulk apply report:
    • considered, selected, applied, skipped, failed (numbers)
    • dry_run (boolean)
    • results array with id, status, reason, source, primary_id, secondary_id, merged_contact_id, error
  • knotter merge dismiss <id> --json returns the merge candidate object after dismissal.
  • knotter merge contacts <primary> <secondary> --json returns the merged Contact object.
  • knotter merge scan-same-name --json scans the local DB for duplicate display names and creates manual merge candidates (reason name-duplicate, source scan:same-name) for review:
    • considered_contacts, skipped_empty_name_contacts, duplicate_groups, groups_scanned
    • candidates_created, pairs_skipped_existing_open
    • dry_run (boolean)
    • results array with display_name, normalized_name, preferred_contact_id, and pairs containing primary_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: vcf or ics)
  • 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:

  • metadata object:
    • exported_at (number, unix seconds UTC)
    • app_version (string)
    • schema_version (number)
    • format_version (number)
  • contacts array 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 of ContactDateDto)
    • interactions (array of objects):
      • id, occurred_at, created_at, kind, note, follow_up_at
      • ordered by occurred_at descending

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)

  • 1 for general failures (I/O, database, unexpected errors).
  • 2 for missing resources (e.g., contact not found, missing TUI binary).
  • 3 for invalid input (e.g., invalid filter syntax like due: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 --help for 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.

  1. 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
  1. 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-remind if you want to separate sync from reminders.

Notes

  • knotter remind prints human output to stdout unless --json is used. If notifications are enabled (via --notify or config), it will notify instead of printing the list.
  • knotter remind --json always emits JSON to stdout. Notifications only run when --notify is provided explicitly.
  • knotter remind --notify will use desktop notifications if built with the desktop-notify feature; otherwise it falls back to a stdout summary.
  • knotter remind --json emits 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) for due_soon_days and notification settings.
  • If notifications are enabled in config, --notify is optional for non-JSON runs. Use --notify to force notifications even when config is disabled.
  • If notifications are enabled in config, pass --no-notify to suppress them for a single run.
  • If notifications.backend = "stdout", --notify prints the full reminder list (same as human output). This backend cannot be used with --json.
  • For email delivery, build with the email-notify feature and configure [notifications.email] in your config; secrets should be provided via password_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

  • FNdisplay_name (required)
  • EMAIL (all) → contact emails (first becomes primary)
  • TEL (first) → phone
  • CATEGORIES → tags (normalized; comma-separated)
  • UID / X-ABUID → stored as an external id for stable imports (UUID-shaped values are lowercased and urn:uuid: is stripped; non-UUID values preserve case but strip a leading urn:uuid: if present)
  • X-KNOTTER-NEXT-TOUCHPOINTnext_touchpoint_at (unix seconds UTC)
  • X-KNOTTER-CADENCE-DAYScadence_days

Dedupe policy

  • If a vCard UID/X-ABUID matches 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 EMAIL is present and matches exactly one active contact (case-insensitive), update that contact.
  • If EMAIL is missing, create a new contact unless --match-phone-name finds a display-name + phone match.
  • When --match-phone-name is 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 merge or the TUI merge list. Duplicate-email and vcf-ambiguous-phone-name candidates are marked auto-merge safe and can be bulk-applied via knotter 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 --group is 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-features or 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-features or 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-skipped stops 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-resync to 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_CODE and (if you use 2FA) KNOTTER_TELEGRAM_PASSWORD to 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-only is used.
  • allowlist_user_ids in config limits sync to specific Telegram user ids.
  • --messages-only never 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-DAYS
    • BDAY (birthday, YYYY-MM-DD, YYYYMMDD, --MMDD, or --MM-DD)
    • X-KNOTTER-DATE (kind|date|label for name-day/custom dates and extra/labeled birthdays)

Archived contacts are excluded from exports.

Round-trip notes

  • Only FN, EMAIL, TEL, CATEGORIES, BDAY, and X-KNOTTER-DATE are 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-archived omits them.
  • metadata.format_version can 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
  • UID is stable and derived from the contact UUID
  • SUMMARY: Reach out to {name}
  • DTSTART: UTC timestamp from next_touchpoint_at
  • DESCRIPTION: 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::List
  • Mode::FilterEditing
  • Mode::Detail(ContactId)
  • Mode::MergeList
  • Mode::ModalAddContact
  • Mode::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, Esc does 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.

  • /, j/k move selection
  • PageUp/PageDown scroll
  • g/G jump 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
  • Backspace delete character
  • Ctrl+U clear the entire line
  • Ctrl+W delete 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 designer
  • due: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)
  • /, j/k scroll interactions list
  • PageUp/PageDown scroll faster
  • g/G top/bottom of interactions

Back

  • Esc or Backspace
    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

  • d delete contact (confirm)
  • D delete selected interaction (confirm) if interaction deletion exists

Mode: Merge list (Mode::MergeList)

Shows open merge candidates created during import/sync.

  • /, j/k move selection
  • PageUp/PageDown scroll
  • g/G jump 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.

  • Tab/Shift+Tab move focus (filter → list → buttons)
  • /, j/k move selection (when list is focused)
  • PageUp/PageDown scroll
  • g/G jump top/bottom

Actions

  • Enter
    Merge selected contact into primary (confirm required).
  • Ctrl+R
    Refresh contact list.
  • Esc
    Return to the previous view.

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)
  • Arrow keys move within lists when a list control is focused.

Every modal has explicit buttons at the bottom:

  • [Save] [Cancel] This avoids unreliable “Ctrl+S” behavior across terminals.

Mode: Add contact (Mode::ModalAddContact)

  • Name (required)
  • Email (optional)
  • Phone (optional)
  • Handle (optional)
  • Cadence days (optional)
  • Next touchpoint date/time (optional)

Keys

  • Tab / Shift+Tab navigate fields and buttons
  • Enter on [Save] saves
  • Enter on [Cancel] cancels
  • Esc cancels

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.

  • 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+Tab move focus among:
    • kind
    • occurred-at
    • follow-up-at
    • note editor
    • buttons
  • In the multi-line note editor:
    • Enter inserts newline
    • Backspace deletes
    • PageUp/PageDown scroll note if needed
  • To save:
    • Tab to [Save], then Enter
  • Esc cancels (confirm if note has content)

Mode: Edit tags (Mode::ModalEditTags(contact_id))

This modal manages the tag set for a contact.

  • Left: existing tags list (with optional counts)
  • Top or bottom: tag search/create input
  • Bottom: buttons [Save] [Cancel]

Keys

  • Navigation:
    • /, j/k move in the tag list
  • Toggle tag attachment:
    • Space or Enter toggles the selected tag on/off for this contact
  • Create new tag:
    • Type in tag input
    • Enter creates the tag (normalized) and toggles it on
    • Invalid tags surface an error and leave the input intact
  • Save:
    • Tab to [Save], Enter
  • Cancel:
    • Esc or [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).

  • 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+Tab move between inputs and buttons
  • Enter on [Save] sets next_touchpoint_at
  • Esc cancels

Optional quick picks (if implemented):

  • 1 → schedule +7 days from today
  • 2 → schedule +30 days from today

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 with Tab navigation.
  • 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 #tag and due: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