- Rust 96.3%
- Nix 2.5%
- Shell 0.6%
- TypeScript 0.4%
- Just 0.1%
| .forgejo/workflows | ||
| .pi/workflows | ||
| deploy/docker | ||
| docs | ||
| nix | ||
| skills | ||
| src | ||
| tests | ||
| .gitignore | ||
| AGENTS.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| CONTEXT.md | ||
| flake.lock | ||
| flake.nix | ||
| Justfile | ||
| README.md | ||
| SYSTEM_PROMPT.md | ||
Evie
A multi-user, isolated-workspace agent host. Every authorized person is a Vouched User with their own always-sandboxed pi workspace, reachable over Telegram and an HTTP API that share session state. There is no shared notes directory and no shared filesystem: cross-user conversation is carried by a per-room Message Log injected into each turn as attributed, untrusted context. Authorization lives in a single explicit registry (users.toml) that admins manage at runtime, with a deliberate, visibly-marked escape hatch (Sudo Mode) for full-access host tasks.
See CONTEXT.md for the full domain model and docs/adr/0023-multi-user-isolated-workspaces.md for the design rationale.
Features
- Isolated workspaces: every non-sudo
pisession runs underbwrapwith a blank home, and the mount set is the whole boundary; what a user reaches beyond it is their granted Capabilities (skills) plus their ownread_paths/write_paths - Runtime authorization:
users.tomlregistry seeded from one bootstrap admin, then managed live via/vouch,/grant,/revoke,/unvouch - Dual interfaces: Telegram bot and HTTP API, independently enabled, sharing session state
- Per-sender / per-topic sessions: two senders in one group get two independent, isolated processes; Telegram forum topics are separate sessions
- Message Log: per-room sqlite history injected into each turn as attributed, untrusted block quotes, hard-capped against prompt/cost bombs
- Sudo Mode: admin-only, unsandboxed escape hatch (
/sudo), marked with a ⚠️ prefix on every reply - Control Plane: local Unix-socket verbs (
send,prompt,query,run) plus schedules, id-locked per session; the sameeviebinary is both daemon and CLI - Scheduled pushes: cron / one-shot schedules that fire delivery verbs into a chat
- Configurable rendering: MarkdownV2 (default) or opt-in GitHub-Flavored Rich Markdown (Bot API 10.1); streaming edit/draft/none modes
- Voice transcription (optional): local Parakeet ASR via
sherpa-onnx+ffmpeg, no GPU or Python
Architecture
┌──────────────────────────────┐
Telegram ─────────────▶│ Daemon │
│ (evie daemon) │
HTTP API ─────────────▶│ ┌────────────────────────┐ │
│ │ Session Manager │ │ per (Sender, Chat, Topic, Sudo)
evie <verb> (CLI) ────▶│ │ spawn_for_key ───────┼──┼──▶ bwrap ── pi (RPC) [blank home]
via Control Plane │ └────────────────────────┘ │ │
│ Registry (users.toml) │ └─ Scoped Control Socket
│ Message Log (messages.db) │
│ Schedule store │
└──────────────────────────────┘
Quick Start
Prerequisites
- Rust toolchain
pibinary in PATH (from github.com/lukasl-dev/pi.nix)bubblewrap(bwrap) in PATH — confines every non-sudo session- Telegram bot token (optional, if using the Telegram interface)
Installation
# Using Nix (recommended — wraps evie's PATH with pi, bubblewrap, ffmpeg, sherpa-onnx)
nix build
nix run -- daemon
# Or with cargo
cargo build --release
./target/release/evie daemon
The Nix flake also exposes nixosModules.default (a system service running as root, because a /sudo session is unsandboxed and every per-user host bind is opened with the daemon's own uid) and homeManagerModules.default (a systemd --user service). Both install a secret-free config.toml and export StateDirectory=evie; secrets are supplied at runtime via environmentFile.
Configuration
Config is read-only and lives at /etc/evie/config.toml by default. If missing, evie daemon writes a commented default there when the path is writable; the packaged systemd unit keeps /etc read-only (ProtectSystem=full), so a missing config is a startup error naming the file. Override with --config. All mutable state resolves under the State Root (homes/, users.toml, schedules.json, messages.db, sessions/, skills/, pi/, auth.json), never beside the config: state_dir config → $STATE_DIRECTORY (systemd's StateDirectory=evie, i.e. /var/lib/evie) → $HOME/.evie (dev fallback) → /var/lib/evie.
# Telegram id seeded as the first admin on first boot, when users.toml is absent/empty.
# Thereafter users.toml is the sole source of identity; manage users at runtime with
# /vouch, /grant, /revoke, /unvouch. May be supplied via $EVIE_BOOTSTRAP_ADMIN_USER_ID.
# bootstrap_admin_user_id = 123456789
# Root of all mutable state. Leave unset for the packaged systemd service.
# state_dir = "/var/lib/evie"
[telegram]
enabled = true
bot_token = "" # from @BotFather; or $EVIE_TELEGRAM_BOT_TOKEN
markdown_format = "markdown_v2" # or "rich_markdown" (Bot API 10.1, opt-in)
streaming_mode = "edit" # or "draft" / "none"
split_messages = false
send_retries = 2
# [telegram.webhook] # switch from long polling to a webhook
# url = "https://evie.example.com/telegram"
# listen = "127.0.0.1:8443"
# secret_token = "" # or $EVIE_TELEGRAM_WEBHOOK_SECRET_TOKEN
# [telegram.voice] # local voice-note transcription (off by default)
# enabled = false
[http]
enabled = false
host = "127.0.0.1"
port = 3000
api_key = "" # bearer token; or $EVIE_HTTP_API_KEY
[pi]
binary_path = "pi"
provider = "" # e.g. "anthropic"
model = "" # e.g. "claude-sonnet-4"
system_prompt = """...""" # inline literal, or a path to a .sh/.py script run at spawn
response_idle_timeout_secs = 60
# session_dir = "/var/lib/evie/sessions" # override <state_dir>/sessions
# extensions = ["pi-exa"] # npm packages evie installs into its own store
# bwrap_path = "bwrap"
# sandbox_user/sandbox_group removed — all sandboxes run as the daemon's own uid
# capability_env removed: a sandbox inherits nothing from the daemon env. Set an integration
# secret in that user's `env` table in users.toml instead.
[session]
idle_timeout_secs = 3600 # idle timeout before killing pi processes
[prompt]
datetime_header = true # prepend current local datetime to the system prompt
markdown_footer = true # append the Telegram markdown syntax guide
[debug]
expose_errors = false
expose_thinking = false
Running
evie daemon # start the daemon (default config path)
evie daemon --config /path/config.toml # custom config
Usage
Managing users
Only a Vouched User may interact; every other sender is silently dropped. A freshly vouched user holds no capabilities. Admins manage the registry over Telegram:
/vouch @user [caps…]— add a user (default: no capabilities)/grant @user <caps…>//revoke @user <caps…>— mutate capabilities/unvouch @user— remove the entry and kill their live sessions
Capabilities are flat grant strings: admin and skills/<name>. extensions/<name> and network/local_network were withdrawn (extensions are global, and every sandbox reaches the network unconditionally) and are now rejected at parse time rather than silently ignored.
Telegram
Message the bot with anything; non-command text goes to pi. In a group, a vouched user is served only when addressed (@-mention or reply-to-evie); DMs are always addressed. Commands: /clear (reset the topic's session), /help, /start, the admin verbs above, and /sudo / /unsudo (Sudo Mode, admin-only).
HTTP API
Send a message (runs an api- session, returns the reply):
curl -X POST http://localhost:3000/api/chat/message \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"chat_id": "alice", "message": "What is on my agenda?"}'
Clear and status:
curl -X POST http://localhost:3000/api/chat/clear/alice -H "Authorization: Bearer $API_KEY"
curl http://localhost:3000/api/chat/status/alice -H "Authorization: Bearer $API_KEY"
Text Injection (/api/telegram/say) delivers out-of-band text into the sole admin's live Telegram DM: evie posts it verbatim as a blockquote, then replies in that live session (shared context). Returns 202 immediately, or 200 {ok, response} with sync: true. Requires Telegram enabled with exactly one admin. See docs/adr/0022-http-text-injection.md.
curl -X POST http://localhost:3000/api/telegram/say \
-H "Authorization: Bearer $API_KEY" -H "Content-Type: application/json" \
-d '{"text": "remind me to buy milk"}'
Control Plane (CLI)
A bare evie <verb> connects to the running daemon over the Control Plane socket, issues one verb, and exits. Delivery verbs: send (verbatim text, no pi), prompt (run through pi, deliver to chat), query (run through pi, return to caller), run (execute a local script). Schedule management: schedule, list, cancel.
evie send --chat tg-123 "deploy finished"
evie prompt --chat tg-123 "summarize today's messages"
evie schedule --cron "0 8 * * *" --chat tg-123 -- prompt "Give me a morning digest."
From inside a sandboxed session the socket is a Scoped Control Socket: every verb is id-locked to the owning (Sender, Chat, Topic), so a user's agent can only act on its own session. Sudo Mode and daemon/scheduled paths use the unscoped socket, which retains cross-user targeting. See docs/adr/0006-daemon-cli-control-plane.md.
Extensions and Skills
The two are granted differently: extensions are global, skills are per user.
Extensions are npm packages evie installs and keeps updated in its own store (under <state_dir>/pi), independent of any user's ~/.pi. Every session receives every entry in [pi].extensions (there is no per-user extension grant), each passed as --extension <store>/node_modules/<package>, with the store bind-mounted read-only as a single mount so each package's npm-hoisted sibling deps resolve too. Restart evie after changing extensions to reinstall the store.
Skills live under <state_dir>/skills (derived, not configurable); a skills/<name> capability binds <state_dir>/skills/<name> and passes --skill. Evie also ships two bundled skills of its own: schedule is injected into every session, while the evie control skill (which drives the run verb) is injected only on the Sudo path. The single provider credential bound read-only into every sandbox is likewise always <state_dir>/auth.json.
Development
cargo test # run tests
cargo test -- --ignored # includes the bwrap-denial integration test (needs bubblewrap)
nix develop # dev shell: Rust, pi, rust-analyzer, cargo-watch
Test fixtures live under tests/fixtures/.
Project Structure
src/
├── main.rs # CLI (clap), daemon startup, State Root wiring, schedule firing
├── config.rs # Config + serde, validation, defaults, State Root resolution
├── chat_id.rs # ChatId, Sender, TopicId, SessionKey
├── state_file.rs # Atomic (scratch + rename) write or link-install of a state file
├── users.rs # Vouched User registry, Capability enum (users.toml)
├── message_log.rs # Per-room sqlite Message Log
├── session.rs # SessionManager, per-key spawning, idle cleanup
├── pi.rs # pi argv + bwrap argv, RPC process spawning
├── control_plane.rs # Unix-socket verbs, scoped/unscoped sockets, id-locking
├── schedule.rs # Schedule store
├── http.rs # Axum routes, bearer auth, Text Injection
├── prompt_script.rs # System-prompt / prompt script resolution
├── transcribe.rs # Voice-note transcription
└── telegram/
├── mod.rs # Message handler, routing/authorization seam, delivery pipeline
├── admin.rs # Admin verb parsing, sudo-vs-sandboxed slot selection
├── markdown.rs # MarkdownV2 / Rich Markdown pagination
└── view.rs # On-screen message reconciliation
CONTEXT.md # Domain model and architecture reference
docs/adr/ # Architecture decision records
License
MIT