This document is the protocol contract between the engine (Rust), the relay (standalone Node service), and the clients (Web UI / desktop / MCP). If any side needs to change an interface, change this document first.
The core engine is a self-contained CLI, independent of the desktop client.
- Power users: install only the engine (single binary
homekb) and get the full feature set — compile, retrieval, integration with various Agents (homekb mcp), and mobile pairing (homekb register/pair/tunnel). No client required. - Regular users: download the desktop client (Tauri). The client is a pure renderer: on startup it detects the local engine; if not installed → auto-install (download the latest
engine-v*GitHub release artifact and install to~/.local/bin— see "Distribution" and "Engine acquisition"); if already installed → connect directly (spawnhomekb serveover local HTTP RPC). - Because the client shares the engine's environment, it handles what remote clients cannot: installing the engine, assisting in establishing connections (generating pairing codes / QR codes), editing configuration (AI provider keys), and managing the tunnel daemon.
- The same RPC protocol is reused everywhere: local HTTP (
homekb serve) and connection-service tunnel forwarding. The UI is written once; only the base URL and authentication method change.
Three independently deployed pieces, with two paths from a remote client to the home device:
- Web UI — Next.js app in this repo's root, deployed on Vercel as a pure frontend. It carries zero relay logic and zero knowledge-base data; the browser talks to the connection service. Knowledge-base bytes (documents, images) never pass through Vercel.
- Relay — a standalone, multi-tenant relay service (
relay/directory:relay/cf/Workers target +relay/node/self-host target, one protocol contract). Operated by the product as shared infrastructure (one or more official instances) so that regular users need zero server knowledge; because it is a single small service, self-hosting it remains possible for power users. It does exactly one job: move requests from remote clients (phone Web / Claude mobile MCP / any Agent) to the home device over the tunnel and stream results (including binary assets) back — Agents can also write back through it (kb.write/kb.create). Zero knowledge-base data at rest. - Engine (
engine/, Rust) — everything on the user's computer.
┌─ User's computer (home) ─────────────────────────────────────┐
│ ~/.homekb/ Data root (md/assets/index snapshot) │
│ homekb CLI (engine) Compile + retrieval + MCP(stdio) + pairing + tunnel│
│ homekb serve HTTP RPC + /assets │
│ · loopback bind (default): no auth — desktop data source │
│ · public bind (optional): Bearer serveToken (hkd_) — power-user/API access│
│ Tauri App (src-tauri) Pure renderer: detect/install engine → connect to serve│
└──────┬───────────────────────────────────────────────────────┘
│ Outbound SSE tunnel (works without a public IP)
┌──────┴──────────────────────────────┐
│ Connection service ("relay", │ Runs anywhere — official hosted,
│ standalone Node service, │ a user's server, or the home
│ multi-tenant, zero KB data) │ machine itself (public HTTPS
│ /api/relay/* RPC + asset piping │ required — ex-"direct mode")
│ /api/mcp remote MCP (OAuth = │
│ enter pairing code) │
│ relay.db: homes/grants/pair_codes │
│ (hashes only) │
└──────┬──────────────────────────────┘
│ Bearer hkt_
│
Web UI (Vercel, pure static frontend) · Claude mobile app · any MCP client
One remote concept: the connection service (relay). A remote client always connects the same way — pair with an 8-char code at a connection service, which forwards traffic to the home over the tunnel. There is no separate client-visible "direct mode". The desktop app is the only other mode, and it is implicit (local serve, auto-detected). Where the service runs is a home-side choice, invisible to the connecting phone:
- Official hosted service (default):
https://homekb-relay.wangjintaoapp.workers.dev(Cloudflare Workers). This is the URL the engine registers against whenhomekb registeris run with no--relay, and the value the Web/mobile pairing screen prefills as its Service address (later: several instances, auto-pick the nearest). - Self-hosted on any server the user controls (fill in its URL when registering).
- Self-hosted on the home machine itself — this is what used to be "direct mode": the phone talks straight to the home computer, zero middlemen, same protocol and same port as any other relay. Requires the machine to be publicly reachable over HTTPS (domain + cert, or a Cloudflare/Tailscale-style tunnel) — that part is the user's responsibility; the app only starts the service and states the requirement.
The desktop Remote tab manages a service list and registers the home with one entry:
- Entries: built-in services baked at build time (
NEXT_PUBLIC_BUILTIN_SERVICES, comma-separated URLs; when unset it falls back to the one official hosted relayDEFAULT_RELAY_URL— the same default the pairing screen prefills andhomekb registerbakes in, so a fresh home lands on a ready-to-use service with zero typing) + user-added URLs (their own deployment, a shared third-party one, or this machine's own service). Built-in entries can't be removed; the user still adds and switches to their own to replace which service the home registers with. One unified "Add service" entry point that enforceshttps://for new entries (isAllowedServiceUrl) — a service URL gets advertised inside pairing QRs and a real phone can only reach an https one. But a non-https existing registration (e.g. ahttp://localhostused for local dev testing) is not blocked and never hides the QR — the pairing card always renders once registered; the Connection card shows an inline warning that it won't work for a phone on another network, plus a "Disconnect" action (relay_clear) and "Change service…". - Reachability + auto-select: every entry is probed via
GET /api/relay/ping(reachable? latency?). Auto-select picks, among reachable entries: a "this machine" entry first (the user marked it as running on this computer), else the lowest latency. Selecting an entry (manually or via auto-select) runshomekb register --relay <url>— registration inconfig.toml [relay]stays the single source of truth. Registering mints a new home identity (home_id/home_secret), sohomekb registeritself (a) retires the previous registration at its service — best-effortDELETE /api/relay/homewith the old credentials, so devices paired to the old identity get 401 and auto-unpair instead of rotting as forever-offline zombies — and (b) restarts an installed launchd tunnel onto the new credentials (engine-level fixes — CLI re-registrations are covered too). The desktop additionally installs + starts the tunnel when none exists yet (first-time setup — pairing is the point of registering). - First-run zero-config lives in the ENGINE, not the client (engine-first):
homekb pairbootstraps on first run — with no registration it enrols against the built-in official default and (macOS) installs the tunnel + compile agents, then mints the code. This holds for both humanhomekb pairand the desktop'spair_new(homekb pair --json), so the client is a thin shell that never carries registration logic of its own — it just asks the engine to pair, and the engine connects to the default service if needed. Registering only makes the home connectable; no device can read the notes until it pairs (a pairing code still gates every grant), so data sovereignty is untouched. The desktop therefore surfaces the pairing card even before a registration exists: "Generate pairing code" triggers the engine's bootstrap. The picker is a "use a different service" affordance, not a gate. - The list is desktop UI state (localStorage
homekb.services.v1), not engine config: CLI users simplyhomekb register --relay <url>directly. - Local service (separate control, decoupled from the connection card; default off): a toggle that starts/stops the bundled relay on this machine (port 8787). Turning it on prompts the requirement — a public HTTPS domain pointing at this machine — and guides the user to add that domain to the service list (marked "this machine", so auto-select prefers it). Lifecycle v1: the desktop spawns
node ~/.homekb-relay/server.mjs(script installed there together withnode_modules/{better-sqlite3,bindings,file-uri-to-path}— the esbuild bundle keeps the native module external; envHOMEKB_RELAY_DISToverrides the script path; pid in~/.homekb-relay/relay.pid; the process outlives the app — it is a service phones depend on). launchd management + bundling the relay runtime = follow-up.
Why HTTPS is non-negotiable (and why LAN-direct died): the Web UI is served over HTTPS, and a browser silently blocks an HTTPS page from
fetch()-ing a plain-HTTP origin (mixed content). A raw LAN address (http://192.168.x.x) cannot get a normal TLS cert, so same-LAN plain-HTTP connections can never work in a browser. Same-network users simply use the connection service like everyone else.
The client pairing screen never mentions "relay": the user types a pairing code or scans a QR. The pairing-code form is the default surface on every device (desktop and phone) — leading with the camera adds friction, so scanning is demoted to a "Scan the QR code instead" action one tap below the form. Manual path: the service address field is visible, prefilled with the official default (NEXT_PUBLIC_RELAY_URL, falling back to https://homekb-relay.wangjintaoapp.workers.dev when unset) — with the field pre-filled, a fresh client only needs to type the pairing code. Without scanning, the client has no way to know which service the home registered with, so the user must still be able to see and edit it. Scan path: nothing else is exposed — the QR carries the service URL and the code. Which service a QR advertises is decided by the home's registration (config.toml [relay] url) — the desktop configuration is the single source of truth.
| Path | Contents |
|---|---|
~/.homekb/notes/ |
Markdown knowledge body (the engine's only scan target, recursive) |
~/.homekb/drafts/ |
Unpublished drafts (<id>.md, one file per draft; not a scan target — never indexed). Lives on the home device so every paired client shares the same drafts; publishing a draft moves it into notes/ via kb.create and deletes the draft file. Attachments are not separated: drafts reference the same shared assets/ (see below), so publishing never has to move or rewrite an asset |
~/.homekb/assets/images/ |
Image assets — originals only; the image variant service never rewrites them (see "Image variant service") |
~/.homekb/assets/attachments/ |
Other attachments |
~/.homekb/cache/images/ |
Derived image variants (resized/transcoded renditions built on demand by the image variant service). Regenerable cache — safe to delete wholesale, rebuilt on the next request. Deliberately inside the data root (data cohesion: one folder = the whole product state); plain immutable files written by atomic rename (no WAL), so cloud-drive sync can't corrupt anything — it just costs avoidable sync traffic |
~/.homekb/shares.json |
Share records (public share links): id, note path, optional salted password hash, optional expiry. Engine-owned truth — the relay stores only a shareId → home routing record. Low write volume; written by atomic rename, re-read per request (serve and tunnel processes both answer share RPCs) |
~/.homekb/index/index.db |
Index snapshot (sqlite-vec, single-file export, safe for cloud-drive sync) |
~/Library/Application Support/homekb/live.db |
Compile working DB (WAL, kept out of the data root) |
~/.homekb/config.toml |
Configuration (AI provider keys, path overrides, relay credentials). Fixed anchor: the file always lives at ~/.homekb/config.toml even when root/notes_dir redirect the data elsewhere (the config defines those paths, so it cannot move with them). ~/.homekb/ folder to a cloud drive uploads the keys too — documented trade-off of the self-contained layout; exclude config.toml from sync if that matters. Legacy location ~/.config/homekb/config.toml is still read when the new path does not exist; any config write migrates to the new path (the legacy file is renamed to config.toml.migrated). $HOMEKB_CONFIG overrides everything (test isolation) |
Notes reference images with standard relative Markdown paths from the note file, assuming the default layout where notes/ and assets/ are siblings under the data root: a note notes/foo.md writes ; a note notes/sub/foo.md writes . This keeps notes portable — the same reference renders in Obsidian / VS Code / GitHub without HomeKB.
Drafts and notes share one asset store — there is no draft/published split. A draft drafts/<id>.md references attachments through the same assets/ tree with the same relative form (../assets/images/bar.png). Because drafts/ and notes/ are both exactly one level under the data root, that reference resolves to the identical asset from either location, so a draft's images/attachments stay valid the instant it is published (drafts/ → notes/) with zero asset moves or rewrites. Every attachment-upload flow must write into the shared assets/ (images/ or attachments/), never a draft-scoped copy. When resolving a draft's refs, treat it as sitting one level under the root (like a top-level note) so the .. math is identical.
Editor image upload (paste / drag-drop): the client uploads the raw bytes through the asset service (POST /assets/<path> on serve, POST /api/relay/asset/<path> through the relay — see "Binary asset channel", upload direction), gets back the final asset path (the engine owns naming: sanitized filename, -2/-3… collision suffixes), and inserts a standard relative reference computed from the note's location (composer / drafts count as one level under the root → ../assets/images/<name>; a note sub/foo.md → ../../assets/images/<name>). Uploading requires the home to be reachable — there is no offline asset queue (same decision as drafts).
Renderer resolution rule (identical in every renderer — Web, desktop, anything future):
http(s):,data:,blob:srcs pass through untouched.- Any other src is resolved against the note's own location under a virtual data root (
notes/<notePath>joined with the src,./..normalized, never escaping the root). - If the resolved path lands under
assets/→ the remainder is the asset path, fetched through the asset service (GET /assets/<path>on serve,GET /api/relay/asset/<path>through the relay — see "Binary asset channel").relaymode fetches with theAuthorizationheader and renders blob URLs;desktopembeds plain serve URLs. - Anything else (escapes the root, points inside
notes/, unresolvable) renders as a broken-image placeholder — never fetched.
The rule is defined against the virtual root, so it keeps resolving correctly even when notes_dir is overridden to a custom directory (on-disk editor portability is then the user's trade-off, not the renderer's problem).
On-demand image transformation, ported from claude-os's generic image service and embedded at the engine's bottom layer so every access path — serve GET /assets/*, the tunnel asset channel, share-scoped asset fetch — serves the same variants with zero client changes. URLs identify the original resource: notes/markdown always reference the original asset path, never a variant. Which representation comes back is decided by query parameters + Accept content negotiation:
| Request | Representation |
|---|---|
?raw=1 |
Original bytes, untouched (download/backup) |
?w=&h=&fit=&q=&f= |
Explicit transform: w/h bounding box (clamped 16–6000), `fit=inside\ |
| (no params) | Web-friendly default: long edge ≤ 2000, q 80, format negotiated — what <img> renderers get without asking |
- Transformable sources: jpg/jpeg/png/webp, plus heic/heif (which must transcode — browsers can't render them; decoded via a
sipssubprocess, macOS only; on other platforms a HEIC request → 404). gif (animation), svg (vector), and every non-image asset always stream the original bytes regardless of params. - Format negotiation (
f=auto):Acceptcontainingimage/webp→ webp; else a png source stays png (alpha-safe); else jpeg. Variant responses carryVary: Accept. - EXIF orientation is applied (auto-rotate) before resizing.
- Cache: derived files land in
~/.homekb/cache/images/<k2>/<key>.<ext>,key= hash(source path + mtime + size + transform params + output format) — editing the source invalidates automatically. Variants are built on demand (tmp file + atomic rename; concurrent duplicate builds are harmless) and served from cache afterwards; asset uploads warm the default variants fire-and-forget. - Conditional requests (serve only):
ETagfrom the variant key (raw path: source identity),If-None-Match→ 304.Cache-Control: private, max-age=3600— asset names are stable but user-mutable on disk, so neverimmutable. - Failure fallback: a failed transform (corrupt file etc.) falls back to the original bytes — except HEIC, which has nothing renderable to fall back to → 404.
- Through the tunnel: the relay forwards the client's query string and
Acceptheader as optionalquery/acceptfields on the SSEassetevent (see "Binary asset channel"); the home applies identical rules before streaming. Older engines ignore the extra fields and stream originals; older relays omit them and the home builds the default variant without webp negotiation — both directions degrade gracefully.
Location: $HOMEKB_CONFIG > ~/.homekb/config.toml (new-home anchor) > ~/.config/homekb/config.toml (legacy, read-only fallback; migrated on first write).
# Path fields are optional; the commented values are the defaults.
# root = "~/.homekb"
# notes_dir = "<root>/notes" # Can point to any existing md directory
# drafts_dir = "<root>/drafts" # Unpublished drafts; stays under root even if notes_dir is overridden
# snapshot_path = "<root>/index/index.db"
# live_db = "<platform data dir>/homekb/live.db"
# chunk_target_tokens = 800
# chunk_hard_max = 2000
# summary_diff_threshold = 0.15
# embed_concurrency = 8
# embed_batch_size = 100
# share_web_base = "http://localhost:3000" # Web UI origin used to compose share URLs (<base>/s/<id>?r=<relay>);
# set to the official Web origin once deployed
[embedding] # REQUIRED for compile & retrieval (product-side: "Embedding" setting)
provider = "openai" # openai | gemini | voyage | cohere | qwen | custom — see provider preset table
api_key = "" # Resolution: this field > provider env var (see table). provider=openai keeps
# the legacy last-resort fallback ~/.config/openai/api_key for existing installs.
# model = "" # Defaults per provider (see table)
# dim = 0 # Expected vector dimension; defaults per provider's default model.
# Validation only — the request never sends a dimensions param (change model → rebuild).
# base_url = "" # Required only for provider = "custom": any OpenAI-compatible /v1/embeddings endpoint
[summary] # REQUIRED for compile (doc summaries + doc_type + suggested question).
provider = "openai" # openai | gemini | deepseek | qwen | custom — OpenAI-compatible /v1/chat/completions
api_key = ""
# model = "" # Defaults per provider (see table)
# base_url = "" # provider = "custom" only
[ask] # OPTIONAL — the ask pipeline's LLM (route + synthesize). When the whole
# provider = "" # section is absent, ask falls back to the [summary] endpoint, so `homekb ask`
# api_key = "" # works out of the box. Product rationale: retrieval-only integrations (other
# model = "" # Agents via MCP/RPC bring their own LLM) never need to fill this in; power
# base_url = "" # users set it to answer with a stronger model than the summarizer.
[relay] # Written by homekb register
url = "https://homekb-relay.wangjintaoapp.workers.dev" # The relay this home is registered to (official default, or self-hosted)
home_id = "hm_xxx"
home_secret = "hks_xxx" # Plaintext stored only on the local machine
name = "MacBook"
[serve] # All optional; defaults shown
# host = "127.0.0.1" # Non-loopback (e.g. "0.0.0.0") = authenticated public bind (power-user/API access), requires token
# port = 8765
# token = "hkd_xxx" # serveToken; auto-generated + persisted on first public bindLegacy keys (still parsed, deprecated): top-level openai_api_key, embedding_model, summarizer_model map onto [embedding]/[summary] with provider = "openai" when those sections are absent, so pre-provider configs keep working unchanged.
One OpenAI-protocol client serves every built-in provider — a preset is just a base URL + defaults + an env-var fallback for the key. Vendors with OpenAI-compatible APIs need no dedicated SDK; anything else compatible plugs in via provider = "custom" + base_url.
| Provider | Base URL | Default embedding model (dim) | Default chat model | Key env fallback |
|---|---|---|---|---|
openai |
https://api.openai.com/v1 |
text-embedding-3-small (1536) |
gpt-4o-mini |
OPENAI_API_KEY |
gemini |
https://generativelanguage.googleapis.com/v1beta/openai |
gemini-embedding-001 (3072) |
gemini-flash-lite-latest |
GEMINI_API_KEY |
voyage |
https://api.voyageai.com/v1 |
voyage-4 (1024) |
— (embedding only) | VOYAGE_API_KEY |
cohere |
https://api.cohere.ai/compatibility/v1 |
embed-v4.0 (1536) |
— (embedding only) | COHERE_API_KEY |
deepseek |
https://api.deepseek.com/v1 |
— (chat only) | deepseek-chat |
DEEPSEEK_API_KEY |
qwen |
https://dashscope.aliyuncs.com/compatible-mode/v1 |
text-embedding-v4 (1024) |
qwen-flash |
DASHSCOPE_API_KEY |
custom |
from base_url |
model + dim required |
model required |
— |
qwen is Alibaba Cloud DashScope's OpenAI-compatible mode (mainland-China endpoint — the default for the domestic audience these presets target; the Singapore region dashscope-intl.aliyuncs.com plugs in via provider = "custom"). Per-provider embedding batch caps: some providers hard-reject large /v1/embeddings batches (DashScope caps at 10 inputs per request, Cohere's compat layer at 96), so the engine clamps the effective batch to min(embed_batch_size, provider cap) — a preset carries its cap and embed_batch_size stays a throughput knob, never a correctness one.
The index snapshot records embedding_provider (+ embedding_base_url for custom) alongside embedding_model/embedding_dim in index_meta, so the query side always embeds in the same vector space regardless of the current config. Old snapshots without the key are treated as openai.
Vector-space drift self-heals — rebuild --force is never required. When the compile pipeline opens the live db and finds its stored embedding identity (provider/model/dim) differs from the current config, it automatically resets the live db to the new config and re-embeds the whole corpus — the same wipe + re-seed rebuild --force performs, now applied by any compile path (scheduled watch, write-triggered compile, kb.reindex, CLI reindex). Switching the embedding endpoint in Settings therefore just works: the next compile rebuilds into the new space with no user-facing command. The reset is loud (warn log with both identities) and the existing guards keep it safe: import_if_newer refuses to re-adopt an old-space snapshot into the reset live db, and a run that embeds zero vectors (bad key, broken endpoint) skips the snapshot export so the last good snapshot stays queryable. A schema_version mismatch (binary vs db skew) still bails — that is version skew, not an endpoint choice. homekb rebuild --force remains as the manual full re-embed for technical users.
Unconfigured means unconfigured. With no [embedding] section in config.toml (and no legacy top-level key), the engine treats AI as not configured: the compile pipeline no-ops cleanly (nothing scanned, nothing stamped — the live db is never seeded with a default provider), status/kb.status report aiConfigured: false, Settings surfaces render "not configured" instead of a preset default, and Config::save() (used by init/register, which run long before the user picks a provider) never materializes unconfigured [embedding]/[summary] sections into the file — a phantom section would silently turn the default into an "active" configuration on the next load. Defaults like openai/text-embedding-3-small only exist as per-provider fill-ins once the user has chosen a provider — they are never presented as an active configuration. This kills the fresh-setup trap where the resident compile's first cycle stamped the brand-new empty db with the default provider before the user had configured anything.
Rebuild is non-destructive to a working index. rebuild --force resets the live db to the new embedding config (drops + recreates the vec0 tables at the new dimension, re-seeds the meta) but leaves the exported snapshot in place. Two guards make the switch safe: (a) import_if_newer refuses to re-adopt a snapshot whose vector space (provider/model/dim) differs from the live db — so the reset live db is not silently overwritten by the old snapshot; (b) reindex skips the snapshot export when a run embedded zero vectors (every document failed — bad key, disabled billing, wrong model) — so a failed switch keeps the last good snapshot queryable instead of leaving an empty knowledge base. A genuinely empty corpus (no errors, no vectors) still exports. Each provider has its own default model (see the preset table); legacy top-level keys (embedding_model/summarizer_model) only seed an openai section and never leak a model name into another provider.
Positioned like Git: a plain subcommand-based CLI, no interactive REPL. Without installing any client, the CLI carries all core capabilities — ask/retrieval, saving documents, exposing to other Agents (MCP), and mobile pairing. The client is merely a usability wrapper.
homekb # No subcommand = print help (like git)
homekb ask [--json] <QUESTION> # Ask: retrieval + LLM-synthesized answer (with citations)
homekb query [--json] [--limit N] [--type T] [--full] [--group] [--max-distance D] [--enumerate] [--route] QUERY
homekb query --list-types [--json] # List the doc_type vocabulary (name + count, for category routing before search)
homekb new [--title T] [FILE] # Add a new note (reads stdin when FILE is omitted)
homekb init [--root PATH] [--notes PATH] [--openai-key KEY] # Create the directory tree + write config
homekb reindex [--quiet] [--reclassify] # One incremental compile; --reclassify resets ALL doc_type labels first so the taxonomy re-emerges (repair for a collapsed vocabulary).
# Self-bootstrapping on the DEFAULT layout: a missing ~/.homekb/notes is created (so the pair-installed
# compile agent works before any `homekb init`); an overridden notes_dir must exist (a typo'd custom
# path must fail loudly, not become a forever-empty library)
homekb watch [--interval SECS=300] # Foreground compile loop (launchd target)
homekb watch --install [--interval N] / --uninstall / --status [--json] # Manage the launchd compile service (macOS only)
homekb status [--json]
homekb rebuild --force
homekb mcp # Expose to other Agents: local MCP server (stdio)
homekb mcp --install [claude|codex|cursor] / --uninstall [claude|codex|cursor] # Register/remove this engine in agents (absolute binary path; no value = every agent detected: claude/codex CLIs on PATH, Cursor when ~/.cursor exists)
homekb serve [--host H] [--port 8765] # HTTP RPC + /assets (loopback = desktop data source; public bind = authenticated power-user access)
homekb register [--relay URL] [--name NAME] # Register with a connection service → write [relay]; --relay defaults to the official hosted relay (https://homekb-relay.wangjintaoapp.workers.dev); retires the previous registration (best-effort), re-registers active share routes at the new service (best-effort), and restarts an installed tunnel
homekb unregister # Retire the current registration at the service (best-effort), remove [relay], uninstall the launchd tunnel
homekb pair [--json] # Generate a pairing code (call the registered connection service); --json for the desktop client to parse (JSON is the FINAL stdout line).
# First-run bootstrap (BOTH human and --json — the engine owns the default; the client is a thin shell):
# not registered yet → registers with the official hosted relay first, and on that fresh-registration path
# also installs the tunnel (--interval 0) + compile (--interval 300) agents (macOS), so "install engine →
# homekb pair (or the desktop's Generate) → enter the code on the web" is the complete minimal path.
# In --json mode the enrolment progress goes to stderr so stdout stays the single JSON result line.
# Already registered → mints a code only (never reinstalls agents; prints a hint if the tunnel isn't running)
homekb share PATH [--password PW] [--expires-days N] [--json] # Create a public share link for one note (requires an active [relay] registration)
homekb share --list [--json] / --revoke SHARE_ID # List active shares / revoke one (revocation kills the link instantly)
homekb tunnel [--interval SECS=300] # Foreground daemon: tunnel + built-in scheduled compile (launchd target)
homekb tunnel --install [--interval N] / --uninstall / --status [--json] # Manage the launchd background service (macOS only)
homekb start [--interval SECS=300] # Start the engine's background services on this machine (compile always; tunnel when registered)
homekb stop # Pause the engine's background services on this machine (reversible; keeps everything)
homekb uninstall [--yes] # Remove the engine from THIS machine — never touches your knowledge base
homekb start / homekb stop are the plain-language lifecycle pair over the launchd services (friendlier aliases for the tunnel --install / watch --install power flags, which stay). start installs + starts the com.homekb.compile service always (local compilation needs no relay) and the com.homekb.tunnel service only when this machine is registered with a connection service — an unregistered tunnel would fail-loop under KeepAlive, so start skips it and points the user at homekb pair instead. It is idempotent (launchd install reboots the services onto the current binary + interval). stop is the exact reverse: it stops + removes both services and nothing else. macOS-only for now (launchd); elsewhere run homekb tunnel / homekb watch in the foreground under a process manager.
Two graduated exits, both leaving ~/.homekb/ untouched:
homekb stop is the reversible pause: it stops + removes the com.homekb.tunnel and com.homekb.compile launchd services and nothing else — the binary, config.toml (including [relay]), the live.db working DB, and the connection-service registration all stay. While the tunnel is down remote devices simply see the home go offline; resume any time with homekb start (or homekb pair on a machine that isn't connected yet).
homekb uninstall is the full removal: it retires the engine's footprint on the current machine while treating ~/.homekb/ as sacrosanct — the command has no code path that deletes under the data root, so notes / assets / index / drafts always survive (reinstall + homekb reindex restores the running state from them). Sequence (each step best-effort, non-fatal, and skipped when already absent): (1) unregister — best-effort retire at the connection service so paired devices auto-unpair instead of rotting as offline zombies, then clear only the [relay] section of config.toml (AI keys and every other config field are kept); (2) stop + remove the com.homekb.tunnel and com.homekb.compile launchd services (macOS; a no-op elsewhere); (3) remove the homekb MCP registration from agents (claude mcp remove -s user / codex mcp remove, skipped when the CLI isn't on PATH, and the mcpServers.homekb entry in Cursor's ~/.cursor/mcp.json — no dangling dead MCP server after the binary is gone); (4) delete the regenerable live.db working DB (+ its -wal/-shm/lock siblings) under the platform data dir and the ~/Library/Logs/HomeKB/ logs — both live outside the data root by design; (5) delete the homekb binary itself (std::env::current_exe), unless it sits under a package manager (Homebrew Cellar / Scoop), in which case it is left in place with a hint to run brew uninstall / scoop uninstall. Safety: without --yes the command prints the plan and the exact paths it would remove, then aborts without doing anything (a built-in dry run). Removing the launchd services is macOS-only; on Linux/Windows steps (1), (3), (4), (5) still apply.
Integrate the local MCP with one command: homekb mcp --install registers the server in every supported agent it detects — the claude / codex CLIs on PATH, and Cursor when ~/.cursor/ exists; pass a value (--install claude / codex / cursor) to target one. The registration always uses the engine binary's absolute path (current_exe, kept as launched so a Homebrew symlink stays stable across upgrades) and never the bare homekb command — the agent's MCP launcher does not necessarily share the shell's PATH (e.g. ~/.local/bin is missing from a GUI-launched agent), and a bare-command registration then fails with an opaque "Failed to connect". Claude Code is registered at user scope (-s user, available in every project — the knowledge base is machine-global, and the default local scope would silently bind the server to whatever directory the command happened to run in). Cursor has no MCP CLI, so the engine edits the global ~/.cursor/mcp.json directly: it only touches the mcpServers.homekb entry, preserves everything else, and refuses to clobber a file it cannot parse (errors instead). Installs are idempotent (best-effort remove, then add; Cursor's entry is simply overwritten). homekb mcp --uninstall [claude|codex|cursor] reverses it. When the agent CLI isn't on PATH the command prints the manual equivalent instead: claude mcp add homekb -s user -- <absolute-path-to-homekb> mcp / codex mcp add homekb -- <absolute-path-to-homekb> mcp / for Cursor, {"mcpServers":{"homekb":{"command":"<absolute-path-to-homekb>","args":["mcp"]}}} in ~/.cursor/mcp.json.
The engine ships as a self-contained single binary — SQLite and sqlite-vec are compiled in (bundled), TLS is rustls — so an installed binary has zero runtime prerequisites (no Rust, no system libraries; an AI API key in config is the only requirement to compile/ask).
-
Release tags:
engine-v<version>(version =engine/Cargo.tomlworkspace version). Distinct from the desktop app'sv<version>tags in the same repo — tooling that resolves "latest engine release" must filter by theengine-vprefix, never usereleases/latest. -
CI:
.github/workflows/engine-release.yml— tag push (or manual dispatch) builds + tests the matrix and attaches artifacts to the GitHub release. Artifact names are a contract (consumed byinstall.sh, the Homebrew formula, the Scoop manifest, and the desktop client's engine installer) and carry no version — the tag does:homekb-macos-arm64.tar.gz(aarch64-apple-darwin)homekb-macos-x64.tar.gz(x86_64-apple-darwin)homekb-linux-x64.tar.gz(x86_64-unknown-linux-gnu, built on the oldest supported runner → glibc ≥ 2.35, covering every currently maintained distro; the alternative fully-static musl target is deferred — its aws-lc-sys crypto backend is a persistent build headache on musl)homekb-windows-x64.zip(x86_64-pc-windows-msvc)
Each archive contains exactly one file: the
homekbbinary (homekb.exeon Windows). -
Install channels:
install.sh(repo root):curl -fsSL https://raw.githubusercontent.com/do-md/homekb/main/install.sh | sh— detects OS/arch, downloads the latestengine-v*release, installs to~/.local/bin/homekb. Always removes the old binary before copying (macOS kernel signature cache: in-place overwrite of the same inode gets the binary SIGKILLed).- Homebrew (macOS/Linux): tap
do-md/homebrew-tap, formulahomekb→brew install do-md/tap/homekb. Formula pins the release tag + per-artifact sha256. - Scoop (Windows): bucket
do-md/scoop-bucket, manifesthomekb→scoop bucket add homekb https://github.com/do-md/scoop-bucket && scoop install homekb. Manifest pins the release tag + artifact sha256. - Linux has no dominant universal package manager;
install.shis the canonical path (Homebrew-on-Linux also works).
-
Channel bumps are CI-automated: the release workflow's
publish-channelsjob (runs after the GitHub release is created) computes the sha256 of the artifacts it just built, re-rendersFormula/homekb.rbindo-md/homebrew-tapandbucket/homekb.jsonindo-md/scoop-bucket, and pushes both — the formula/manifest files are generated, never hand-edited in the channel repos. Cross-repo push authenticates with thePACKAGES_PUSH_TOKENActions secret indo-md/homekb(fine-grained PAT, Contents read/write on the two channel repos only); if the secret is missing or expired the job fails loudly while the release itself stays published (fallback: manual bump, same rendered shape). -
Platform caveat: the resident-service management commands (
watch --install,tunnel --install) are launchd-based and macOS-only for now; on Linux/Windows they fail with a clear message andwatch/tunnelrun in the foreground instead (systemd user-unit support planned). Everything else — compile, query, ask, MCP, serve, register/pair, tunnel — is platform-independent.
The ask pipeline (core::answer, completed end-to-end inside the engine): LLM routing (infer the doc_type filter + whether the full document is needed + whether the question is a category enumeration — "what recipes do I have" wants coverage of a category, not a KNN top-K — + whether a synthesized answer is needed at all, the answer dimension consumed by auto mode below) is dispatched in parallel with query vectorization (the vector depends only on the question string, not on the routing result) → retrieval: normally dual-pool local KNN with the ready-made vectors; when the router says enumerate with a valid docType, retrieval switches to the category summary sweep (every doc of the category, content = compiled summary, ranked by summary-vector distance, no distance cutoff — the synthesizer then judges over the whole category's summaries, which beats vector recall for enumeration/recommendation intents; needs_full is ignored in this mode) → assemble the context block (numbered per source document — one [n] per doc with its snippets listed under it) → LLM-synthesized answer (based solely on the retrieved snippets, citing sources, following the question's language). The user-facing hits/citations are source-level (see the kb.ask row in the RPC table). Three external requests total: route(chat) ∥ embed(embeddings) → synthesize(chat), reusing shared per-endpoint OpenAI-protocol clients in-process (connection-pool keep-alive). Route + synthesize use the [ask] endpoint (falling back to [summary]); embed uses the [embedding] endpoint pinned by the snapshot meta. Ported from claude-os kb.service's kbInferRoute/kbSynthesize. The engine exposes both a one-shot ask() (blocking synthesize) and an ask_stream() (synthesize via OpenAI create_stream, emitting AskStreamEvent::Delta chunks then a terminal AskStreamEvent::Done{citations,hits}) — both share the identical route/embed/retrieve path; only the synthesize call differs.
Auto mode (auto: true on the streaming kb.ask — the UI's single ask entry): the router additionally decides answer: boolean — false when the intent is retrieval/browse (find my notes about X, list a category, bare keywords: the user wants the notes themselves), true when the question wants a synthesized reply (how/why/what-is/compare/recommend/summarize). When the router says answer: false, the engine skips synthesis entirely: it reuses the already-computed route + query vector to run the same search a routed kb.query would (enumerate sweep when routed with a valid docType, otherwise grouped doc-level KNN — group: true, limit 20, maxDistance 1.1), and emits a terminal results frame (see "Streaming answer channel") instead of sources/delta/done. When it says answer: true (or auto is off, or routing fails — the failure default is answer: true, preserving today's behavior), the normal answer flow runs unchanged. Cost model: auto adds zero LLM calls and zero latency — the same single route call already ran in parallel with embedding; a list decision actually saves the synthesize call. Engines that predate auto ignore the param and always answer (clients degrade gracefully). One-shot kb.ask, MCP, and the CLI are unaffected: agent callers decide answer-vs-search themselves by choosing kb_ask vs kb_search.
First-paint batch (hits frame — progressive delivery): nothing the user sees first should ever wait on a chat-model round trip. On every streaming kb.ask the route call is spawned concurrently, but the engine does not wait for it: as soon as the embedding returns it runs an unrouted grouped doc-level KNN (pure local vector math on the ready vector) and emits one early event: hits {"hits":[Hit]} frame — the first-paint note list, typically sub-second. The routed outcome then refines it: a terminal results frame (list path) or the sources frame (answer path — its doc-level hits are the authoritative source list) replaces the early batch; clients key by path so unchanged notes don't re-render. Frame order is always hits → (results | sources → delta* → done) | error. The stages a client can render map 1:1 onto frame arrivals: submit→hits = vector search, hits→route outcome = query analysis, sources→done = answer synthesis. Clients that predate hits ignore the unknown frame; engines that predate it simply never send it — both directions degrade gracefully.
POST /rpc {method, params} → {ok, result} | {ok:false, error, message}. The method set is identical to the tunnel RPC (see the table below). Used by the desktop client, local scripts, and — over an authenticated public bind — power users' own tooling.
POST /rpc/stream{method, params}→text/event-stream— the streaming variant,kb.askonly (the UI's ask entry). Params:{query, auto?}(auto: true= the engine decides answer-vs-list; see "Auto mode"). First frame:event: hitsdata: {"hits":[Hit]}— the unrouted first-paint batch, emitted right after embedding, before the route resolves (see "First-paint batch"). Then on the answer path:event: sources,event: deltadata: {"text":"<chunk>"}(incremental answer text, many), and exactly oneevent: donedata: {"citations":[{path,title}],"hits":[Hit]}(final source metadata). On the auto list path: a terminalevent: resultsdata: {"hits":[Hit],"route":{docType?,enumerate}}(grouped doc-level hits, same shape as routedkb.query {group:true}output) instead. On failure a singleevent: errordata: {"code","message"}arrives instead of the terminal frame. A non-streamable method → oneerrorframe{code:"not_streamable"}. Same auth + CORS as/rpc. The one-shotPOST /rpckb.askstays for MCP / CLI / power tooling (no streaming, no auto there).GET /health→{ok:true, version:"<engine version>", binPath:"<absolute path of the serving binary>", binMtime:<epoch-seconds mtime of that binary, captured at process start>}(liveness + identity probe, always unauthenticated: the desktop client uses it to tell whether serve is already running and whether it is stale — see "serve lifecycle"). Engines predating the identity fields return plain{ok:true}; a prober must treat missing identity as stale.GET /assets/<path>→ serves a file under~/.homekb/assets/(e.g./assets/images/foo.png→~/.homekb/assets/images/foo.png). Path-traversal-safe (resolved path must stay inside the assets root). Transformable images return a derived variant by default, not the original — query protocol (?raw=1/?w=&h=&fit=&q=&f=/ parameterless web default),Acceptnegotiation (Vary: Accept) andETag/304 are defined once in "Image variant service". Non-image assets and non-transformable formats (gif/svg/…) always stream the original.Content-Typematches the served representation,Cache-Control: private, max-age=3600. Missing file → 404{ok:false, error:"not_found"}.POST /assets/<path>→ asset upload: raw bytes body written under~/.homekb/assets/.<path>is the suggested relative path and must beimages/<name>orattachments/<name>(exactly one directory level, kind allowlisted); the engine sanitizes the filename and resolves name collisions with a-2/-3… suffix, then returns the final path:{ok:true, path:"images/<final-name>"}. Bad kind/name → 400{ok:false, error:"bad_path"}; body capped at 50 MB → 413. Auth identical toGET /assets/*(loopback exempt, otherwise Bearer serveToken). This is the write half of the shared asset store (see "Image references in notes" — uploads never create draft-scoped copies).- Bind address: default
127.0.0.1(config[serve] hostor--hostto override). Binding a non-loopback address enables the authenticated public bind (a power-user/API capability — the client UI has no direct mode; remote clients go through a connection service). - Authentication: requests from non-loopback peers must send
Authorization: Bearer <serveToken>(hkd_) on/rpcand/assets/*; loopback peers are exempt (the desktop webview keeps working unchanged even when serve is publicly bound). On the first non-loopback bind with no token configured, serve generates one, persists it toconfig.toml [serve] token, and prints it once. - CORS — two policies keyed on the bind address:
- Loopback bind (default): fixed allowlist, never
*(prevents arbitrary browser pages from driving local retrieval):tauri://localhost,http://tauri.localhost,http://localhost:3000,http://127.0.0.1:3000(the latter two arenext devdebugging origins). - Non-loopback bind: any origin,
Authorizationheader allowed — data is gated by the Bearer token, not by origin.
- Loopback bind (default): fixed allowlist, never
For the desktop client to parse: the JSON {"code","expiresAt","relayUrl","homeName"} (expiresAt is epoch milliseconds; comes from the connection service's POST /api/relay/pair) is the final stdout line — the parser takes the last line beginning with {. First-run bootstrap runs here too (the engine enrols with the official default when unregistered), and its progress is written to stderr, so stdout stays the single JSON result. Without --json it keeps the human-readable output.
The UI (features/kb) is written once; the transport layer (lib/client) routes by connection mode:
| Mode | How chosen | Base URL | Auth |
|---|---|---|---|
desktop |
Auto: window.__TAURI_INTERNALS__ present |
http://127.0.0.1:8765 (serve) |
None (loopback) |
relay |
The only remote mode: scan a QR or enter a pairing code on the connect screen | <relayUrl>/api/relay |
Bearer clientToken (hkt_) |
- The connect screen exposes no mode choice and never says "relay": the default surface (all devices) is the pairing-code form — the user types the pairing code + the service address (visible field, prefilled from
NEXT_PUBLIC_RELAY_URL, falling back to the official hosted relayhttps://homekb-relay.wangjintaoapp.workers.devwhen unset — never a localhost fallback); or taps "Scan the QR code instead" and scans the QR from their home computer (nothing else shown). The client cannot know the home's service without scanning, so the prefilled field stays editable. - The Web UI stores the whole connection object in localStorage under
homekb.connection.v1:{mode:"relay", relayUrl, token, home:{homeId,homeName}}. - Recovery paths: two consecutive 401s on any calls (health poll included) auto-unpair back to the connect screen — a single transient 401 (e.g. a relay deploy-window auth blip) must not destroy the pairing (see "Tunnel liveness & deploy safety"); any success resets the counter. The offline screen additionally offers an explicit "Disconnect & pair again" escape hatch so a user is never stuck staring at "Home is offline" with no way to re-scan.
- Asset rendering:
relaymode fetches…/asset/<path>with theAuthorizationheader and renders via blob URLs;desktopembeds plain serve URLs. How image srcs inside note Markdown map to asset paths is defined once in "Image references in notes" (lib/client/asset-ref.tsimplements it). - Ask streaming: the query page has one ask entry — every submit calls the streaming endpoint (
${base}/rpc/streamon serve,${relayUrl}/api/relay/rpc/streamon relay — therpcUrlsibling) withauto: true, and the engine decides the surface. Delivery is progressive (see "First-paint batch"): the earlyhitsframe paints the note list immediately (the list is always the top surface); the routed outcome then refines it — a terminalresultsframe finalizes the list, whilesources/delta/donereplace the list with the answer's sources and stream the answer into a compact fixed-height dock above the composer (no auto-scroll while streaming; an expand control opens the full answer as a hash overlay#answer=1, so the system back gesture closes it). A three-stage indicator (vector search → query analysis → answer) tracks frame arrivals. There is no user-facing Answer/List toggle; an "Answer with AI instead" escape hatch on list results (and on empty results) re-submits withauto: falseto force synthesis, and home-screen "Try asking" suggestions force the answer path directly.
Each tab is a real Next.js route; dynamic overlays live in the URL hash (static-export friendly — no SSR of dynamic params — and a pushed hash entry makes the system back gesture close the overlay instead of leaving the app):
| URL | Surface |
|---|---|
/ |
Redirects to /search, carrying query + hash (keeps the pairing link below intact) |
/search |
Search entry + results; #doc=<encoded path> overlays the reader |
/new |
Composer (active compose buffer); #draft=<id> resumes a home-side draft; #note=<encoded path> opens the same composer in Edit note mode for a library note — the reader's Edit hands off here (no in-place reader edit). Same surface, same actions: "Save draft" checkpoints the text as a home-side draft (the edit session stays open), "Save to library" updates that note via kb.write instead of creating one — create/edit/update are one form |
/new/drafts |
Drafts collection (focused mode, under the New note tab) |
/status |
Knowledge-base health dashboard; hosts the background-compile schedule card (kb.scheduleGet/kb.scheduleSet) on all platforms |
/remote |
Device-connection hub |
/settings |
Settings on all platforms. Desktop = full surface (engine install/updates, tunnel toggle, rebuild, AI endpoints, paths — Tauri commands). Web = the remote subset over RPC: AI endpoint editors + home paths via kb.configGet/kb.configSetAi, and the rebuild card via kb.rebuild + kb.status (see "Settings over RPC"), plus appearance/about. Trust-anchor cards (engine install, tunnel, registration) never render on web |
/s |
Public share viewer (?id=<shareId>&r=<service url>) — read-only render of a shared note via the relay's public share endpoints; requires no pairing and lives outside the app shell (no providers/gates, no connect screen). The Web deployment rewrites /s/<id> → /s?id=<id> (next.config rewrites, web build only) for pretty links; the static-export (desktop) build ships the page but nothing links to it |
The providers/gates shell mounts once in a shared layout (app/(app)/layout.tsx, loaded ssr:false) so client state persists across tab navigation; the unpaired connect screen renders on any route until paired.
UI language (i18n): the client UI (Web + desktop, one codebase) ships en / zh / ja dictionaries (lib/i18n/locales/*.json, i18next + react-i18next — the same trilingual set as the READMEs). The language follows the browser/OS preference only — resolved from navigator.languages on mount, no in-app switcher, no persistence (mirroring the follow-the-system theme policy). SSR/static export always renders English; the client switches right after hydration (I18nProvider in the root layout, so every route is covered, /s included). React components use useTranslation(); non-React code (stores, libs) calls the i18n.t singleton (lib/i18n). On desktop the resolved locale is forwarded once over the set_locale Tauri command — the native side currently renders nothing user-visible (no menu, no dialogs by design), so the command only records the locale; the channel is reserved so a future native surface picks the language up without a protocol change.
Markdown file import (global drag-and-drop): dropping a .md/.markdown file anywhere in the paired app — any tab, web and desktop, one HTML5 code path (features/kb/components/global-md-drop.tsx, mounted once in the shell) — imports it as a new library note via plain kb.create: content = file text, title = filename stem, the engine still owns slugging + -2/-3 collision suffixes (so foo.md lands as notes/foo.md unless taken). No new protocol surface. The global handler runs window-capture and claims only Markdown files; image drops pass through to the editor's paste/drop bridge unchanged, and an image-only drag never shows the drop overlay. The public share viewer (/s) sits outside the shell and has no import. Desktop prerequisite: dragDropEnabled: false on the main window in tauri.conf.json — Tauri's native drag-drop interception (the default) eats HTML5 file drops in the webview; re-enabling it would silently break both this import and the editor's image drag-drop.
The desktop Remote tab renders a QR code next to the pairing code so a phone can connect without typing:
<webBase>/?relay=<serviceUrl>&code=<pairingCode>
serviceUrl= the connection service this home is registered with (config.toml [relay] url) — the desktop configuration is the single source of truth; the client never asks the user for it on the QR path.webBaseis fixed, never user-configured: the Web UI lives at one official origin (NEXT_PUBLIC_WEB_URLbaked at build time; dev fallbackhttp://localhost:3000). The primary scan path is the Web UI's built-in camera scanner (8b), which reads only the query params — the origin is irrelevant there. Encoding a full URL anyway is a freebie: the same QR also opens via a native camera app once the official domain serves the Web UI. When a web client mints the QR (see "Paired-device equivalence"),webBaseis its ownlocation.origin— it is the Web UI, so this is the same fixed-origin rule, not a configuration knob.- The landing screen reads the params on load, prefills the form, auto-submits the claim, and immediately strips the params from the address bar (
history.replaceState) so codes never linger in browser history. Putting the pairing code in a URL is acceptable — it is single-use with a 10-minute expiry; the QR never carries a long-lived token (it is exchanged at the service for thehkt_clientToken).
Pure renderer: the UI is the same features/kb as the Web version, running in desktop connection mode (local serve, no auth). Saving/creating is still a button-click controlled flow (kb.write/kb.create land directly in ~/.homekb/notes), and the desktop never triggers any system dialog (no dialog plugin introduced).
-
Mode detection (runtime): the presence of
window.__TAURI_INTERNALS__= desktop mode. No build-time env distinction is used; the samenext dev(3000) serves both the browser (Web mode) and thetauri devwebview (desktop mode). -
Build: desktop = static export (
npm run build:tauri→BUILD_TARGET=tauri next build,output:"export"→out/). Since the relay moved out torelay/, the Next.js app has no server routes left; the Web deployment (Vercel) builds the same app withnext build. -
App self-update (
tauri-plugin-updater+tauri-plugin-process): the shell updates itself from the GitHub Releases permalinkhttps://github.com/do-md/homekb/releases/latest/download/latest.json; updater artifacts are minisign-signed and verified against the pubkey baked intotauri.conf.json(private key~/.tauri/homekb-updater.key, never committed — losing it means existing installs can no longer receive updates). The UX honors the no-dialog rule: production builds check silently on launch and on window focus (rate-limited to once per hour), download + install in the background, then show an in-app "Restart to update" banner; the Settings "App updates" card offers a manual check with inline status. Dev builds never auto-check. Release pipeline:client/scripts/release.sh— version syncpackage.json→Cargo.toml/tauri.conf.json, signed + notarized + stapled build viaclient/scripts/build-desktop.sh, updater tarball packaged after stapling withCOPYFILE_DISABLE=1 tar --no-mac-metadata(AppleDouble._*members break the updater's Rust tar extraction), minisign signature,latest.json, GitHub release. aarch64 only for now. Self-update replaces the shell only; the engine binary is not part of the app bundle — it is acquired and upgraded through the download flow (see "Engine acquisition"). -
Engine acquisition (download, not bundled): the app does not bundle the engine binary. Startup detection order: env
HOMEKB_BIN>~/.local/bin/homekb> common install locations (/usr/local/bin,/opt/homebrew/bin,~/.cargo/bin) — an engine installed through any channel (install.sh / Homebrew / cargo) is used as-is. If none is found →engine_installdownloads the latestengine-v*GitHub release (tag resolution filters theengine-vprefix — neverreleases/latest), picks the platform artifact by the Distribution name contract, extracts the single binary, stages it next to the target, verifies it runs (--version), and only then swaps it in: the old file is removed before the staged file is renamed into place (~/.local/bin/homekbgets a fresh inode — an in-place overwrite would trip the macOS kernel signature cache and the binary would be SIGKILLed). No dialog anywhere. The same command doubles as the upgrade path: Settings "Engine" has a manual check (engine_latest_version) and an Update button that rerunsengine_install; after a successful install the shell restarts its owned serve child so the new binary serves immediately (an externally started serve is left alone). -
serve lifecycle: on startup, probe with
GET /healthfirst. Running and fresh → attach directly (external process, not taken over). Not running → spawn ahomekb servechild process, reclaimed when the app exits. Running but stale → take it over: kill the listener (pid vialsof -ti tcp:8765 -sTCP:LISTEN, SIGKILL after a grace period) and spawn a fresh child. Staleness is judged from the/healthidentity fields: (a) identity absent — an engine predating it; or (b)binPathequals the shell's resolved engine binary butbinMtimediffers from that file's current mtime — the process is executing a replaced binary's stale in-memory image (the exact aftermath of everyrm+cpengine upgrade: the old serve keeps answering/healthwhile speaking an old protocol — e.g. never emitting the first-painthitsframe, so the desktop renders answers but no note list; observed 2026-07-21). A serve whosebinPathdiffers from the shell's engine binary is a deliberately different build (dev workflow) and is left alone. -
compile lifecycle: background scheduled compilation is kept resident by the
com.homekb.compileLaunchAgent (single source of truth); the desktop does not self-spawn it. The Status page's schedule card (shared across all platforms) drives it over RPC —kb.scheduleGet/kb.scheduleSetagainst local serve — turning it off pauses compilation while serve and the tunnel keep running. (The formercompile_start/compile_stop/compile_statusTauri commands are removed; the RPC path is the one implementation.) -
tunnel lifecycle: kept resident by the
com.homekb.tunnelLaunchAgent (single source of truth); the desktop does not self-spawn and does not pkill.tunnel_start/tunnel_stop/tunnel_statusare thin wrappers aroundhomekb tunnel --install/--uninstall/--status --json. The desktop installs the tunnel with--interval 0(compilation is owned by the compile agent). On app exit only serve is reclaimed; the compile and tunnel agents are kept alive by launchd. -
Tauri command surface (invoke, used only by the desktop UI, not part of the RPC protocol):
engine_status(install/version/serve liveness/config summary — includes a per-section AI endpoint summaryai.{embedding,summary,ask}with{provider, model, keyPresent, configured}),engine_install(download + install/upgrade the latestengine-v*release per "Engine acquisition"; returns the installed version and restarts the shell-owned serve child if one is running),engine_latest_version(resolve the latest published engine version from GitHub — the Settings "Engine" update check),engine_init,serve_ensure,config_set_ai_endpoint(writes one[embedding]/[summary]/[ask]section of config.toml directly, a same-machine responsibility:{section, provider, apiKey?, model?, baseUrl?}; omittedapiKeykeeps the stored key only when neither the provider nor the base URL changed — the key is bound to its endpoint identity, see "Settings over RPC"; emptyprovideronaskdeletes the section — back to the summary fallback; any write lands at the~/.homekb/config.tomlanchor and migrates a legacy file. Same write semantics askb.configSetAi— one shared contract),index_stats(runshomekb status --json→{docs, chunks, available, embeddingModel, embeddingProvider}— for the Settings rebuild card's cost estimate and config↔index drift detection),engine_rebuild_reindex(homekb rebuild --force→homekb reindex, then restarts the shell-owned serve child — belt-and-suspenders now that serve hot-reloads config per request (see "Settings over RPC"), kept because a vector-space switch is exactly the moment to start from a clean process; a full re-embed is mandatory after changing the embedding model/provider because vectors are model-specific and the dimension often changes; long-running, minutes; returns the reindex summary line),relay_register(wrapshomekb register),relay_clear(wipe[relay]from config.toml — disconnect from the current service, back to the picker; the store also stops the tunnel since it cannot run without a service),pair_new(wrapshomekb pair --json),relay_credentials(returns{url, homeSecret}from config.toml[relay]so the desktop UI can call the homeSecret-authenticated relay endpoints — grants list/revoke; the secret never leaves the machine except toward its own relay),tunnel_start/tunnel_stop/tunnel_status(wraphomekb tunnel --install --interval 0/--uninstall/--status --json),local_relay_status/local_relay_start/local_relay_stop(the this-machine connection service, port 8787 — see "Desktop service picker"; start spawnsnode ~/.homekb-relay/server.mjsdetached with the pid recorded in~/.homekb-relay/relay.pid, stop kills only a pid the app recorded, status = TCP probe),open_notes_dir(reveal the notes directory in the OS file manager — an allowed desktop affordance; still no system dialogs),set_locale(records the UI-resolved locale —en/zh/ja— in shell state; the reserved i18n channel, nothing native consumes it yet — see "UI language (i18n)"). All daemons launchd-managed, not self-spawned. -
The Settings view renders on all platforms; the desktop shows the full surface (engine/directory info, AI provider keys, relay registration, the tunnel toggle), while the Web version shows the RPC-backed subset (AI endpoints + home paths via
kb.configGet/kb.configSetAi, plus the rebuild card viakb.rebuild; see "Settings over RPC") — machine-local cards are desktop-only. The background-compile schedule card (on/off + interval,kb.scheduleGet/kb.scheduleSet) lives on the Status page and renders on all platforms. The Remote tab is likewise near-symmetric: pairing-code generation and the paired-devices list render on all platforms (see "Paired-device equivalence"); only service registration, the tunnel toggle and the local relay service stay desktop-only (machine-local: they need the homeSecret-holding engine and launchd). -
First-run AI-key onboarding (desktop only): the engine can neither compile nor retrieve until both REQUIRED endpoints —
[embedding]and[summary]— carry a key, so a fresh install must be guided to Settings before anything else. The Search empty state derivesaiSetupNeededfromengine_status'sai.{embedding,summary}.keyPresentand, when either key is missing, shows an "Add your AI keys" guide in place of the empty-library guide (adding notes first would only pile up un-indexable files); a coral dot also rides the Settings tab. Both clear the moment the keys are saved (config_set_ai_endpointrefreshes the status). Purely client-side — no RPC/protocol involvement. (Remote clients can now set a home's keys viakb.configSetAi, but the first-run guide stays desktop: a fresh install has nothing paired yet, and onboarding belongs where the engine lives.)
| Type | Prefix | Description |
|---|---|---|
| homeSecret | hks_ + 48hex |
Home device credential for the relay, returned once by register, relay stores only the sha256 |
| clientToken | hkt_ + 48hex |
Remote access credential for the relay path, obtained by claiming a pairing code, relay stores only the sha256 |
| serveToken | hkd_ + 48hex |
Credential for an authenticated public homekb serve bind (power-user/API use); lives only in config.toml [serve] token on the home machine (never touches any relay) |
| Pairing code | 8 chars A-Z/2-9 | TTL 10 minutes, single use |
| shareId | 32 hex (128-bit random) | Public share link identifier — capability-style: unguessable, public by design once shared. Not a bearer token: it grants access to exactly one note, subject to password/expiry/revocation enforced by the home |
All authentication uniformly uses Authorization: Bearer <token>.
The relay has two deployment targets sharing one protocol contract (this API, byte-identical — clients and the engine cannot tell them apart):
- Cloudflare Workers (
relay/cf/) — the primary target, in two flavors: the product's official instance on*.workers.dev(default story for regular users), and one-click self-deploy into a user's own Cloudflare account (Deploy-to-Cloudflare button once the repo is public; auto-provisions D1 + DO, and the Worker self-initializes its schema on first request so no manual database step exists). Self-deploying makes the user the operator — the strongest trust posture that still gets platform auditability. - Node self-host (
relay/node/) — a standalone Node HTTP service, not part of the Next.js app. One process, one SQLite file, no framework. Retained for power users who want the pipe on their own box (must sit behind public HTTPS on port 443 — AI-client connectors only egress on 443).
Either way, users register their home against whichever relay they chose (homekb register --relay <url> / the desktop service picker) — the "add your own service" flow is the same picker that handles the official instance.
Shared invariants (both targets):
- Multi-tenant by design: one relay instance serves many homes/users (
homes/grants/pair_codesare all keyed by home). Regular users need zero server knowledge. - Pure pipe: knowledge-base data and asset bytes only stream through request/response bodies; nothing is written to disk. The relay DB stores pairing relationships and token hashes only.
- CORS:
*on all API routes (the Web UI is on another origin — Vercel; auth is Bearer-token-based, not origin-based). - The relay also serves the OAuth authorize page itself (
GET /oauth/authorize, server-rendered HTML form) since the Next.js app no longer hosts server routes.
- Run:
npm run relay:dev(port 8787, fromclient/) during development;npm run relay:buildbundles torelay/node/dist/server.mjs(esbuild,better-sqlite3kept external), deploy = copy +node relay/node/dist/server.mjson any box with Node ≥ 20. Port via--port/PORT(default 8787), DB viaHOMEKB_RELAY_DB(default~/.homekb-relay/relay.db).
- Topology: a stateless Worker handles all HTTP routes; one Durable Object per home (
HomeTunnelDO,idFromName(home_id)) is the tunnel hub — it holds the home's open SSE downstream, correlates rpc/result by request id, and body-pipes the asset/ask channels between the home's upstream POST and the pending client response. D1 stores the same schema as relay.db (verbatim SQL, async API); the Worker self-initializes the schema (idempotentCREATE TABLE IF NOT EXISTS, once per isolate) so a freshly provisioned empty database works with zero manual steps (schema.sqlstays as the human-readable reference). - Tunnel protocol unchanged: the home still speaks
GET /api/relay/tunnelSSE with 25s pings — existing engine binaries connect without modification. (A WebSocket + DO Hibernation transport is a planned cost optimization — an SSE stream keeps the DO active while connected — and will be spec'd here before implementation.) - Why workers.dev and not a custom domain: the GFW-blocked state of
*.workers.devis predictable — it is included in mainstream proxy rule sets, so "this service needs a proxy" is common knowledge requiring zero per-user configuration. A custom domain reachable today could be blocked tomorrow, and no user's routing rules would know about it. Corollary: both sides need a proxy in mainland China — the remote client and the home machine runninghomekb tunnel. - Auditability: the Worker code in this repo is exactly what runs — the platform offers no shell, no sidecar processes, no place to bolt on out-of-band traffic capture, unlike an operator-run box. The residual trust is in Cloudflare itself: transit plaintext is visible to the platform (same operator trust model as any relay; see the trust-boundary note below).
- Limits: Workers' egress/runtime fits the pipe exactly — streaming responses have no wall-clock cap while the client stays connected; request bodies stream through without buffering; the DO pins in memory while its home is connected.
- At rest: zero. No knowledge-base data is ever persisted; long-lived credentials (
hks_/hkt_, 24 random bytes) are stored as sha256 hashes only — a stolen relay DB yields no usable token. The only plaintext secrets at rest are short-lived and single-use: pairing codes (10 min) and OAuth authorization codes (5 min). - Tenant isolation is structural. A clientToken is bound to exactly one
home_idat pairing time; every forward routes by the server-sidegrant.home_idlookup. Requests carry no client-controllable target-home parameter, so cross-tenant access is inexpressible, not merely rejected. - In transit: the operator is trusted. TLS terminates at the relay; content passes through relay memory in plaintext. The relay never logs bodies, but that is policy, not cryptography. Self-hosting the Node target removes this trust point entirely; the Workers target narrows it to the platform (auditable code, no operator side-channels). End-to-end encryption could remove it for HomeKB's own clients, but the Claude connector speaks standard MCP and cannot participate — that path inherently trusts the relay operator.
| Endpoint | Auth | Description |
|---|---|---|
GET /api/relay/ping |
None | → {ok:true, service:"homekb-relay"} — liveness/identity probe. The desktop service picker uses it for reachability checks and latency ranking (auto-select); anyone may call it, it leaks nothing |
POST /api/relay/register {name} |
None | → {homeId, homeSecret} |
POST /api/relay/pair {action:"new"} |
homeSecret or clientToken | → {code, expiresAt} — mint a pairing code for the caller's home. clientToken callers mint for the home their grant is bound to (see "Paired-device equivalence"): any paired device can invite another device |
POST /api/relay/pair {action:"claim", code, label?} |
None | → {token, homeId, homeName} |
GET /api/relay/tunnel |
homeSecret | SSE downstream: event: hello (first; carries connId) / event: rpc / event: asset / event: assetUpload / event: ping(25s) |
GET /api/relay/tunnel/health |
homeSecret | → {ok, online, connId} — the relay's current view of this home's tunnel (answered by the current hub/DO instance, never by a draining old one). The engine polls it to detect zombie connections; see "Tunnel liveness & deploy safety" |
POST /api/relay/tunnel/result {id, ok, result?, error?} |
homeSecret | Home device returns the RPC execution result → 204 |
POST /api/relay/tunnel/asset/<id> |
homeSecret | Home device streams the requested asset back: raw bytes body + Content-Type; on failure empty body + X-Asset-Error: <code> header → 204 |
POST /api/relay/rpc {method, params} |
clientToken | Forward to the home device, 30s timeout; offline → 502 {error:"home_offline"} |
POST /api/relay/rpc/stream {method, params} |
clientToken | Streaming forward — kb.ask only (see "Streaming answer channel"): → text/event-stream piped verbatim from the home (hits → sources → delta* → done, or hits → results on the auto list path, or error). Home offline → 502; no first byte within 60s → 504 |
POST /api/relay/tunnel/ask/<id> |
homeSecret | Home device streams the answer back as an ordered series of chunk POSTs (see "Streaming answer channel"): each POST carries X-Ask-Seq: <n> (0-based, +1 per chunk) and a body of zero or more complete SSE frames (hits/sources/delta/done/results/error); the final chunk adds X-Ask-Fin: 1. Seq 0 resolves the pending client stream; every chunk is appended verbatim and ACKed 204 immediately; fin closes the client stream. Unknown id → 409 {error:"no_pending"}, out-of-order seq → 409 {error:"bad_seq"}. A legacy single streaming POST (no X-Ask-Seq) is still accepted and piped as before (older engines) |
GET /api/relay/asset/<path> |
clientToken | Binary asset fetch through the tunnel (see below); streams the home's bytes to the client without buffering. Image variant query params + the Accept header are forwarded to the home on the SSE asset event (see "Image variant service") |
POST /api/relay/asset/<path> |
clientToken | Binary asset upload through the tunnel (see below): raw bytes body; <path> is the suggested relative path (images/<name> / attachments/<name>). → {ok:true, path:"<final path>"} once the home has written the file. Home offline → 502; no home pickup / result within 120s → 504 |
GET /api/relay/tunnel/upload/<id> |
homeSecret | Home device pulls the pending upload body: the relay pipes the client's request body straight into this response (Content-Type/Content-Length forwarded). One-shot — a second claim → 404. The home then reports the outcome via POST /api/relay/tunnel/result {id, ok, result:{path}} (or {ok:false, error:{code,message}}) with the same id |
GET /api/relay/health |
clientToken | → {online} whether the home device is online |
DELETE /api/relay/home |
homeSecret | Retire this home identity: deletes the home + all its grants / pair codes / OAuth codes / share routing records. Every client paired to it stops authenticating (401) and auto-unpairs on its next health poll, landing back on the connect screen — no zombie "forever offline" pairings. Called best-effort by homekb register (retiring the identity it replaces) and by homekb unregister |
GET /api/relay/grants |
homeSecret or clientToken | → {grants: [{id, label, createdAt, lastUsedAt, self?}]} — every paired device / MCP grant of this home, newest first. Feeds the "Paired devices" list (desktop and web). clientToken callers see their own home's grants, with self: true on the caller's own grant (so the UI can mark "this device" and treat its revocation as a disconnect). The relay knows only labels + hashes, so per-grant liveness is not reported (only the home itself has an online state); clients show lastUsedAt instead |
DELETE /api/relay/grants/:id |
homeSecret or clientToken | Revoke one grant (unpair a device): its clientToken stops authenticating immediately → {ok:true}; unknown id → 404. Scoped to the caller's home — the owning home or any of its paired devices (a device may revoke a sibling, e.g. a lost phone, or itself = disconnect) |
POST /api/relay/share {shareId} |
homeSecret | Register a share routing record (shareId → this home) → {ok:true}. Idempotent upsert (re-posting the same shareId is safe). Called by the engine when a share is created — registration must succeed before the engine persists the share locally — and again for every active share after homekb register moves the home to a new service (routing follows the registration) |
DELETE /api/relay/share/:shareId |
homeSecret | Drop the routing record (share revoked) → {ok:true} (idempotent). Best-effort from the engine; a stale record is harmless — the home answers share_not_found |
POST /api/relay/share/:shareId {password?} |
None (public) | Public share view: look up the routing record, forward kb.shareGet over the tunnel (the relay constructs the method itself — the visitor controls nothing but the password) → {ok:true, result}. Engine errors map to HTTP: share_not_found→404, share_password_required→401, share_password_wrong→403, share_expired→410; home offline→502 |
GET /api/relay/share/:shareId/asset/<path> |
None (public; X-Share-Password header when the share is password-protected) |
Share-scoped binary asset fetch: forwarded on the asset channel with share context; the home streams the bytes only if the share is valid and the shared note references that asset. Image variant query params + Accept are forwarded like the paired route |
Every paired device is the owner — so any paired device can invite another device and manage the device list, not just the home machine. A phone paired via the web can mint a pairing code for a tablet; that tablet can pair the next browser. The desktop client's only privilege is not needing to pair (it talks to local serve directly); on the relay everything else is symmetric.
- Minting is relay-side only (
pair_codesis relay state): no tunnel round trip, and inviting works even while the home is offline (the new device just sees "home offline" until the tunnel returns, like every client). - Delegation is transitive and revocation does not cascade: a grant created via a device's pairing code is a sibling, not a child — revoking the inviter leaves the invitee working. The audit surface is the paired-devices list itself: every grant is visible there with label +
lastUsedAt, and any device (or the home) can revoke any of them. Consequence for a stolen token: it can mint further grants, but each one appears in the list; full recovery is retire-the-home (DELETE /api/relay/homeviahomekb register/unregister), which kills everything at once. - QR payloads composed by a web client use its own origin as
webBase(it is the Web UI; the scanner path reads only the query params anyway — see "Pairing link (QR payload)"). The relay URL embedded is the client's own stored connection. - Direct-mode connections are outside this surface in v1: serve's
/pair/newstays loopback-only; the pairing card renders for relay connections.
SSE rpc event data: {"id":"<reqId>","method":"kb.query","params":{...}}. An optional "stream":true field (only for kb.ask, set when the client used /api/relay/rpc/stream) tells the home to stream the answer over the ask channel (POST /api/relay/tunnel/ask/<id>) instead of posting a single result to /api/relay/tunnel/result.
SSE hello event data: {"homeId","name","connId"} — connId is a random id minted by the hub for this registered connection; the engine stores it and uses it for out-of-band liveness verification (see "Tunnel liveness & deploy safety" below).
SSE asset event data: {"id":"<reqId>","path":"images/foo.png"}. Two optional fields carry the client's variant request (see "Image variant service"): "query" — the raw query string of the client's asset GET (no leading ?, e.g. "w=800&f=webp"), and "accept" — the client's Accept header (for f=auto negotiation). Older engines ignore both and stream originals. Share-scoped asset requests add "share":{"shareId":"...","password":"..."?} — the home then validates the share (exists, unexpired, password matches, asset referenced by the shared note) before streaming; a failed validation returns the usual empty body + X-Asset-Error (share_denied).
SSE assetUpload event data: {"id":"<reqId>","path":"images/foo.png","contentType":"image/png"?} — an upload is waiting; the home claims the bytes with GET /api/relay/tunnel/upload/<id> and reports the final path via POST /api/relay/tunnel/result.
Assets (images/attachments) are never base64-encoded into the SSE stream (a text channel shared with small JSON control messages — +33% size and head-of-line blocking). Instead they get a dedicated binary path:
- Client → relay:
GET /api/relay/asset/images/foo.png(Bearer clientToken), optionally with image variant query params (?w=…,?raw=1— see "Image variant service"). - Relay → home (SSE):
event: asset{"id","path","query"?,"accept"?}— the relay forwards the client's query string andAcceptheader verbatim; the relay registers a pending asset request (30s timeout → 504). - Home → relay: resolves
~/.homekb/assets/<path>(same traversal guard as serve/assets), applies the image variant service (same rules as serve — transformable image → cached variant, otherwise original), andPOST /api/relay/tunnel/asset/<id>with the bytes (Content-Typeof the served representation). Failure → empty body +X-Asset-Error: not_found|read_error. - Relay pipes the home's request body directly into the client's pending response (no buffering, no disk);
X-Asset-Errormaps to 404/500.
Clients never put tokens in asset URLs: the Web UI fetches with an Authorization header and renders via blob URLs. (Desktop mode needs no auth: plain http://127.0.0.1:8765/assets/<path>.)
Upload direction (editor paste/drop → the home's shared asset store) mirrors the same design — no base64 on the SSE stream, no relay buffering:
- Client → relay:
POST /api/relay/asset/images/foo.png(Bearer clientToken), raw bytes body +Content-Type. The relay validates the path shape and holds the request open. - Relay → home (SSE):
event: assetUpload{"id","path","contentType"?}; the relay registers a pending upload (120s total timeout → 504 — uploads are larger than downloads). - Home → relay:
GET /api/relay/tunnel/upload/<id>— the relay pipes the client's request body straight into this response (one-shot claim). The home writes the bytes through the same sanitize/collision-avoid logic as servePOST /assets/*. - Home → relay:
POST /api/relay/tunnel/result{id, ok:true, result:{path:"images/<final-name>"}}(or{ok:false, error}), and the relay answers the client's still-open POST with{ok:true, path}.
The client then inserts the standard relative Markdown reference (../assets/images/<final-name> from a top-level note/draft) — rendering flows back through the download channel above. Desktop mode skips the relay entirely (POST http://127.0.0.1:8765/assets/<path>).
The streaming kb.ask delivers the LLM answer token-by-token. Like assets, it uses a dedicated channel rather than many small SSE control frames on the shared tunnel — so it never head-of-line-blocks the tunnel's control SSE and the relay stays a pure pipe (zero re-framing, frames never parsed).
Why chunk POSTs, not one long streaming POST (contract changed 2026-07-19): the original design had the home hold one POST /api/relay/tunnel/ask/<id> open and write frames into its request body. That works on the Node target (direct pipe) but is structurally broken on Workers: Cloudflare's edge buffers an inbound request body until it is complete before the Worker sees any of it (verified empirically — a 20s slow chunked upload to a dead ask id got its 409 only after the full body finished, and a real answer's frames all reached the client in one burst the moment the engine closed the POST). Result: zero incremental delivery (the phone stares at a dead spinner for the whole synthesis), and a long-lived idle POST that intermittently dies mid-flight leaves the client with nothing at all. Discrete chunk POSTs each complete immediately, restoring true incremental delivery with a single contract for both relay targets. (The asset channel keeps its single-POST shape: asset bodies are finite files with known length, where delivery-at-completion is acceptable.)
- Client → relay:
POST /api/relay/rpc/stream {method:"kb.ask", params:{query, auto?}}(Bearer clientToken); the relay holds this response open astext/event-stream. - Relay → home (SSE):
event: rpc{id, method, params, stream:true}; the relay registers a pending stream (60s to first byte → 504, home offline → 502). - Home → relay: runs the ask pipeline (route ∥ embed → retrieve → synthesize with chat-completions streaming) and delivers the frames as an ordered series of
POST /api/relay/tunnel/ask/<id>chunk POSTs —X-Ask-Seq: <n>(0-based, +1 per chunk), body = the complete SSE frames produced since the last flush (the engine batches on a ~150ms flush window, awaiting each POST before the next so ordering is inherent; a transient failure is retried once before the stream is abandoned), final chunk flaggedX-Ask-Fin: 1(its body may be empty or carry the trailing frames). Frame vocabulary:event: hits {"hits"}first (the unrouted first-paint batch, emitted right after embedding without waiting for the route — see "First-paint batch"), thenevent: sources {"citations","hits"}(emitted right after retrieval, before the first token — the sources are fully known then, so clients render the source list immediately instead of waiting out the synthesize latency) →event: delta {"text"}* →event: done {"citations","hits"}(or a singleevent: error {"code","message"}). Withauto: trueand a routeranswer: falsedecision (see "Auto mode"), the frames afterhitsare instead a terminalevent: results{"hits","route"}— no synthesis, nosources/delta/done. - Relay: chunk seq 0 resolves the pending client stream (this is the "first byte" that stops the 60s timer); every chunk's bytes are appended verbatim to the client's open response and the POST is ACKed 204 right away;
X-Ask-Fincloses the client stream. Unknown id → 409{error:"no_pending"}(pending timed out / client gone), out-of-order seq → 409{error:"bad_seq"}— either 409 tells the engine to abandon the remainder. An inter-chunk idle timeout (120s) GCs abandoned sessions by closing the client stream. Relays also still accept the legacy single streaming POST (noX-Ask-Seqheader → piped as one body, exactly the old behavior) so engines predating the chunk contract keep working.
Desktop mode skips the relay entirely: the webview hits POST http://127.0.0.1:8765/rpc/stream and consumes the same frame protocol directly from serve.
The answer's [n] markers and citations follow the same source-numbering contract as the one-shot kb.ask (see the RPC table). citations/hits arrive twice: early in the sources frame (render-first UX) and again in the terminal done frame (kept for backward compatibility — older clients that only read done keep working; the payloads are identical).
The failure mode (observed 2026-07-17 after a Workers deploy): any relay deploy/restart severs every home's tunnel SSE. The engine reconnects within seconds — but during the deploy propagation window that reconnect can land on a draining old instance (Workers keeps an old isolate alive while its in-flight requests finish, and an SSE request never finishes). The old instance registers the connection and keeps pinging it, so to the engine everything looks healthy; meanwhile the current instance answers /online with conn = null, and every remote client sees the home as offline — indefinitely. The lesson generalizes: in-band traffic (pings included) can never prove the connection is registered with the current instance; only an out-of-band request — which always routes to the current deployment — can.
The self-healing contract (all three parts are mandatory; together they make relay deploys/restarts zero-coordination — nobody restarts anything by hand):
- Connection identity: the hub/DO mints a random
connIdwhen a tunnel registers and sends it in thehelloevent.GET /api/relay/tunnel/health(homeSecret) reports the current instance's view:{online, connId}. - Engine verification loop: while a tunnel is connected, the engine verifies out-of-band — once ~10 s after connect (catches the deploy-window zombie almost immediately), then every ~60 s (bounds any other silent divergence). A verification is negative when the response is definitive and contradicts the engine's state:
online:false, orconnId≠ the one fromhello. On a negative the engine drops the connection and reconnects (normal backoff). Network errors / non-200s are inconclusive and never kill a working tunnel (a flaky proxy must not cause reconnect storms). - Client tolerance: remote clients auto-unpair on 401 only after two consecutive 401 responses — a single transient 401 (deploy-window auth blip) must not destroy a pairing. Health-poll "offline" states never unpair (only 401s do).
Deploy runbook consequence: wrangler deploy needs no tunnel coordination — every home self-heals within ~10 s (deploy-window zombies) / ~60 s (worst case). The old launchctl kickstart workaround is obsolete.
A single note can be shared with anyone via a link, without pairing. The share is served live from the home machine through the relay — nothing is cached or persisted anywhere else. If the home is offline, the share page honestly says so (this is the direct corollary of "data never leaves home"; there is deliberately no relay-side caching in v1 — an opt-in edge cache would be a contract change spec'd here first). Standard controls: optional password, optional expiry, revocation.
- The engine owns every policy decision. Share records live in
~/.homekb/shares.json. Password verification (per-share random 16-byte salt, sha256(salt‖password), throttled per share — 10 failures / 10 min lockout), expiry, and revocation are all enforced by the home when answeringkb.shareGet. The relay stores exactly one routing fact per share (sharestable: shareId → home_id) and cannot even tell whether a password guess was right — it merely forwards. Revoking deletes the engine record: the link dies instantly even if the relay row lingers (a stale routing row resolves toshare_not_found). - Capability-style id: shareId = 128-bit random hex; possessing the link is the capability. The password (when set) travels in the POST body /
X-Share-Passwordheader — never in the URL. - Share URL:
<share_web_base>/s/<shareId>?r=<relay url>— composed by the engine (share_web_baseconfig, defaulthttp://localhost:3000until an official Web origin exists). The?r=parameter tells the viewer which relay to query (same precedent as the pairing QR'srelay=param); the viewer falls back toNEXT_PUBLIC_RELAY_URLwhen absent. - View flow: visitor opens
<web>/s/<id>(Next.js viewer, pure frontend, no pairing) → viewer POSTs{password?}to<relay>/api/relay/share/<id>→ relay forwardskb.shareGetover the tunnel → engine validates and returns the note → viewer renders the Markdown read-only; images resolve throughGET /api/relay/share/<id>/asset/<path>(share-scoped asset channel: the home streams an asset only if the shared note references it — resolved with the same virtual-root rule as every renderer). - Creation surfaces:
homekb share(CLI),kb_share(MCP — share from inside any AI conversation),kb.shareCreate(RPC — paired clients/desktop). Creating a share requires an active[relay]registration; the routing record is registered at the relay before the share is persisted locally (no orphan shares). - Trust boundary unchanged: the relay still stores zero knowledge data at rest; shared content passes through relay memory in transit exactly like every other RPC. Anonymous view/password attempts are throttled at the engine (per-share); a relay-side anonymous rate limiter is a follow-up, noted here so it isn't mistaken for implemented.
- Switching connection services: share records survive a relay switch because they live on the home, but their routing must move. After a successful
homekb registeragainst a new service, the engine re-registers every active (non-expired) share's routing record at the new relay (best-effort per share,POST /api/relay/share— an idempotent upsert on the relay side), so each share stays reachable under the same shareId via a fresh link. Previously distributed links still die: they embed the old service in?r=(and retiring the old identity deletes its routing rows) — this is inherent to the zero-state viewer, not a bug. Clients surface the fresh link throughkb.shareList'surl.
| method | params | result |
|---|---|---|
kb.query |
{query, limit?, docType?, full?, group?, maxDistance?, enumerate?, route?} |
{query, results: Hit[], route?: {docType?, enumerate}} |
kb.ask |
{query} |
{answer, citations: [{path,title}], hits: Hit[]} (LLM-synthesized answer, home-device key. No chunk granularity at the user layer: hits is aggregated per source document — kind doc\ |
kb.read |
{path} |
{path, content, mtime} |
kb.write |
{path, content} |
{path} (only .md within notes, to prevent path traversal). Kicks an async incremental compile on success — the write is an event-driven compile trigger, not deferred to the periodic scan (see "Compile trigger model") |
kb.create |
{content, title?} |
{path, title} (slug filename + duplicate-name avoidance). Kicks an async incremental compile on success (see "Compile trigger model") |
kb.draftList |
{} |
{drafts: [{id, text, editedAt}]} — every unpublished draft (~/.homekb/drafts/<id>.md), editedAt epoch ms, newest first. Drafts live on the home device, so all paired clients see the same list (no per-device local storage) |
kb.draftSave |
{id?, text} |
{id, editedAt} — upsert a draft. id omitted → the home generates one and returns it; id present → overwrite that draft (id charset [A-Za-z0-9_-]{1,64}). Empty/whitespace text is rejected |
kb.draftDelete |
{id} |
{id} — delete a draft (idempotent; deleting a missing id still returns ok). Used both for the drafts list's delete action and after a draft is published to notes/ |
kb.list |
{limit?} |
{docs: [{path,title,docType,mtime,sizeBytes}]} mtime descending |
kb.status |
{} |
{generation, docs, chunks, chunksWithVectors, pending, failures, lastCompileAt, lastCompileHost, embeddingModel, embeddingProvider, aiConfigured} — aiConfigured: false = no [embedding] section configured yet (fresh setup): compile idles, UIs must render "not configured", never a preset default |
kb.listTypes |
{} |
{types: [{docType, count}]} |
kb.suggestions |
{limit?} |
{suggestions: [{question, path, title, mtime}]} — one auto-generated question per recently updated document, newest first; docs without a generated question yet are skipped. Feeds the home-screen "Try asking" list |
kb.reindex |
{} |
{started: true} (executed asynchronously inside the tunnel process) |
kb.configGet |
{} |
{root, notesDir, configPath, ai: {embedding, summary, ask}} — the masked config summary behind the Settings surface. Each AI section: {provider, model, keyPresent, configured, baseUrl?, dim?} (baseUrl echoed only when explicitly configured; dim embedding-only). API keys are never returned by any read path — presence flags only. ask.configured: false = section absent (summary fallback active). Path fields are display strings describing the home machine |
kb.configSetAi |
{section, provider, apiKey?, model?, baseUrl?, dim?} |
{ai: {embedding, summary, ask}} (fresh masked summary). Writes one [embedding]/[summary]/[ask] section — the same write semantics as the desktop config_set_ai_endpoint command, one shared contract (see "Settings over RPC"): omitted/empty apiKey keeps the stored key only when neither provider nor baseUrl changed; empty provider on ask deletes the section; omitted/empty model resets to the provider default. Takes effect without restart (hot reload). Every successful write also kicks an immediate write-triggered compile with a freshly re-loaded config (never the request-scoped snapshot, which predates the write): an embedding-identity change is picked up by the drift self-heal (see "Vector-space drift self-heals") and rebuilds the index into the new space with no further client action; any other write no-ops cheaply |
kb.scheduleGet |
{} |
{supported, installed, running, intervalSecs} — state of the background compile schedule (the com.homekb.compile LaunchAgent, homekb watch --interval N). supported: false on non-macOS platforms (launchd-based; systemd user units planned); intervalSecs is null when not installed (parsed from the installed agent's --interval argument). Feeds the Status page's schedule card on all platforms |
kb.scheduleSet |
{enabled, intervalSecs?} |
Same result shape as kb.scheduleGet (fresh state). enabled: true (re)installs + starts the compile agent — intervalSecs clamped 60–86400; omitted → the currently installed interval, else 300. Changing only the interval re-installs in place (install is idempotent). enabled: false stops + removes the agent (pauses background compilation; serve and the tunnel are untouched). Errors: unsupported_platform, schedule_failed |
kb.rebuild |
{} |
{started: true} — full rebuild: rebuild --force + reindex, executed asynchronously in the answering process (same fire-and-forget shape as kb.reindex; the compile lock serializes against a concurrent scheduled compile). Required after switching the embedding provider/model (kb.configSetAi alone leaves the index in the old vector space); progress is observable by polling kb.status (chunksWithVectors climbing). Existing rebuild guards apply (last good snapshot survives a failed run) |
kb.shareCreate |
{path, password?, expiresDays?} |
{shareId, url, expiresAt?} — create a share for one note (path must be an existing .md under notes). Registers the routing record at the relay first; on relay failure nothing is persisted. url = <share_web_base>/s/<shareId>?r=<relay url> |
kb.shareGet |
{shareId, password?} |
{path, title, content, mtime, docType?} — the public share read; the only method the relay forwards unauthenticated, and only from its own public share endpoint (method constructed server-side). Errors: share_not_found (unknown or revoked), share_password_required, share_password_wrong (throttled per share), share_expired |
kb.shareList |
{} |
{shares: [{shareId, path, title, createdAt, expiresAt?, hasPassword, url?}]} newest first. url is composed against the current [relay] registration and share_web_base (same shape as kb.shareCreate's), so clients always copy a link pointing at the service currently serving the share; absent when the home has no active registration |
kb.shareRevoke |
{shareId} |
{shareId} — delete the share record (idempotent) + best-effort drop of the relay routing record |
Hit = {kind: "chunk"|"doc"|"doc_full", path, title, headingPath?, content, score, mtime, docType?, matches?}
The kb.ask row above is the one-shot form (single JSON response), used by the local MCP, homekb ask, power-user tooling, and any non-streaming caller. The UI uses real token streaming instead — a separate transport (/rpc/stream on serve, /api/relay/rpc/stream through the relay) that emits sources → delta* → done SSE frames (or a single results frame on the auto list path); see "HTTP RPC", "Auto mode", "Streaming answer channel", and the engine's streaming synthesize. Both share the same retrieval + source-numbering; only the delivery differs. The Web UI feeds delta chunks straight into the DOMD editor via insertText (no client-side typewriter simulation).
group: true = source-aggregation mode (used by the UI "hit list"): internally amplifies retrieval (the fusion pool takes limit*5), and after narrowing by maxDistance merges the same path into a single entry (kind is always doc; content/headingPath take that document's top-ranked hit; matches = the number of merged hit segments). The source order = the position of each document's first hit in the fusion ranking, and limit applies to the merged document count. When both full and group are given, full takes precedence. There is still only one embedding request and zero LLM calls.
Category enumeration (enumerate: true, requires docType): coverage-first retrieval for "what do I have in X" intent, where KNN cutoffs are exactly wrong — the question's vector is far from every individual document, so top-K + maxDistance truncate the category arbitrarily. Enumeration returns every doc of the category as kind:"doc" hits (content = the doc's compiled summary), ranked by summary-vector distance to the query so a hybrid question ("recipes that use shrimp") floats matches to the top while the rest of the category stays visible. limit/maxDistance/full/group are ignored (hard cap 500 docs, truncation logged — no silent caps). Still one embedding request, zero LLM calls.
route: true = routed search (for list surfaces whose caller has no LLM of its own): the engine first runs the same router as kb.ask ({docType?, enumerate} — the [ask]/[summary] chat endpoint; router failure degrades to plain unrouted search) and applies the inferred filter/enumeration; the applied route is echoed back in the result's route field so clients can label the result set (e.g. "recipe · 17"). Explicit docType/enumerate params take precedence over inferred ones. Agent-facing callers (MCP, CLI, the knowledge-base skill) normally keep routing themselves and pass explicit params instead. The Web UI's query page no longer calls this directly — its list results come from the streaming kb.ask {auto: true} short-circuit, which applies the identical routed-search behavior engine-side (see "Auto mode").
path is always relative to notes_dir. Error result: {ok:false, error:{code, message}}; on success the relay wraps a {ok:true, result} layer and returns it to the client.
kb.configGet/kb.configSetAi open the AI-endpoint Settings to paired clients (Settings is "(all platforms)" by design). The security posture is deliberately identical to the desktop's, resting on three rules:
- Masked reads, write-only keys. No read path —
kb.configGet,kb.configSetAi's echo, or the desktopengine_status— ever returns an API key; onlykeyPresent. A stolen clientToken already reads every note (kb.read/kb.query); it must not additionally harvest the user's provider credentials. - The stored key is bound to its endpoint identity
(provider, baseUrl). A write that changes either field drops the stored key unless the same write supplies a new one. Without this rule, "keep the stored key on same-provider writes" + "explicitbaseUrloverrides the preset for any provider" would compose into a key-exfiltration primitive: repoint[embedding]at an attacker-controlled base URL while the stored key rides along. This binding applies identically to the desktop command and the RPC method — one semantics, implemented once per side of the contract. - Trust delta is zero by construction. A paired client can already read and mutate the entire library, so endpoint edits grant no new data access; the only new material — a key being set — crosses the relay in transit plaintext exactly once at write time, the same class as the pairing-code claim (see "Relay trust boundary"). The relay stores nothing.
On-protocol machine operations — compile scheduling and rebuild. The engine + Web UI alone is a supported complete deployment (the minimal path: install the engine, homekb pair, finish setup from the web), so the two operations that setup and embedding switches genuinely need are part of the protocol: kb.scheduleGet/kb.scheduleSet (the background compile agent: on/off + interval) and kb.rebuild (full re-embed after an embedding switch). Trust delta is zero by the same argument as the AI endpoints: a paired client can already trigger compiles (kb.reindex) and mutate the library; pausing compilation or re-embedding grants no new data access and cannot sever any transport.
Everything else machine-local stays off the protocol: engine install/upgrade, the tunnel toggle, relay registration, and the serve bind remain desktop/CLI-only — they manage trust anchors on the home machine, which a remote client has no business driving (a remote tunnel stop would even sever its own transport).
Hot reload (config takes effect without restart): homekb serve and homekb tunnel resolve Config per request behind an mtime check on the config file — a kb.configSetAi write (or a desktop command / hand edit) is picked up on the next RPC with no process restart; the compile daemon already re-loads config every cycle. Switching the embedding provider/model changes the vector space; the compile pipeline detects the drift on its next run and self-heals by resetting + re-embedding automatically (see "Vector-space drift self-heals") — no manual rebuild --force involved. kb.configSetAi additionally kicks an immediate compile after every successful write (with a freshly re-loaded config — never the request-scoped snapshot, which predates the write), so a Settings save that switches the endpoint is followed by the re-embed within seconds rather than the next scheduled cycle.
| tool | Parameters | Behavior |
|---|---|---|
kb_search |
{query, limit?, doc_type?, full?, enumerate?} |
Semantic retrieval, returns hits JSON. enumerate: true (requires doc_type) = whole-category summary sweep ranked by relevance — use for "list everything in X" intents |
kb_read |
{path} |
Read a whole md file |
kb_create |
{content, title?} |
Create a new note |
kb_update |
{path, content} |
Overwrite an existing note |
kb_list |
{limit?} |
Recent document list |
kb_status |
{} |
Index status |
kb_share |
{path, password?, expires_in_days?} |
Create a public share link for one note (anyone with the link — and the password, if set — can read it). Returns {url, shareId, expiresAt?}. Requires the home to be registered with a relay |
Local: homekb mcp calls the engine core directly. Remote: /api/mcp (served by the relay) is stateless Streamable HTTP (POST JSON-RPC → JSON response), where tool calls are translated into RPC and forwarded over the tunnel.
Real-client compatibility (verified 2026-07 against the Workers relay): claude.ai / Claude mobile (Settings → Connectors, account-level) and ChatGPT web (individual plans: Settings → Security and login → Developer mode, then create the app at chatgpt.com/plugins → + — NOT the Connectors page; apps land in Drafts and are enabled per-conversation via + → Developer mode). Both complete the pairing-code OAuth (dynamic registration + PKCE) and accept the stateless JSON response mode. ChatGPT Deep Research additionally requires dedicated search/fetch tools (not implemented; would wrap kb.query/kb.read).
GET /.well-known/oauth-authorization-server,GET /.well-known/oauth-protected-resource: RFC8414/9728 metadata.POST /api/oauth/register: dynamic client registration (RFC7591), public client.GET /oauth/authorize?...: the authorization page = enter the pairing code (in place of a login). After submission the server claims the pairing code → generates a grant + authorization code (TTL 5min, stores code_challenge) → 302 back to redirect_uri.POST /api/oauth/token: authorization_code + PKCE(S256) verification → access_token = clientToken (long-lived)./api/mcpwithout a token → 401 +WWW-Authenticate: Bearer resource_metadata="...".- Claude Code and the like can also skip OAuth and use
--header "Authorization: Bearer hkt_..."directly.
homes(id TEXT PK, name TEXT, secret_hash TEXT, created_at INTEGER, last_seen_at INTEGER);
grants(id TEXT PK, home_id TEXT, token_hash TEXT UNIQUE, label TEXT, created_at INTEGER, last_used_at INTEGER);
pair_codes(code TEXT PK, home_id TEXT, expires_at INTEGER, used INTEGER DEFAULT 0);
oauth_clients(client_id TEXT PK, redirect_uris TEXT, name TEXT, created_at INTEGER);
oauth_codes(code TEXT PK, client_id TEXT, home_id TEXT, code_challenge TEXT, redirect_uri TEXT, expires_at INTEGER, used INTEGER DEFAULT 0);
-- The authorization code stores home_id (the result of claiming a pairing code); the grant/token is created only at token exchange, and the plaintext token is handled just once
shares(id TEXT PK, home_id TEXT, created_at INTEGER);
-- Share routing only: which home answers this shareId. Password hash / expiry / revocation all live on the home (shares.json) — the relay cannot evaluate share policy
- Compile: scan → mtime/size + SHA256 change detection → H2/H3/paragraph chunking → embedding via the configured provider (OpenAI-protocol client; batching + retries + concurrency limit) → sqlite-vec write → atomic snapshot export. Summarization (the
[summary]LLM) + automatic doc_type classification + one suggested question per document (docs.suggested_question, schema v3): generated in the same summarizer chat call (JSON{summary, question}, zero extra LLM requests), written atomically with the summary sosummary_src_hashgoverns both; docs where it is still NULL (pre-v3 index, or a parse fallback) are backfilled incrementally like the doc_type backfill. Served bykb.suggestions.- doc_type taxonomy = built-in seeds + emergent growth. The categorizer is always offered a built-in seed vocabulary (
SEED_CATEGORIES: recipe, restaurant_log, tech_note, code_snippet, how_to, product_note, reference, book_note, travel_log, diary, health, finance — English anchors; any user-facing localization is a display-layer mapping, never a key change) merged with the categories already in use, so a fresh KB lands on stable labels from doc #1; documents that fit nothing may still grow new labels (organization emerges at compile time — no manual tagging). Reuse is cross-language (a Chinese cooking note belongs inrecipe; same-meaning duplicates in another language are forbidden); an invented label follows the document's own primary language so its owner recognizes it. - doc_type key space is language-open: Unicode letters/digits/underscores in any script — NFC-normalized, lowercase-folded (no-op for caseless scripts), whitespace/dashes → underscores, punctuation/emoji stripped, ≤32 chars.
菜谱is as valid a key asrecipe; a future user-defined-categories feature inherits this same key space and normalization. Sanitization is normalization + validation, never a language gate: a response with nothing valid left is a retryable failure (one corrective retry, then fall back tootherwith a warning), never silently coerced (the historical ASCII-only sanitizer silently laundered every CJK label intootherand collapsed the whole taxonomy). Theotherbucket is excluded from the reuse-preference vocabulary shown to the categorizer — it is a last-resort fallback, not an attractor (a frequentotherotherwise snowballs via the "prefer existing categories" rule until the taxonomy is useless and the ask router can no longer filter). - Collapse tripwire + repair: every reindex run checks the aggregate (≥10 classified docs and >50%
other→ warning in the compile log), andhomekb statusprints the doc-type distribution with the same warning — this failure mode is silent per-call, so it must be visible in aggregate.homekb reindex --reclassifyrepairs an already-collapsed index: it NULLs every doc_type under the compile lock and lets the sequential backfill re-grow the taxonomy (summaries/embeddings untouched).
- doc_type taxonomy = built-in seeds + emergent growth. The categorizer is always offered a built-in seed vocabulary (
- Retrieval: query embedding → dual-pool KNN (vec_docs summary pool + vec_chunks content pool) → RRF(k=60) fusion + parent-document boost.
- Compile trigger model — event-driven, with the periodic scan demoted to a fallback. App-layer writes have exact trigger points, so they compile immediately instead of waiting for the next scan:
kb.createandkb.write(hence every surface funnelling through them — Web new-note / drag-drop, the desktop editor, draft publish =kb.create+kb.draftDelete, and both the local and remote MCPkb_create/kb_update) call the in-process compile coalescer (compile_queue::request_compile) after a successful write.kb.draftSavedeliberately does not trigger a compile (drafts are never scanned, see the directory layout), and asset uploads do not (assets are never indexed).- Coalescing single-flight. Rapid writes collapse into at most one in-flight incremental reindex plus one queued follow-up (a dirty flag +
tokio::sync::Notify, one background worker per process), so a burst of creates is not a burst of compiles. The reindex is already incremental at the embedding layer (the scan is stat-only; only files whose mtime/size + content hash changed are re-embedded), so an immediate full reindex on each write is cheap — a dedicated single-file compile path is an optional future optimization, not a prerequisite. - Cross-process lock — retry, never drop. Reindex holds the cross-process
compile.lock(fs2 advisory flock). A write-triggered compile that races the fallback agent's run (or a manualhomekb reindex) getsCompileLockBusy(try-lock, fail-fast); the coalescer keeps the request pending and retries with a short backoff, so the new note lands right after the current run finishes — never silently deferred to the next fallback tick. A genuine compile error (bad provider key, db fault) is dropped to the periodic fallback; failing files are already recorded in thefailurestable by reindex itself.
- Coalescing single-flight. Rapid writes collapse into at most one in-flight incremental reindex plus one queued follow-up (a dirty flag +
- The periodic scan is a folder-level fallback (robustness backstop), not the primary compile path. launchd is not used to schedule the compile (
StartIntervalis deprecated); scheduled reindex is built into the in-process loop ofhomekb watch(and, when a non-zero interval is given,homekb tunnel --interval). It exists to catch out-of-band changes that bypass the app layer: a batch of files dragged straight into~/.homekb/notes/, edits from an external editor (Obsidian/vim), or files arriving via cloud-drive sync. The interval may be lengthened now that the common path is event-driven, but stays reasonably short (default 300s) so external edits are picked up without user action. - Process residency — two independent LaunchAgents with cleanly separated responsibilities (so the desktop can pause the scheduled compile without touching remote access). Event-driven compiles run inside whichever process handled the write — serve, tunnel, or the local MCP — and take the same
compile.lock, so every compiler across every process is serialized by the flock and can never corruptlive.db; a loser retries (per the trigger model above) rather than dropping the write:com.homekb.compile(homekb watch --interval N) — the sole scheduled-compile source (the fallback scan); managed by the Settings engine/auto-compile toggle. Turning it off pauses the scheduled fallback while leaving serve (the data plane, which still compiles on write), the tunnel (remote access), and the event-driven path untouched.com.homekb.tunnel(homekb tunnel --interval 0) — the pure relay tunnel (remote access only);--interval 0disables only its scheduled compile loop, not the event-driven trigger (a write arriving over the tunnel still kicks the coalescer in the tunnel process). (A pure-CLI power user who does not run the compile agent may still install the tunnel with a non-zero interval to get the built-in fallback scan too.)- Both agents:
KeepAlive-only (notStartInterval),RunAtLoad,ProcessType=Background; ProgramArguments hardcodes~/.local/bin/homekb …(after an update overwrites the binary, a KeepAlive restart adopts it automatically); logs at~/Library/Logs/HomeKB/{compile,tunnel}.log; status vialaunchctl print gui/$UID/<label>(exit code 0 = loaded; output containingpid/state = running= running); management always uses the modernlaunchctl bootstrap/bootout/enable/kickstart, not the deprecatedload/unload. Installed/removed/queried viahomekb watch|tunnel --install/--uninstall/--status [--json]. Single source of truth: the desktop only invokes these CLI commands, never self-spawns.
- AI provider keys are used only on the home device; remote Agents do not need to bring their own key when retrieving via MCP/RPC.