Rose #
A local agent that watches Fastmail traffic and turns repeated decisions into deterministic rules, so the LLM is invoked as little as possible over time.
The idea #
Most emails don't need a language model to handle correctly — they need a rule. Rose's job is to notice the pattern, let you approve it once, and never ask the LLM again for that kind of email.
┌──────────────────────┐
Fastmail (JMAP) ──▶│ plugin match? │──yes──▶ extract fields ──▶ call a module (deterministic,
│ (regex, no LLM) │ no LLM: HTTP request built from
└─────────┬────────────┘ typed fields per a TOML-defined
│ no module spec, e.g. YNAB)
▼
┌──────────────────────┐
│ LLM triage │──notify──▶ Discord thread ──▶ you reply ──▶ LLM converse
│ (no_op | notify │ │ │
│ only — no mutating │ ▼ ▼
│ action possible) │ one-off action "make this a rule"
└─────────┬────────────┘ (fires once) → author + test + save
│ no_op a new plugin
▼
silently dismissed
Three ways an email gets handled, in order of how much they cost:
- A plugin matches. Regex extracts fields, a module (a declarative TOML file describing an HTTP API, not hand-written Rust) makes the call. Zero LLM calls. This is the steady state you build toward.
- Nothing matches, but it's obviously notify-worthy or obviously not. One
LLM call decides
no_opornotify— and that's architecturally the only thing it can decide here: the schema the LLM answers into has no mutating option, so "let it through" can never accidentally become "send a payment." - You have a conversation about it. Reply in the Discord thread Rose created. She can take a one-off action, or — if you say "make this a rule" — author a new plugin, test it against the actual email that triggered the conversation, and only save it if the test passes.
A plugin that starts failing (email format changed, etc.) doesn't just error silently: Rose asks the LLM for a fix, verifies it against every existing fixture and the failing case, and only auto-applies it if the fix can't mutate anything external. A fix that touches money waits for you.
Architecture #
| Crate | Role |
|---|---|
rose-core |
Pure domain types (Email, Action, TriageDecision, …). No I/O. |
rose-plugins |
Plugin manifest schema, regex/Rhai extraction, fixture-based tester. |
rose-modules |
Generic HTTP module system — new integrations are TOML, not Rust. |
rose-secrets |
SecretStore trait. FileSecretStore (age-encrypted, portable) by default; KeychainSecretStore opt-in on macOS. |
rose-jmap |
Fastmail JMAP client (session, polling, backlog queries). |
rose-discord |
Discord bot: notifications, buttons, thread conversations. |
rose-llm |
Anthropic client — every call uses forced tool-use for structured, schema-validated output. |
rose-store |
SQLite state: seen emails, thread↔email map, plugin enable state, conversation history. |
rose-daemon |
Orchestrator tying all of the above together, plus self-heal. |
rose-cli |
The rose binary. |
Plugins and modules are runtime data, not source: defaults.rs embeds the
shipped examples (plugins/power-bill, modules/ynab.toml) into the binary at
compile time, and rose init materializes them under the user's data directory.
A packaged install never reads from the source tree at runtime — everything
under plugins/ and modules/ at the repo root exists purely as the canonical,
tested copy that gets embedded and as a development convenience.
Setup #
1. Toolchain #
rustup toolchain install 1.96.0 # matches rust-toolchain.toml
If your cc doesn't resolve to a toolchain with libiconv available (this can
happen with certain Nix setups where the ambient C compiler wrapper points at
an SDK missing that library), .cargo/config.toml pins the linker explicitly
— check it if you hit a library not found for -liconv error; it's a no-op
otherwise.
2. Run rose init #
cargo run -p rose-cli -- init
This creates Rose's data directory (XDG-conventional: ~/.local/share/rose
on Linux, ~/Library/Application Support/rose on macOS — override
with ROSE_DATA_DIR, or it's picked up automatically from systemd's
STATE_DIRECTORY under a packaged service), sets up the secret store
(generating an encryption identity on first use), and prompts for the four
credentials below — only for whichever ones aren't already set, so it's
safe to rerun after rotating a token. It does not install any plugins
or modules — a fresh install starts with none; see "Authoring a plugin"
below.
You'll need, before running it:
Fastmail API token — Settings → Privacy & Security → New API token, scoped to Mail only.
Discord bot:
- https://discord.com/developers/applications → New Application.
- Bot tab → Add Bot → copy the token.
- Bot tab → enable Message Content Intent (Rose reads thread replies).
- OAuth2 → URL Generator → scope
bot, permissions: Send Messages, Create Public Threads, Read Message History. Open the generated URL to invite it to your server. - Right-click the channel you want Rose to post in → Copy Channel ID (enable Developer Mode in Discord settings first if you don't see this).
Anthropic API key.
rose init writes everything to <data dir>/rose.env (0600 permissions).
3. Run it #
rose init prints the exact rose.env path it wrote (it lives under whatever
rose init reported as the data directory). Source it, then run any subcommand:
set -a; source /path/from/rose-init/output/rose.env; set +a
cargo run -p rose-cli -- test # run every plugin's fixtures — start here
cargo run -p rose-cli -- run # the live daemon
cargo run -p rose-cli -- backlog # one-shot paced backlog scan (good for cron)
Optional env vars: ROSE_DATA_DIR (overrides the whole resolved data
directory), ROSE_PLUGINS_DIR / ROSE_MODULES_DIR / ROSE_STORE_PATH /
ROSE_IDENTITY_PATH / ROSE_SECRETS_PATH (override individual paths under
it), ROSE_SECRET_BACKEND (file — default — or keychain, macOS-only),
ROSE_BACKLOG_BATCH_SIZE (default 5 — how many emails one backlog scan surfaces
at once; deliberately paced, not a dump).
Running as a service #
See packaging/ for a systemd unit and a NixOS module (nixosModules.default
in flake.nix). Both run rose init once interactively to generate the
secret-store identity and collect credentials, then the service itself just
runs rose run under StateDirectory=rose — the daemon needs no path
configuration under a properly configured unit, since it picks up systemd's
STATE_DIRECTORY automatically.
Authoring a plugin #
Want a worked example first? cargo run -p rose-cli -- plugin example power-bill
installs the reference power-bill plugin (and its YNAB module dependency) —
not something rose init does automatically, since a fresh install
shouldn't silently contain a plugin you didn't write. Run plugin example
with no name to see what's available.
To start from scratch:
cargo run -p rose-cli -- plugin new my-plugin
Writes <plugins dir>/my-plugin/manifest.toml, disabled by default. Fill in:
[match]— ANDed conditions (from_domain,subject_contains,subject_regex,body_contains,body_regex). At least one is required.[extract]— regex fields with a named transform (text,integer,dollars_to_milliunits_expense/_income, three date formats), or a sandboxed Rhai script for shapes regex can't capture.[action]—no_op,notify_discord, orcall_module(references a module + operation defined undermodules/).
Add fixtures under <plugins dir>/my-plugin/fixtures/*.toml (see
plugins/power-bill/fixtures/ for the shape — both positive matches and
negative "should NOT match" cases matter). Then:
cargo run -p rose-cli -- test
A plugin only becomes trustworthy once its fixtures pass — this is also exactly the mechanism self-heal uses to verify a proposed fix before applying it.
Adding an integration #
New external API → a new modules/<name>.toml, not a new crate. See
modules/ynab.toml: base URL, auth (currently bearer_token referencing
a secret-store entry, or none), and named operations (HTTP method, path
template, required/optional typed fields, JSON body template). The executor's
only capability is "send the HTTP request this operation declares with these
typed fields substituted in" — it can't do anything a module definition doesn't
explicitly describe.
- Rust 98%
- Nix 2%