ContentsWho this is for: people who want an AI assistant on their own machine without handing chat history and file paths to a cloud; people building desktop agents in Electron and fighting permissions, paths and packaging; people who want to see where a local-first product must compromise.
Bottom line first: the core of Z Bot is not "plug in a model" but putting the model in a box managed by the main process — database, keys, tool execution and system capabilities all stay there, the renderer gets only a whitelisted API, every side-effecting action needs permission, memory starts with SQLite FTS5 rather than a vector store, and reminders, context and retries all have hard caps.
Reading time ~55 minutes · Level: advanced
Cloud chat products are already strong, but "personal assistant" and "general Q&A" are different products. General Q&A does not need to know your project directories, last week's todos or your writing voice; a personal assistant does, and that information naturally should not leave the machine. Z Bot starts from a plain idea: keep personal context, long-term memory, tasks and reminders in a local SQLite database; let the model do reasoning and generation; and execute file reads, web fetches and reminder creation locally, each with the user's authorisation.
That positioning brings three hard constraints. Data must be explainable, backup-able and deletable — never "the memory lives in some cloud table you cannot see or remove". The model must be swappable: OpenAI, OpenAI-compatible, Anthropic, Google and Ollama all work, so the product cannot lock anyone to one vendor. And the assistant must be resident, able to keep standing by after the window closes, send system notifications and read local files — which makes it a desktop app, not a web page.
Z Bot is not a coding agent. Its loop is understand, remember, reason, help, act, learn, focused on personal context and daily tasks rather than editing a repository. The README says plainly that complex software work belongs to a specialised agent like NanoHermes, while Z Bot understands the request, dispatches work, receives results and reminds the user. That boundary matters: the moment it sees itself as a general coding agent, the tool set, permission model and context budget all run out of control.
Local-first has costs too. No multi-device sync, no server push; a new machine means reconfiguring models and authorised directories; reminders are reliable only while the process lives, with no catch-up after a full exit; and local OCR is capped by whatever engine Windows ships. Those costs recur throughout this post. Stating the product boundary first is what makes the technical choices defensible.
The stack is Electron 45, React 19 and TypeScript 6, with Node's built-in node:sqlite and FTS5 for search. The repository is a pnpm workspace with six packages — shared, database, model, memory, tools, bot-core — plus apps/desktop. Tests are 32 unit tests plus 2 E2E, covering path guards, memory dedupe, reminder recurrence, provider normalisation, the runtime and project-stall reminders.
The layering is direct. shared holds types, Zod schemas, IPC channel constants and defaults; database wraps node:sqlite, migrations and stores; model collapses five providers into one streaming event shape; memory owns writing, dedupe, retrieval and prompt formatting; tools is the registry, path guard, web safety policy and concrete tools; bot-core is the runtime, context manager, permission manager, intent router and agent loop. The Electron main process in apps/desktop assembles them and exclusively owns the database, secrets, tool execution and system capabilities.
One conversation flows like this: the renderer calls chat.send through preload; the IPC layer validates with Zod; BotRuntime.startChat immediately writes the user message and an empty assistant message, creates a requestId and an AbortController, then runs asynchronously. Execution first judges intent — direct answers take one model call, multi-step tasks enter the agent loop. Model text-delta, tool-call, finish and error events are normalised into ChatEvent and pushed back via chat.event. Tool calls pass through the permission manager first, results are appended to the message array, and the assistant message is finally persisted.
Two database decisions deserve their own note. node:sqlite avoids native compilation, so the installer never faces node-gyp or ABI issues; the cost is a synchronous API, with every query on the main process's single event loop. Z Bot keeps transactions short, serialises migrations, and on open sets WAL, synchronous=NORMAL and a 5-second busy timeout, runs wal_checkpoint(TRUNCATE) before exit, and copies the whole database before a version upgrade. It did not move the database to a worker, so long queries can still stall the window and IPC.
Symptom. During migrations, bulk queries or checkpoints, window events and IPC responses can be blocked by the same thread, showing brief unresponsiveness.
Cause. DatabaseSync in node:sqlite is synchronous, and Z Bot deliberately gives the main process exclusive ownership without a worker or a separate database process.
Fix. WAL for read/write concurrency, synchronous=NORMAL to cut per-commit fsync cost, a 5-second busy timeout, BEGIN IMMEDIATE for serialised migrations, wal_checkpoint(TRUNCATE) before exit, and a full backup before upgrades based on user_version.
Cost. The root problem of a synchronous API does not disappear; it is confined to short transactions. Full exports, index rebuilds or large log searches would need a worker; this version trades simplicity for stability.
The first rule of Electron security is not to hand Node to the page. Z Bot's window uses contextIsolation: true, nodeIntegration: false and sandbox: true; the renderer has no require, no process, no filesystem. CSP narrows loading to self and the custom zbot-media: scheme rather than opening arbitrary https:; all web permission requests are denied by default, so camera, microphone, geolocation and notifications never open because a page asked once.
Attachments are capped at 25 MB each and six per message before saving, so one conversation cannot blow up local storage or later requests. Attachment images are read through zbot-media://attachment/<id>: the main process accepts only hostname === 'attachment', treats the path as an attachment id looked up in the database and attachment directory, returns 404 when missing, and adds a default-src 'none' CSP to the response. The renderer therefore cannot reach arbitrary local paths — only an image already recorded in the attachment table. That is far safer than exposing file://, but it also means attachments must pass through the main process first.
Preload is the only bridge. It exposes a ZBotApi via contextBridge.exposeInMainWorld('zbot', api) with roughly 40 methods grouped under app, chat, conversations, memory, settings, providers, webSearch, projects, roots, tasks, todos, reminders, home, permissions, clipboard, screenshot and events. Raw ipcRenderer is never handed over, so the page cannot compose channel names or listen to arbitrary events.
Event subscriptions are closed off too. chat.onEvent, events.onNavigate, events.onScreenshot and events.onTheme return an unsubscribe function, wrapping the listener internally before removeListener. That looks cosmetic but actually fixes listener leaks after React unmounts: callers can unsubscribe in useEffect cleanup without remembering which function reference they passed.
This boundary is not "configure once, safe forever". Add one generic invoke(channel, ...args) to preload and the whitelist is decoration; expose shell.openPath straight to the renderer and the path guard is bypassed. Z Bot keeps every IPC in register.ts behind a uniform handle() wrapper with per-channel schemas — but a few channels slipped through, covered below.
IPC registration goes through one handle wrapper: channel, handler, optional schema. Each call runs schema.parse(raw); Zod's .strict() rejects extra fields so the renderer cannot smuggle unexpected properties. Parse failures turn ZodError.issues into a readable message; handler throws pass through redactErrorMessage before being returned. Logs record an ipc request failed line without raw keys, tokens or cookies.
ipcMain.handle(channel, async (_event, raw: unknown) => {
try {
const input = schema ? schema.parse(raw) : raw;
return await handler(input);
} catch (error) {
logger.warn({ channel, error: redactErrorMessage(error) }, 'ipc request failed');
throw new Error(redactErrorMessage(error));
}
});
The value here is safe by default: adding a channel forces an explicit decision about a schema, and a channel without one stays visible in review. There is an obvious gap: the third argument is optional, so app:open-path and web-search:set-key genuinely lack schemas. The first only does a string-containment check for "inside the data directory", the second uses value as a string. They are not the most dangerous channels, but they show that a uniform wrapper is not uniform validation.
Symptom. app:open-path and web-search:set-key have no Zod schema; clipboard:read-text and screenshot:capture execute over IPC without permission confirmation, while tools of the same name go through PermissionManager. Subtler still: the renderer never calls those two IPC channels — they are high-privilege surfaces that exist unused.
Cause. IPC is the UI's fast path, tools are the model's controlled path; the two evolved separately and the permission model covers only the latter. Optional schema arguments make omissions easy.
Fix. app:open-path uses path.relative instead of naive string comparison; secret-writing IPC accepts only strings and goes through safeStorage in ProviderService; logging and error messages are redacted uniformly. Clipboard and screenshot tools go through full permission confirmation with risk described in the prompt.
Cost. Unused high-privilege IPC stays on the attack surface. The right move is deleting channels nobody needs or giving them schemas and confirmation; the repository keeps them because the UI may need them later. That is an explicit to-do, not a solved problem.
Symptom. Model API keys and web-search keys are sensitive; written in plaintext or leaked into logs, even a local app exposes them.
Cause. A desktop app must persist keys, but safeStorage is unavailable in some Windows environments; logs, error messages and IPC payloads can all carry configuration objects.
Fix. SecretService.encrypt throws when safeStorage.isEncryptionAvailable() is false, refusing plaintext; the database stores encrypted_api_key; outbound DTOs strip encryptedApiKey and tell the UI only "configured"; pino's redact list hides apiKey, password, cookie, encryptedApiKey and nested variants, with regex redaction on error messages.
Cost. safeStorage relies on the OS credential service, so keys become unreadable after a machine change or a broken profile and users must re-enter them. Redaction is a double line of defence (field names plus regex) and cannot guarantee that a key concatenated into an arbitrary string is always caught. Acceptable for a local app, not absolute safety.
Registering a tool does not make it runnable. Every tool calls getPermission() before executing, producing a ToolPermissionSpec with title, description, risk level, scope and operation list. PermissionManager.authorize() checks persisted rules first: an allow hit passes, a deny hit rejects; only a miss creates a PermissionRequest, pushes it to the UI via chat.permission, and awaits the user's decision.
tool.run()
-> getPermission() -> ToolPermissionSpec
-> PermissionManager.authorize(spec)
-> rule allow / deny
-> miss: chat.permission -> wait for UI
-> AbortSignal cancel -> cancelForChat
Users have four choices: deny, allow once, allow for the scope, or cancel the task. allow_once deliberately is not persisted, so the same action asks again next time; allow_scope persists on "tool name + scope", usually an authorised directory or operation type; deny does not run the tool and returns Permission denied by user. The operation was not executed: ... as the tool result so the model learns the path is blocked instead of the conversation collapsing. Cancellation rejects every pending promise through AbortSignal and cancelForChat, so permission dialogs never hang forever.
The key idea is that a denial is not an exception. Many agents throw when a tool is refused, leaving the model blind to the reason and retrying the same action next turn. Z Bot makes denial an ordinary tool result: the model can take another route or explain why it did not act. The cost is that the model may ask for the same permission repeatedly; there is no soft "do not ask again in this conversation", only persisted deny rules.
Scope granularity is another matter. allow_scope binds tool and scope, not specific arguments; once write access to a directory is allowed, later writes there never prompt. That matches the "reduce interruptions" goal but leaves high-risk tools depending on scopes designed narrowly. Z Bot models write, delete and batch-move separately, sends deletes to the Recycle Bin and shows a preview before batch operations — using tool design to compensate for permission granularity.
Tasks and permissions interact. If the agent stalls waiting for permission, the task becomes waiting_permission; on app exit, markInterrupted() turns running and pending into paused, and a restart never automatically replays interrupted tasks that may have had side effects. That trades "auto-resume after restart" for "never quietly delete or move files again".
The first gate for file tools is resolveAuthorizedPath. It rejects relative paths, drive roots, device paths and UNC shares, caps length at 4096 and refuses NUL bytes; then it canonicalises the target and checks containment against every authorised root's real path. Writes additionally require allowWrite on that root, mustExist checks read/write access, and expected verifies file versus directory.
Containment must not use startsWith. C:\Users\me\Documents and C:\Users\me\Documents-evil differ by a suffix, and a string prefix misjudges them; Z Bot computes a relative path with path.relative, rejects .. and absolute escapes, and lowercases on Windows before comparing. This is easy to miss in cross-platform code, and missing it is a directory traversal bug.
Symptom. A test creates a temp directory with mkdtemp, compares against a realpath result, the two differ and the test fails on Windows. Two commits fixed tests: f4cf8a8 and b9b45de.
Cause. Windows generates 8.3 short names for long paths, so the temp directory and its real path can come back in different forms. The test compared strings while production code canonicalised with realpath: the test thought the product was wrong when the assertion was not normalised.
Fix. The test normalises expectations the same way; production path-guard.ts calls realpath level by level, reattaches the missing segments to the real parent, and lowercases before comparing.
Cost. Path canonicalisation is best effort, not cryptographic equivalence: short names, casing and junctions can make two strings look different or identical. Tests must follow platform behaviour rather than assume POSIX semantics.
Symptom. Canonicalising only the target makes a not-yet-created write path fail with ENOENT; if the target is a junction or symlink pointing outside the authorised directory, realpath lands outside the root. The two failures are opposite: one false positive, one false negative.
Cause. Write targets often do not exist and cannot be required to resolve, while symlinks can carry a path outside the authorised range after resolution, so checks are needed before and after.
Fix. canonicalizePotentialPath walks upward from the target to the nearest existing parent, at most 64 levels, collecting missing segments, resolves that parent, then reattaches segments in order; roots are canonicalised too; expected checks reject isSymbolicLink() after lstat. A Windows system-directory blacklist rejects SystemRoot, Program Files and similar.
for (let index = 0; index < 64; index += 1) {
try {
const canonical = await realpath(current);
return resolve(canonical, ...missing.reverse());
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') throw error;
missing.push(basename(current));
current = dirname(current);
}
}
Cost. A TOCTOU window remains between check and use: an attacker can swap a parent directory for a link after realpath and before the write. The 64-level cap rejects very deep paths, and the system blacklist depends on environment variables that may be missing. The guard stops ordinary mistakes and naive attacks, not a kernel-level sandbox.
The first step of startChat is not calling the model but persisting. It confirms or creates the conversation, writes the user message, updates the title, writes an empty assistant message, then creates requestId and AbortController, emits chat.started and runs asynchronously. That ordering guarantees the user message and a failure placeholder exist even if the model call fails, so the UI never shows "sent but missing from history".
Two shortcuts sit at the entrance. First, an explicit memory regex: sentences starting with "remember" go straight to memory writing without a model call. Second, intent routing: short sentences, greetings and questions take direct, while todos, reminders, files, web and long sentences take agent. Routing is not a semantic classifier but an ordered set of regexes — cheap, testable, no extra model call, and occasionally wrong.
Direct mode calls the model once, suited to "explain this concept" or "write one line of copy". Agent mode loops: the model emits text and tool calls, results are appended, the model is called again, until it stops calling tools or hits a cap. The loop allows at most 20 steps, 30 tool calls and a 5-minute total timeout; usage and retries accumulate on the task record. If a tool result fails its verification, the step is marked failed rather than pretending success.
Symptom. Routing depends only on keywords and sentence length. A chatty sentence containing "file" can enter the agent, while a long sentence with no keyword that genuinely needs multiple steps can be misrouted.
Cause. No trained classifier and no model call to judge intent first; regexes plus an 80-character threshold were chosen to save a call and reduce latency.
Fix. High-frequency intents are separate regexes matched in order — memory, todo, reminder, web, system, file, recall, image, greeting, question, short sentence; explicit memory and shortcuts short-circuit before routing; long or unclassifiable requests default to the agent, spending an extra loop rather than treating a multi-step task as chat.
Cost. Regex maintenance grows with every intent, and mixed Chinese/English or colloquial phrasing slips through; the 80-character threshold is empirical, not semantic. A small local classifier could replace it later; that has not been done.
Z Bot does not stuff all history into the prompt. ContextManager.build() fetches the project, authorised roots and relevant memories, assembles the system prompt, then fills history against a character budget. The total is 72,000 characters; history holds at most 20 messages, newest first, each truncated to 8,000 characters; the current user input is cut at 20,000; OCR text is capped at 24,000 and wrapped in <untrusted_image_text> so the model is told plainly that it is untrusted data, not instructions.
Memory retrieval uses the first 500 characters of the current question as the query, takes 8 FTS hits, and if fewer than four arrive, tops up with the four most recent by project and time, deduplicated to at most eight. Ordering is bm25 ascending, then importance descending, then updated_at descending. The strategy is conservative: better a few recent memories than answering with none.
History loading is short code with a clear boundary:
let remaining = Math.max(0, maxChars - system.length);
const selectedHistory: LlmMessage[] = [];
for (let index = history.length - 1; index >= 0; index -= 1) {
const message = history[index]!;
if (message.content.length > remaining) break;
selectedHistory.unshift(message);
remaining -= message.content.length;
}
Symptom. When a single historical message exceeds the remaining budget, the loop breaks and no earlier messages load at all. One long old reply truncates the entire history instead of being skipped.
Cause. The implementation chose "load continuously from newest backwards" so the context is one contiguous run with no holes; the cheapest way to handle a message that does not fit is to stop.
Fix. Each message is truncated to 8,000 characters first, reducing the chance; the system prompt and memories are kept inside the budget; the current user message is truncated separately and never competes with history.
Cost. This is a known rough edge. Skipping the oversized message and continuing with older ones would be more reasonable, as would budgeting in tokens rather than characters; neither is done, because contiguous history helps the model and the 8,000-character cut already excludes most cases.
Character budgets carry another error: Chinese, English and code differ in token density, so 72k characters is not 72k tokens. Characters were chosen because they can be estimated cheaply without a tokenizer, at the risk of occasionally exceeding the model window. Provider errors surface as MODEL_* codes in the UI, so users see a failure instead of a silently dropped message.
The provider layer wraps OpenAI, OpenAI-compatible, Anthropic, Google and Ollama. The first four use official or compatible SDKs; Ollama goes through the OpenAI-compatible interface at http://127.0.0.1:11434/v1 by default. The AI SDK's streamText performs the request, and Z Bot normalises output into its own events: text-delta, tool-call, finish, usage, error, aborted. The agent above never needs to know which provider is in use or how each streams.
Retry policy is at most three attempts per provider with 500 ms, 1000 ms and 2000 ms backoff; the AI SDK's maxRetries is set to 0 to avoid a second layer inside. Only retryable errors retry — configuration failures like MODEL_KEY_MISSING or MODEL_BASE_URL_MISSING fail immediately. The fallback model is used only after every primary attempt fails, and never once text has already been emitted.
const retryable = normalized instanceof ModelRequestError ? normalized.retryable : true;
if (!retryable || attemptNumber >= maxAttempts || textEmittedRef.value) {
break;
}
retries += 1;
await delay(500 * 2 ** (retries - 1), signal);
Symptom. Streaming already produced a few words, the request fails midway, the retry answers from the beginning, and the UI shows "partial text + complete answer".
Cause. Retry logic cared only about "the request failed", not about whether visible deltas had already been emitted; the SDK could also retry internally and amplify it.
Fix. A textEmittedRef records whether this attempt sent any text-delta; once true, the current provider may not retry and fallback is forbidden. The provider sets maxRetries: 0, concentrating retries in streamModel.
Cost. Emitted partial text is not retracted, so a user may see half a sentence followed by an error; and if the model fails after a tool call, retry is also forbidden and the task fails. This buys "no duplicates" over "always succeed", which matters more for chat.
The 5-minute overall timeout is a second gate covering the whole conversation, not one request; when it fires, AbortController cancels the running stream, permission waits and tool execution, and the run is marked cancelled rather than treated as a successful partial answer.
Memory has five types: user, project, event, task, global. They live in a STRICT memories table with content, type, project id, importance, confidence, expiry and archive time; tags live in memory_tags with cascading foreign keys. Retrieval uses the memories_fts FTS5 virtual table with content='memories' and content_rowid='rowid', kept in sync by three triggers for insert, update and delete.
Chinese retrieval is the most underrated problem here. FTS5's default unicode61 tokenizer does not segment Chinese into meaningful words; a whole sentence often becomes one token, and searching "release process" matches nothing beyond the exact phrase. Z Bot switches to the trigram tokenizer, indexing three-character windows so Chinese phrases can be partially matched; queries shorter than three characters cannot use FTS and degrade to instr(lower(content), lower(query)) substring scans.
CREATE VIRTUAL TABLE memories_fts USING fts5(
content,
content='memories',
content_rowid='rowid',
tokenize='trigram'
);
Symptom. English keywords match, whole Chinese sentences do not; after switching the tokenizer to trigram, two-character queries stop matching entirely.
Cause. FTS5's default tokenizer targets space-separated languages and Chinese has no spaces; trigram needs at least three characters to build a window.
Fix. Create the table with trigram; queries of three or more characters use MATCH with the query quoted and inner quotes escaped; shorter ones use instr(lower(...)). A test with the two-character word "简洁" covers the degraded path specifically.
Cost. A trigram index is larger than the default and costs more on write; two-character queries become full scans and slow down as memory grows; there are no synonyms, no pinyin and no semantic recall, so "go live" and "release" stay unconnected. v0.1 accepts this and defers a vector store.
Memory has exactly two write paths: the entry regex recognising "remember ...", or the model actively calling the remember tool. The latter is gated by memory.autoExtract; with it off, the model cannot write on its own. There is no post-conversation extraction pass, deliberately: that needs extra model calls plus handling for duplicates and hallucination, so v0.1 first makes "the user explicitly said remember" reliable.
Dedupe is exact equality after normalisation: same type, same project, and equal lower(trim(content)). A hit takes the larger of importance and confidence and merges tags. There is no semantic dedupe and no decay scoring; expiry only archives via expiresAt -> archivedAt, with no time-decayed ranking. Retrieval orders by bm25, importance and updated_at — simple, explainable, fast enough.
This system is closer to an index than a brain. It does not summarise conversations, and an unused memory never loses weight; its strength is that every item can be seen, edited and deleted in the UI, so users can explain why the model mentioned something. For a personal assistant, explainability beats recall.
The scheduler is light: one setTimeout per enabled reminder, and refresh() clears every timer and re-plans on start or on any change. A fired reminder writes a reminder.triggered event, sends a Windows notification per settings, then computes the next trigger with nextReminderTrigger; one-off reminders set enabled to 0, recurring ones advance to the next future moment.
The problem is the setTimeout ceiling. Node's timer delay overflows above 2^31−1 milliseconds (about 24.8 days) and fires immediately. Z Bot clamps to 2_147_000_000 ms, sleeps that long, then re-reads the reminder and re-plans, forming a chain.
const MAX_TIMEOUT = 2_147_000_000;
const delay = reminder.triggerAt - Date.now();
if (delay > MAX_TIMEOUT) {
const timer = setTimeout(() => {
this.timers.delete(reminder.id);
this.schedule(this.reminders.get(reminder.id) ?? reminder);
}, MAX_TIMEOUT);
timer.unref?.();
this.timers.set(reminder.id, timer);
return;
}
Symptom. A reminder a month out fires immediately after startup.
Cause. setTimeout takes a 32-bit signed delay; beyond 2^31-1 ms it wraps negative or to zero and runs at once.
Fix. Sleep one 24.8-day segment and re-read the reminder in the callback; treat negative delays as 0 so overdue reminders fire once; disable one-off reminders after firing, and advance recurring ones with nextReminderTrigger instead of replaying missed history.
Cost. Chained re-planning requires the process to stay alive; a full exit loses the timers and missed reminders never fire. The README admits this. Windows Task Scheduler is not used, nor is a startup scan for reminders that should have fired in the last 24 hours, because catch-up policy is a product decision — once or every missed occurrence — and v0.1 declines it.
One more boundary: at startup a negative delay fires immediately. For one-off reminders that surfaces an overdue, never-fired item; for recurring ones it fires the current occurrence and advances to the next future moment without replaying every missed day. The semantics are right, but a user expecting "catch up on everything missed" will feel one is missing.
The proactive service handles project-stall reminders. It checks 10 seconds after start and every 6 hours after; a running flag prevents re-entry. Each pass reads 400 events, finds the latest project.inactivity per project, skips when that notification is newer than the project's latest activity, and otherwise decides against the threshold. The default threshold is 7 days, and directory mtime counts as activity only when the directory is inside an authorised root.
That dedupe solves "notify the same stalled project on every pass". It compares "last notified" with "latest activity": activity after a notification restarts the clock, and silence since then stops repeats. The cost is that the event table must retain enough history; 400 rows is empirical, and a busy event stream can squeeze out old records and cause duplicate notifications.
Task recovery is the other half. On exit, markInterrupted() turns running and pending into paused, and nothing auto-resumes. Agent tasks can have side effects — writing files, moving files, deleting to the Recycle Bin, sending notifications; replaying them after a restart could delete or move twice, far worse than a task sitting paused.
Symptom. The app closes halfway through a task; resuming from the stored state on restart can execute a tool call again.
Cause. Task state and tool-call records persist, but "which step ran" and "whether the side effect happened" have no transactional link, so the model may replay a tool.
Fix. Call markInterrupted() at startup, turning running and pending into paused; tasks waiting on permission become waiting_permission; nothing resumes automatically, and the user must confirm and re-issue. Tools add idempotency: writes carry content hashes, moves and deletes preview, deletes go to the Recycle Bin.
Cost. Users lose auto-resume, and a long interrupted task must be re-described; paused tasks have no one-click resume. That is safety over completeness.
Tray residency, minimise-on-close, single instance and launch-at-login are the foundation of that residency. requestSingleInstanceLock() makes a second launch exit and focus the existing window; closing the window minimises to the tray and only an explicit quit ends the process. That gives reminders and proactive checks somewhere to run, and makes it easy for users to think the app already exited.
Windows notifications are mandatory for a desktop assistant and the classic "code is correct but nothing appears". Z Bot calls app.setAppUserModelId('com.zbot.desktop') at startup, the prerequisite for Windows notifications; before sending it checks Notification.isSupported() and returns false instead of throwing. Clicks can navigate — a reminder to Home, a stalled project to Memory.
Symptom. A reminder fires, the log says reminder fired, and no desktop notification shows.
Cause. Windows needs the right App User Model ID or it cannot attribute the notification; Focus Assist, disabled notification permission or a tray process that is not running also suppress it silently.
Fix. Set setAppUserModelId in the main process; NotificationService.show checks isSupported() first; the README documents the diagnostic steps — system notification settings, Focus Assist, whether the app runs in the tray.
Cost. isSupported() reports platform capability, not whether the user muted notifications, and there is no in-app banner fallback, so reminders can be missed. That is a blank spot in this version.
OCR better illustrates how plans change. The repository ends up using Windows' built-in Windows.Media.Ocr through a PowerShell child process: -NoProfile, -NonInteractive, -ExecutionPolicy Bypass, windowsHide, a 30-second timeout and a 1 MB output buffer. Results go to a temp file, are read back and clipped to 20,000 characters; failure returns null so the vision model can take over.
Symptom. It started with Tesseract, the build config still carries a plugin copying @tesseract.js-data, but runtime code no longer references it.
Cause. Tesseract needs extra language data and native dependencies, with unfavourable size, startup and Chinese accuracy; Windows 10/11 ships an OCR engine with language packs already installed, so calling it is lighter.
Fix. Move to a PowerShell child process driving Windows.Media.Ocr, packaging the script as a resource; return null on failure so the vision model handles it; hide the window and cap timeout and output so the main process cannot hang.
Cost. The child process has startup overhead, and 30 seconds may be too short for huge images; it depends on Windows language packs, so Chinese OCR can fail on non-Chinese systems; the leftover Tesseract build config is technical debt — harmless at runtime, misleading to the next reader.
The tray and shortcuts are the last piece. The tray menu offers show, quick ask and quit; Ctrl+Space opens Quick Ask and Ctrl+Shift+Z captures the primary screen into the chat. Single instance and launch-at-login keep those entry points stable, which also means a lifetime longer than a normal window app — quit order must call scheduler.stop(), proactive.stop(), database.close() and wal_checkpoint in sequence.
The 32 unit tests cover what is easy to get wrong and hard to notice afterwards: short paths and symlinks in the path guard, exact memory dedupe, reminder recurrence rules, provider event normalisation, runtime permission denial, project-stall dedupe. Two E2E tests cover starting a real window and a basic conversation. The suite is small, but each case maps to a real hole, which beats chasing a coverage number.
Packaging is another category of trap. Automated releases ship only portable and MSI, because the NSIS uninstaller gets locked by antivirus; unsigned Windows installers trigger SmartScreen, requiring an extra "run anyway". playwright and jsdom sit in runtime dependencies and inflate the installer; the repository has no measured size, so this post states the cost without inventing a number.
Symptom. The NSIS installer never reaches release artefacts, only portable and MSI; the installer is larger than expected.
Cause. The NSIS uninstaller is held or locked by antivirus during build or scan; playwright and jsdom, which belong in tests or tooling, were written into dependencies, so the packager bundles them at runtime.
Fix. The release script uses electron-builder --win portable msi to bypass NSIS; asar is enabled; the signing gap is documented rather than pretended away; dependency cleanup — moving test dependencies to devDependencies or excluding them in the bundler — is deferred.
Cost. Without NSIS there is no classic setup wizard or Start Menu uninstall entry, only the portable build or MSI; unsigned builds warn in SmartScreen; no size measurement exists to judge the benefit. All three remain open.
Z Bot compresses into four sentences. First, keep dangerous capability in the main process: database, keys, tools and system calls never enter the renderer, and preload exposes only whitelisted methods. Second, make permissions a flow rather than a switch: tools declare a spec, matching rules pass, misses wait for the user, and denial becomes a tool result rather than an exception. Third, write the budgets down: 72k context characters, 20 history messages, 8 memories, 20 agent steps, 30 tool calls, 5 minutes, 24.8-day timers — numbers that can be audited. Fourth, make memory an explainable index: FTS5 with trigram, exact dedupe, archival on expiry, no vector store.
Its value is not feature count but that every feature has a traceable boundary: which step runs in the main process, which needs authorisation, which can fail, and what the user sees when it does. Its rough edges are equally explicit: an oversized history message breaks the loop, some IPC lacks validation, OCR build config is stale, NSIS and signing are unresolved, and reminders do not catch up after a full exit. Writing those down beats pretending they do not exist.
If you are building a desktop agent, copy these three first: main-process exclusivity plus a whitelisted preload, the declare-check-wait permission flow, and realpath normalisation in the path guard. Context budgets, memory retrieval and scheduling can be tuned to your product.