How Sevak works¶
This guide explains Sevak's architecture for curious users and contributors. It covers the main components, how a query flows through the system, and the strategies used for hotkeys and indexing.
System components¶
graph LR
subgraph ui["UI<br/>(Svelte 5)"]
UiWindow["Window & Results<br/>Text input"]
end
subgraph shell["Sevak Shell<br/>(Tauri)"]
TauriShell["Hotkey Handler<br/>IPC Router"]
SearchState["Search State<br/>Result Cache"]
Commands["Commands<br/>execute, copy, paste"]
end
subgraph engine["sevak-core<br/>(Engine & Plugins)"]
SearchEngine["SearchEngine<br/>route & rank"]
UsageStore["Usage Statistics"]
PluginRegistry["Plugin Registry<br/>Built-in plugins"]
end
subgraph plugins["sevak-plugins<br/>(Implementations)"]
BuiltIn["Built-in:<br/>apps, files, calc,<br/>web, system, shell,<br/>clipboard, snippets"]
ScriptHost["Script Plugin Host<br/>(external scripts)"]
end
subgraph platform["sevak-platform<br/>(OS Abstraction)"]
PlatformProvider["PlatformProvider<br/>launch, open, paste,<br/>clipboard, hotkey strategy"]
Windows["Windows:<br/>Start Menu,<br/>UWP apps"]
macOS["macOS:<br/>.app bundles"]
Linux["Linux:<br/>.desktop files"]
end
subgraph os["Operating System"]
OSServices["File System<br/>Process Launch<br/>Clipboard<br/>Window Manager"]
end
UiWindow ---|"Tauri IPC"| TauriShell
TauriShell ---|query| SearchEngine
TauriShell ---|execute| Commands
SearchEngine ---|plugins| PluginRegistry
PluginRegistry ---|queries| BuiltIn
PluginRegistry ---|external| ScriptHost
Commands ---|actions| PlatformProvider
BuiltIn ---|platform provider| PlatformProvider
PlatformProvider ---|Windows| Windows
PlatformProvider ---|macOS| macOS
PlatformProvider ---|Linux| Linux
Windows ---|calls| OSServices
macOS ---|calls| OSServices
Linux ---|calls| OSServices
SearchEngine ---|reads/writes| UsageStore
PlatformProvider ---|gets stats| UsageStore
Component breakdown:
- UI (Svelte 5): The launcher window with the search input, result list, and action panel. Communicates with Tauri via IPC commands.
- Sevak Shell (Tauri): Owns the application window, system hotkey registration, tray icon, and CLI argument parsing. Routes queries to the engine and executes actions via the platform provider. Manages the single-instance lock (on Windows/macOS/Linux).
- SearchEngine: The core: routes queries to plugins, ranks results by fuzzy match and usage statistics, applies fallback search, and supplies late-result notification callbacks.
- Built-in plugins: Apps, files (and whole-disk / content search through the OS index), calculator (with unit/currency conversion), web search, system commands, automation tasks, media controls, shell commands, clipboard history (text, images and files), snippets, the emoji picker, contacts, 1Password, the dictionary, the opt-in AI assistant, and bookmarks. Each implements the
Plugintrait and answers queries from in-memory data or, for slow sources such as the OS file index, through late results. The file buffer and snippet expansion as you type live beside them. - Script Plugin Host: Discovers and manages external plugins—scripts in Python, PowerShell, Node.js, or any language—without requiring a rebuild.
- Extensions: The opt-in store behind Settings > Extensions and the
ext/storekeywords (sevak_plugins::extensions): the catalog from the gallery lists, install, update and uninstall with receipts, the.sevakextpackage for native extensions written in Rust, and the launcher plugin. It adds no trust of its own: everything it installs is a new, unapproved script plugin, workflow or theme. See Writing extensions in Rust. - Workflows: A graph engine and runtime for workflows (keyword, script filter, hotkey, Universal Actions and external triggers chained to actions and outputs), plus the opt-in gallery. See Workflows for contributors.
- Platform Provider: The OS abstraction. Launches apps, opens files and URLs, manages clipboard, synthesizes keypresses for pasting, captures the selection (for Universal Actions), and provides the hotkey strategy.
Query lifecycle¶
sequenceDiagram
actor User
participant UI as UI (Svelte)
participant Tauri as Tauri Shell
participant Engine as SearchEngine
participant Plugins as Plugins
participant Platform as Platform Provider
User->>UI: Type "ff"
UI->>Tauri: search("ff")
Tauri->>Engine: query("ff")
par Parallel query
Engine->>Plugins: query("ff") to global plugins
Plugins-->>Engine: Vec<ResultItem>
end
Engine->>Engine: Merge, dedupe, rank by score
Engine->>Engine: Apply usage boost
Engine->>Engine: Truncate to max_results
Engine-->>Tauri: results (ticket + items)
Tauri->>Tauri: Extract icons, convert to UI DTOs
Tauri-->>UI: SearchResponse {<br/> ticket, results<br/>}
UI->>UI: Render result list
Note over Tauri: UI shows results,<br/>user presses Enter on first item
User->>UI: Press Enter
UI->>Tauri: execute(item_id, ticket)
Tauri->>Engine: execute(item, full_query)
Engine->>Plugins: execute(item)
Plugins->>Plugins: Build Action for the result
Plugins-->>Engine: Action::Launch | OpenPath | ...
Engine->>Platform: execute_action(action)
Platform->>Platform: Dispatch: run app, open file, copy text, ...
Platform->>Tauri: Success or Error
Tauri->>UI: Result executed, hide window
UI->>User: Window disappears
par Background (if enabled)
Tauri->>Tauri: Record usage (result id, timestamp, recency)
Tauri->>Tauri: Save usage.json after debounce
end
Key points:
- Per keystroke:
queryruns on a worker thread (never the main thread) and must complete in milliseconds. - Result caching: Results are stored with a ticket number so later-arriving script plugin results can still update the list by rerunning
querywith the cached results. - Execute path: The item contains a prebuilt
Actionfrom the plugin;execute_actionin the platform layer handles standard actions without plugin involvement. - Usage recording: After a successful action, the result's id is logged with a timestamp for ranking boost on future queries. This ranking is what "Learn as you type" means.
Keyword routing and ranking¶
graph TD
A["Input: 'g search term'"] --> B{Whitespace after<br/>first word?}
B -->|"Yes: 'g <br/>search term'"| C{Any plugin has<br/>keyword='g'?}
C -->|Yes| D["Keyword route:<br/>Query web:g plugin<br/>with 'search term'"]
C -->|No| E["Global route:<br/>All global plugins"]
B -->|"No: 'gsearch'"| E
D --> F["Results from<br/>web:g"]
E --> G["Results from<br/>apps, files, calc, etc."]
G --> H["Merge & Dedupe<br/>by result id"]
F --> H
H --> I["Rank by:<br/>1. Fuzzy score or<br/> score::KEYWORD"]
I --> J["2. Apply usage boost<br/>(if score less than<br/>KEYWORD threshold)"]
J --> K["3. Sort by score desc,<br/>then title, then id"]
K --> L["4. Truncate to<br/>max_results"]
L --> M{Got any<br/>results?}
M -->|Yes| N["Return results"]
M -->|No| O["Check fallback plugins<br/>(default: web:g)"]
O --> P["Query fallback<br/>with full input"]
P --> N
N --> Q{Input exactly<br/>matches keyword?}
Q -->|Yes: 'g'| R["Add keyword hint<br/>(e.g., 'Search Google...')"]
Q -->|No| S["Done"]
R --> S
style D fill:#e8f4f8
style I fill:#fff4e8
style J fill:#fff4e8
style O fill:#f0e8f8
Routing rules:
- Whitespace-only input: No results.
- Keyword route (e.g.,
gfor Google): Only plugins with that keyword answer; no fallback. - Symbol keywords (e.g.,
>): No space required—>lstriggers the shell plugin. - Global route: All plugins with
global() == trueanswer (apps always global; files/bookmarks only if configured). - Fallback search: If global route found nothing, query the fallback (usually
web:gfor web search). - Keyword hints: If you type a keyword alone (
g), a hint row appears so Tab autocompletes it tog.
Ranking:
- Fuzzy matches score roughly 16–20 points per character plus bonuses for word starts.
- Non-fuzzy sources (calculator, clipboard history) use fixed scores:
EXACT_ANSWER(10,000),KEYWORD(5,000), orFALLBACK(0). - Usage boost: Recent and frequent launches get a boost that halves every 72 hours. Prefix matches get a flat bonus. Boosts only apply below
KEYWORDthreshold. - Deduplication: If multiple plugins return the same result id, the highest score wins.
Executing a result¶
sequenceDiagram
actor User
participant UI as UI
participant Tauri as Tauri Shell
participant Engine as SearchEngine
participant Plugin as Plugin
participant Platform as Platform Provider
participant App as Target App
Note over User,App: Execute primary action (Enter)
User->>UI: Press Enter on Firefox result
UI->>Tauri: execute(item_id='apps:firefox.exe', <br/>ticket=42)
Tauri->>Tauri: Find cached ResultItem by id
alt Cached ResultItem found
Tauri->>Engine: execute_secondary(item, index=-1, query)
Engine->>Engine: Find plugin by item.plugin_id
Engine->>Plugin: execute(item)
Plugin->>Plugin: Validate Action::Launch
Plugin-->>Engine: Ok(())
else Cached ResultItem missing
Tauri-->>UI: Error: "Result expired"
end
Engine->>Platform: execute_action(&item.action)
Platform->>Platform: Match on action type
alt Action::Launch
Platform->>Platform: Resolve LaunchTarget to command
Platform->>App: Spawn process (detached)
else Action::OpenPath
Platform->>App: Open file with default handler
else Action::OpenUrl
Platform->>App: Open URL in default browser
else Action::CopyText
Platform->>Platform: Set clipboard
else Action::PasteText
Platform->>Platform: Copy text
Platform->>Platform: Remember foreground app
Platform->>Platform: Bring it back to focus
Platform->>Platform: Synthesize Ctrl+V / Cmd+V
Platform->>Platform: Restore old clipboard (if requested)
else Action::RevealPath
Platform->>Platform: Open folder with item highlighted
else Action::RunAsAdmin
Platform->>Platform: Launch with elevation (Windows UAC)
else Action::Custom
Platform-->>Plugin: "Unsupported; ask plugin"
Plugin->>Plugin: Handle custom payload
end
Platform-->>Engine: Result: Success or Error
Engine-->>Tauri: Success or Error message
Tauri->>Tauri: Record usage: result id + timestamp
Tauri->>UI: hide_window()
UI->>UI: Window disappears
opt Late result handling
Note over Tauri: If error, don't hide;
Note over Tauri: user can retry or try a different result
end
Execute path:
- Lookup: The cached
ResultItemis found by id from the last successful search. - Plugin execution: The plugin validates its action and returns success or an error.
- Platform dispatch: Standard actions (
Launch,OpenPath,CopyText,PasteText, etc.) are handled by the platform provider.Customactions are unsupported at this layer and require the plugin to handle them. - Usage record: On success, the result id is logged with a timestamp for the usage store. This is how the launcher learns which results you pick.
- Error handling: If an action fails, the window stays open so you can try again.
Secondary actions (Ctrl+Enter, Shift+Enter, or via the action panel) follow the same path with a different action swapped into the item.
Hotkey strategies per OS¶
graph TD
A["Sevak is running"] --> B{Operating System}
B -->|Windows| C1["In-App Global Shortcut<br/>(HotkeyStrategy::InApp)"]
B -->|macOS| C2["In-App Global Shortcut<br/>(HotkeyStrategy::InApp)"]
B -->|X11 Linux| C3["In-App Global Shortcut<br/>(HotkeyStrategy::InApp)"]
B -->|Wayland Linux| C4["External Hotkey<br/>(HotkeyStrategy::External)"]
C1 --> D1["Tauri global-shortcut plugin<br/>registers hotkey via OS"]
C2 --> D2["Tauri global-shortcut plugin<br/>uses Cocoa APIs"]
C3 --> D3["Tauri global-shortcut plugin<br/>uses X11 grab"]
C4 --> D4["Sevak does NOT register.<br/>Desktop environment owns it."]
D1 --> E1["Hotkey pressed"]
D2 --> E2["Hotkey pressed"]
D3 --> E3["Hotkey pressed"]
D4 --> E4["Desktop runs:<br/>sevak --toggle"]
E1 --> F["Tauri calls<br/>hotkey::pressed()"]
E2 --> F
E3 --> F
E4 --> G["CLI launches new<br/>single-instance handler"]
G --> H["Single-instance forwards<br/>to running Sevak"]
H --> F
F --> I{Which hotkey?}
I -->|Main hotkey| J["Toggle window<br/>show/hide"]
I -->|Actions hotkey| K["Capture selection<br/>Show action panel"]
I -->|[[hotkey]] entry| L["Execute direct query<br/>or run by id"]
J --> M["window::toggle()"]
K --> N["selection::trigger()"]
L --> O["direct::open_with_query()<br/>or direct::run_result()"]
M --> P["Window visible?"]
P -->|Yes| Q["Hide & return<br/>focus to previous app"]
P -->|No| R["Show & focus"]
style C4 fill:#ffe8e8
style E4 fill:#ffe8e8
style D1 fill:#e8f4f8
style D2 fill:#e8f4f8
style D3 fill:#e8f4f8
Hotkey strategies:
- Windows, macOS, X11 Linux: Sevak grabs the configured global shortcut through the Tauri
global-shortcutplugin. When you press it, the plugin invokes the handler immediately. - Super+Space (the default) and keys another app owns: The OS reserves Win+Space (input language), Cmd+Space (Spotlight) and Super+Space (GNOME input sources). On Windows a low-level keyboard hook (
sevak_platform::hotkey_hook, one hook thread shared with snippet expansion) recognises exactly the configured shortcuts the plugin cannot register, swallows them and sends the shell the same "pressed" event; the recognising logic is a pure state machine with unit tests. On macOS and GNOME, Sevak asks once whether it may turn the OS shortcut off or move it, records the answer and the old values (hotkey-takeover.json) and can restore them (sevak --restore-hotkey). See Troubleshooting. - Wayland Linux: Applications cannot grab keys (security model). Instead, the desktop environment (typically GNOME) owns the bindings and runs
sevak --toggle(or--actions,--query,--run) when the hotkey is pressed. Sevak's single-instance handler forwards the command to the running instance. - Single-instance: Only one Sevak process runs. If a second instance is launched, it exits after forwarding its arguments to the first.
Indexing lifecycle¶
stateDiagram-v2
[*] --> Startup: App starts
Startup --> BuildEngine: Load config & usage
BuildEngine --> RefreshStart: Spawn refresh thread
RefreshStart --> Scanning: Scan built-in data<br/>(apps, files, bookmarks)
Scanning --> RefreshComplete: Index complete
RefreshComplete --> Idle: Ready for queries
Idle --> UserReload: User chooses<br/>'Reload index'
Idle --> PeriodicRefresh: 10 minutes elapsed
Idle --> ConfigReload: Config or plugins changed
UserReload --> Scanning
PeriodicRefresh --> Scanning
ConfigReload --> Scanning
Scanning --> RefreshComplete
RefreshComplete --> Idle
Idle --> Queries: Queries run<br/>using current index
Queries --> Idle
Idle --> UsageSave: Result executed<br/>(after 2s debounce)
UsageSave --> SaveFile: Write usage.json
SaveFile --> Idle
Idle --> Quit: User quits
Quit --> SaveFinal: Final usage save
SaveFinal --> [*]
note right of RefreshStart
Runs in background,
doesn't block queries
end note
note right of Scanning
apps plugin: list desktop entries,
Start Menu shortcuts
files plugin: build search index
bookmarks: read browser databases
(Firefox: make temp copy of .sqlite)
end note
note right of PeriodicRefresh
Checks for new apps,
deleted files, updated bookmarks
end note
Indexing lifecycle:
- Startup: The app loads the config and usage statistics, builds the search engine, and spawns a background refresh thread.
- Initial scan: Apps, files, and browser bookmarks are indexed.
- Queries run: Until a refresh completes, queries use the previous index. Late refresh results are shown via
sevak:resultsevent if still relevant. - Periodic refresh: Every 10 minutes, the index is updated in the background. A file that hasn't changed (modification time and size) is not re-read.
- User reload: "Reload index" in the tray menu forces an immediate refresh.
- Config reload: Editing
config.tomlor restarting triggers a rebuild. - Usage save: After a result is executed, usage is written to disk after a 2-second debounce (to avoid thrashing on rapid launches).
Script plugin process model¶
sequenceDiagram
participant Sevak
participant ScriptHost as Script Host
participant Process as Script Process
participant Script as Script Code
Note over Sevak,Script: Startup (lazy)
User->>Sevak: Type "hello world"
Sevak->>ScriptHost: query("hello", plugin_id)
alt Process not running
ScriptHost->>ScriptHost: Load plugin.toml
ScriptHost->>ScriptHost: Build command
ScriptHost-->>Process: spawn(["python", "main.py"])
Process->>Script: Process starts
end
ScriptHost->>Process: Send JSON:<br/>{"type":"initialize",...}
Process->>Script: Parse initialize
opt Script replies (optional)
Script->>Process: stdout: {"type":"ready"}
end
ScriptHost->>ScriptHost: Start timer<br/>(timeout_ms = 50ms)
ScriptHost->>Process: Send JSON:<br/>{"type":"query",<br/>"request_id":1,<br/>"input":"world"}
par Query wait
Process->>Script: Execute query logic
Script->>Script: Do I/O, compute, etc.
end
alt Before timeout (50ms)
Script->>Process: stdout: {"type":"results",...}
Process->>ScriptHost: JSON received
ScriptHost->>ScriptHost: Parse, validate, cache
ScriptHost-->>Sevak: results
Sevak->>User: Show results
else Timeout, answer comes late
ScriptHost->>ScriptHost: Timeout; show cached or empty
ScriptHost-->>Sevak: Late answer dropped or queued
Note over ScriptHost: Later keystroke (newer request_id)
Note over ScriptHost: Late result still arrives
ScriptHost->>ScriptHost: If for newest request,<br/>emit sevak:results event
Sevak->>Sevak: Re-run query with<br/>now-cached result
User->>User: List updates
end
Note over ScriptHost: User picks result or types more
alt Custom action result
User->>Sevak: Press Enter on custom action
Sevak->>ScriptHost: execute(key, payload)
ScriptHost->>Process: Send JSON:<br/>{"type":"execute",...}
Process->>Script: Handle execution<br/>(e.g., save note)
Note over Process: No reply; script does work
end
Note over ScriptHost: Idle timeout or shutdown
ScriptHost->>Process: Send JSON:<br/>{"type":"shutdown"}
Process->>Script: Graceful shutdown
Script->>Process: Exit
Note over ScriptHost: Wait 500ms, then kill if needed
Script plugin communication:
- Lazy start: A persistent script starts only when its keyword is first used, not at Sevak startup.
- Protocol: Newline-delimited JSON (NDJSON) over stdin/stdout. Every line is one message object, UTF-8.
- Query flow: Sevak sends a
querymessage with a request_id and input. The script hastimeout_ms(default 50ms) to reply. - Late results: If the script answers after the timeout, Sevak uses the previous cached result (if the typed text is similar) and queues the late answer. The next keystroke reuses the cache, avoiding a flash of empty results.
- Execute: If the user picks a result with a
customaction, Sevak sends anexecutemessage. The script handles it and does not reply. - Graceful shutdown: When Sevak quits or reloads, it sends
shutdownand waits 500ms for the process to exit. If it doesn't, Sevak kills it.
Process lifecycle:
- Idle timeout: After
idle_timeout_secs(default 5 minutes) with no queries, the script is shut down to free resources. - Crashes: If the process dies or violates the protocol, it counts as a failure. After 5 failures in a row, the plugin stays off until the next "Reload index".
- Hard timeout: If a query stays unanswered for
hard_timeout_ms(default 3 seconds), the process is killed and marked as hung.
More information¶
- For why Sevak is built this way (local-first, the closed action vocabulary, approval before run, the release process and more), see the design decisions. For what it protects and how, see the threat model.
- For how to write a built-in plugin, see Writing plugins.
- For external script plugins (Python, PowerShell, Node.js), see External plugins.
- For development setup, see Developing Sevak.