personal assistant telegram chatbot + HTTP API
  • Rust 96.3%
  • Nix 2.5%
  • Shell 0.6%
  • TypeScript 0.4%
  • Just 0.1%
Find a file
Wei Wen Goh c623634b91
Some checks failed
CI / check (push) Successful in 2m49s
CI / publish (push) Failing after 1m43s
refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams
2026-08-13 13:38:57 +08:00
.forgejo/workflows ci: build and publish the evie image to the Forgejo registry on main (#88) 2026-08-12 18:02:54 +08:00
.pi/workflows fix: loop implementation until tests pass 2026-07-12 17:49:51 +08:00
deploy/docker refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
docs refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
nix refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
skills refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
src refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
tests refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
.gitignore refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
AGENTS.md refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
Cargo.lock refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
Cargo.toml refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
CLAUDE.md docs: add design docs and initial project setup 2026-07-03 22:54:07 +08:00
CONTEXT.md refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
flake.lock build: migrate from rustPlatform.buildRustPackage to crane for incremental nix builds 2026-08-12 23:42:56 +08:00
flake.nix refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
Justfile refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
README.md refactor: simplify-code-base run a7b8fa: 52 iterations of folding duplicated decisions onto single seams 2026-08-13 13:38:57 +08:00
SYSTEM_PROMPT.md refactor: clean up with gnhf 2026-07-24 16:40:31 +08:00

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 pi session runs under bwrap with a blank home, and the mount set is the whole boundary; what a user reaches beyond it is their granted Capabilities (skills) plus their own read_paths/write_paths
  • Runtime authorization: users.toml registry 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 same evie binary 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
  • pi binary 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