This guide covers the architecture, development workflow, and project-specific conventions for contributing to Tilbo.
Tilbo is a three-component system: a daemon, a CLI, and a GUI. All three are in a single Go module (github.com/darkliquid/tilbo) with the GUI written in QML.
Unix socket (JSON-RPC)
tilbo ────────────────────────────────────────────► tilbo daemon
│
tilbo gui (QML) ────────── tilbo-ui.sock ─────────────►│
│
┌────────────────────────┤
│ │
┌─────▼─────┐ ┌──────▼──────┐
│ Watcher │ │ IPC Server │
│ (fanotify │ │ (handlers) │
│ /inotify) │ └──────┬──────┘
└─────┬─────┘ │
│ filesystem events │ requests
┌─────▼─────┐ │
│ Processor │◄─────────────────┘
│ (M2 pipe) │
└─────┬─────┘
│
┌──────────┼──────────┐
┌─────▼─────┐ ┌──▼──┐ ┌─────▼──────┐
│ Harvester │ │Rules│ │ Vectorizer │
│ Pipeline │ │Eng. │ │ (optional) │
└─────┬─────┘ └──┬──┘ └─────┬──────┘
│ │ │
┌─────▼──────────▼───────────▼─────┐
│ SQLite Index │
│ (FTS5 + sqlite-vec + sidecar) │
└──────────────┬────────────────────┘
│
┌──────▼──────┐
│ xattr / FS │
│ (source of │
│ truth) │
└─────────────┘
│
┌──────▼──────┐
│ FUSE Mount │
│ (tag-based │
│ symlinks) │
└─────────────┘
- Watcher detects filesystem changes via fanotify (preferred) or inotify (fallback)
- Processor (the M2 pipeline) receives events and orchestrates processing:
- Harvesters extract metadata (MIME type, dimensions, EXIF, audio tags, etc.)
- Rule Engine evaluates TOML/Lua rules against the metadata map and determines which tags to apply
- Vectorizer (optional) generates embeddings for semantic similarity search
- Index stores results in SQLite with FTS5 for full-text search and sqlite-vec for vector queries
- xattrs are the source of truth for tags and metadata. The index is a rebuildable cache
- Sidecar provides fallback storage for filesystems without xattr support (keyed by inode+device)
- FUSE exposes a virtual filesystem where directories represent tag queries and entries are symlinks to real files
- IPC serves requests from CLI and GUI over a Unix socket using protobuf-defined message envelopes
Tags and metadata are written to two places simultaneously:
- Primary (xattrs):
user.tags(space-separated list),user.meta.<key>(individual values),user.tags.source(JSON provenance) - Fallback (sidecar): SQLite database at
~/.local/share/tilbo/sidecar.dbfor filesystems without xattr support
The SQLite index is always the third copy, used purely for fast querying. It can be deleted and rebuilt from xattrs at any time.
Communication uses a protobuf-defined envelope model (proto/tilbo/ipc/v1/ipc.proto):
Envelope { request_id, oneof { Request, Response, Event } }
- Request/Response: Paired by
request_id. Each request type has a corresponding response type. - Events: Server-pushed notifications (e.g.,
FileTaggedEvent,IndexUpdatedEvent) with no response expected. - Transport: Newline-delimited JSON over Unix sockets. Go uses vtprotobuf for marshalling performance; the QML GUI uses protobufjs-generated ESM bindings.
There are two sockets:
/run/user/$UID/tilbo.sock— CLI-to-daemon IPC$XDG_RUNTIME_DIR/tilbo-ui.sock— GUI-to-daemon IPC (also receives push events)
The index has six migration stages (in internal/index/migrations/):
| Migration | Purpose |
|---|---|
0001_initial |
Core tables: files, tags, file_tags, metadata, tag_provenance, tag_overrides. Cardinality triggers on file_tags |
0002_fts5 |
FTS5 virtual table over metadata values |
0003_sidecar |
sidecar_data table (inode+device keyed JSON blobs) |
0004_embeddings |
Raw embedding storage |
0005_vec_embeddings |
sqlite-vec virtual table for ANN vector search |
0006_perf |
Additional indexes for query performance |
Hand-written queries live in internal/index/query.sql and are compiled to type-safe Go via sqlc (output in internal/index/dbgen/).
The harvester system has three execution modes:
- Built-in: Pure-Go extractors in
internal/harvester/builtin/(EXIF, PDF, MP4, MKV, EPUB, audio tags, MIME detection, file stat). These run in-process. - Subprocess: External tools invoked via stdin/stdout JSON protocol. Configured via TOML drop-in files in
~/.config/tilbo/harvesters/. - WebAssembly: WASI modules run in wazero sandbox. Same JSON protocol as subprocess but with no filesystem/network access.
Harvesters are registered in a priority-ordered registry. They run concurrently per-file, filtered by MIME type or path glob. Results are merged into a single metadata map before rule evaluation.
Two rule types:
- TOML declarative rules: Condition-based matching (operators:
eq,glob,gte,lte,gt,lt,between,in,not,before,after). Stored in~/.config/tilbo/rules/*.toml. - Lua scripted rules: An
apply(meta)function receives the metadata map and returns a list of tags. Sandboxed (no FS/network access). Stored in~/.config/tilbo/rules/*.lua.
Rule overrides: if a user manually removes a rule-applied tag, the override is recorded in tag_overrides and the rule won't reapply it.
The Quickshell GUI (internal/quickshell/) is a pure QML application with no Go build step:
- Services layer (
services/):TilboDaemon.qmlhandles socket connection and JSON-RPC;Theme.qmlprovides centralised colour palette;I18n.qmlprovides localisation - Components (
components/): Reusable UI elements (file grid, file list, tag search bar, theme button/icon, image preview) - Windows (
windows/):BrowserWindow.qmlimplements the main window with dual mode (normal directory browsing and tag-based search)
The GUI receives push events from the daemon (FileTagged, IndexUpdated, DaemonStateChanged, ShowWindow) and updates in real time without polling.
- Linux (kernel 5.10+ for fanotify; 5.17+ recommended)
- mise — task runner (install from mise.jdx.dev)
- Docker or Colima — for integration tests
- Quickshell — only needed if working on the GUI
mise manages all other tools (Go, buf, golangci-lint, sqlc, Node.js, esbuild, protobufjs-cli). Run mise install to set everything up.
mise run build # Build daemon + CLI (runs code generation first)
mise run build-daemon # Build daemon only
mise run build-cli # Build CLI onlyThe daemon prefers fanotify for filesystem watching, which requires CAP_SYS_ADMIN:
sudo setcap cap_sys_admin+ep ./tilboRebuilding the binary clears file capabilities — reapply after each build. If the capability is unavailable, the daemon falls back to inotify automatically.
All development tasks are defined in mise.toml. mise manages tool versions (Go, buf, golangci-lint, sqlc, Node.js, esbuild, protobufjs-cli) so you don't need to install them globally.
| Task | What it does | Notes |
|---|---|---|
mise run build |
Build all binaries | Depends on generate |
mise run build-daemon |
Build daemon only | Depends on generate |
mise run build-cli |
Build CLI only | Depends on generate |
mise run test |
Unit tests (go test -v ./...) |
|
mise run lint |
Lint with golangci-lint | Very strict config, see below |
mise run format |
Auto-format (goimports + golines) | |
mise run fixup |
go fix + golangci-lint --fix |
Fixes what it can automatically |
mise run ci |
Full pipeline | generate → lint → test → build |
mise run clean |
Remove build artifacts + generated code |
| Task | What it does | Notes |
|---|---|---|
mise run generate |
All code generation | Depends on generate-proto + generate-sqlc |
mise run generate-proto |
buf generate |
Generates Go + vtprotobuf from .proto |
mise run generate-js |
protobufjs → ESM bundle | Generates QML IPC bindings |
mise run generate-sqlc |
sqlc generate |
Runs in internal/index/ context |
| Task | What it does | Notes |
|---|---|---|
mise run test |
Unit tests | go test -v ./... |
mise run integration-test |
Integration tests | Requires Docker |
mise run local-integration-test |
Integration tests (Colima) | Sets DOCKER_HOST for Colima socket |
go test -v -run TestFunctionName ./internal/package/...mise run run-quickshell # Daemon must be running firstThis auto-detects the icon theme via contrib/scripts/detect-icon-theme.sh and launches Quickshell.
Both build-daemon and build-cli depend on generate, which runs protobuf and sqlc code generation. mise tracks source file timestamps, so generation only re-runs when .proto, buf.*, or query.sql files change.
The IPC protocol is defined in proto/tilbo/ipc/v1/ipc.proto. Changes to this file require regenerating bindings for both Go and JavaScript:
mise run generate-proto # Go + vtprotobuf → internal/ipc/gen/
mise run generate-js # protobufjs → internal/quickshell/services/qml_ipc.mjsBoth generated outputs must be committed alongside proto changes.
Go bindings use buf (buf.gen.yaml) with two plugins:
protoc-gen-go— standard Go protobufprotoc-gen-go-vtproto— vtprotobuf for fast marshal/unmarshal/size
JS bindings use a three-step pipeline:
pbjsgenerates a static ES6 modulepbtsgenerates TypeScript declarationsesbuildbundles into a single ESM file for QML import
SQL queries in internal/index/query.sql are compiled to type-safe Go by sqlc:
mise run generate-sqlc # Output: internal/index/dbgen/The sqlc config (internal/index/sqlc.yaml) targets SQLite and emits JSON tags. Generated code lives in internal/index/dbgen/ — do not edit these files manually.
SQL migrations live in internal/index/migrations/ and are numbered sequentially (0007_description.sql). The daemon applies them in order on startup. After adding a migration, update query.sql if new tables or columns are involved, then run mise run generate-sqlc.
The project uses golangci-lint v2.11.3 with a strict configuration (.golangci.yml). Key rules to be aware of:
Maximum 120 characters, enforced by golines. Run mise run format to auto-fix.
Imports must be grouped as: stdlib, third-party, then github.com/darkliquid/tilbo. Enforced by goimports.
| Banned | Use instead | Scope |
|---|---|---|
github.com/golang/protobuf |
google.golang.org/protobuf |
All files |
math/rand |
math/rand/v2 |
Non-test files |
log |
log/slog |
Non-main files |
All nolint comments must specify the linter name and include an explanation, except for funlen, gocognit, and golines which are allowed without explanation.
Files under cmd/ are exempt from forbidigo (fmt.Print is legitimate terminal output), gochecknoglobals (Cobra requires global vars), and gochecknoinits (Cobra uses init() for wiring).
mise run lint # Check for issues
mise run fixup # Auto-fix what's possible (go fix + golangci-lint --fix)
mise run format # Auto-format (goimports + golines)mise run test # All unit tests
go test -v -run TestSpecificFunction ./internal/pkg/... # Single test
go test -v -count=1 ./internal/index/... # Skip test cacheIntegration tests live in test/integration/ and run inside a privileged Docker container with real fanotify, FUSE, and loopback-device capabilities. They test the full daemon lifecycle across multiple filesystem types (ext4, btrfs, vfat, tmpfs).
mise run integration-test # Standard Docker
mise run local-integration-test # Colima Docker contextIntegration tests use testcontainers-go to manage the container lifecycle. The test suite:
- Builds the unified
tilbobinary from source - Creates loop-mounted ext4, btrfs, vfat, and tmpfs volumes
- Starts a daemon instance and runs CLI + FUSE tests against it
Environment variables for local-integration-test:
TILBO_TEST_WATCHER— watcher backend override (fanotifyorinotify, default from env)TILBO_TEST_TMPDIR— temp directory for test artifacts
The linter allows white-box testing (same package name) for core internal packages: builtin, fuse, graph, harvester, index, ipc, main, rules, sidecar, sync, watcher, xattr. Other packages should use _test package names.
- Create a file in
internal/harvester/builtin/following the existing pattern - Implement the harvester interface (receives file path + existing metadata, returns metadata map)
- Register it in the harvester registry with a priority and MIME/glob filter
- The daemon's
builtins.gowires built-in harvesters at startup
Create a TOML file in ~/.config/tilbo/harvesters/:
[harvester]
name = "my-harvester"
command = ["/path/to/binary"]
mime_filter = ["video/*"]
priority = 50
timeout_ms = 5000
async = trueThe binary receives JSON on stdin ({"path": "...", "mime": "...", "existing": {...}}) and writes a JSON metadata map to stdout. Exit 0 to merge results; non-zero to skip.
Same TOML config but with a .wasm path in the command field. WASI modules have stdio only — no filesystem or network access.
- Define request/response messages in
proto/tilbo/ipc/v1/ipc.proto - Add the new types to the
RequestandResponseoneofblocks - Run
mise run generate-protoandmise run generate-js - Implement the handler in
internal/daemon/handlers.go(orbrowser_handlers.gofor browser-specific methods) - Add the CLI command in
cmd/tilbo/cmd_<domain>.go - If the GUI needs it, add the RPC call in
internal/quickshell/services/TilboDaemon.qml - Commit the proto file and all generated outputs together
TOML rule condition operators are defined in internal/rules/toml.go. To add a new operator:
- Add the operator case to the condition evaluation logic in
toml.go - Document it in the README under "Condition operators"
- Add test coverage in the rules package tests
cmd/
tilbo/ Unified CLI binary (Cobra commands + `tilbo daemon`)
internal/
bookmarks/ GTK bookmarks integration for virtual tag roots
browser/ Browser operations interface for FUSE/UI
config/ Configuration file parsing
daemon/ Importable daemon command/runtime package
desktopfile/ .desktop file parsing for "Open With" support
extension/ Extension/plugin mechanism for custom file actions
fsutil/ Filesystem utilities
fuse/ FUSE mount implementation (tag-based virtual FS)
graph/ Tag relationship graph for related-file queries
harvester/ Metadata extraction pipeline + builtin extractors
icontheme/ XDG icon theme detection
index/ SQLite index (FTS5 + sqlite-vec), migrations, sqlc queries
ipc/ JSON-RPC client/server over Unix sockets
quickshell/ QML GUI (components, services, windows)
rules/ Tag rule engine (TOML declarative + Lua scripted)
sidecar/ Fallback metadata storage for non-xattr filesystems
sync/ Background filesystem-to-index synchronisation
thumbnail/ Image thumbnail generation
trash/ XDG trash/recycle bin integration
vectorize/ ONNX embedding model integration for semantic search
watcher/ Filesystem monitoring (fanotify + inotify fallback)
xattr/ Extended attribute read/write for tag storage
proto/
tilbo/ipc/v1/ Protobuf IPC definitions
test/
integration/ Docker-based integration tests
contrib/
scripts/ Build and install helper scripts
systemd/ systemd service unit file