Pyra — Developer Guide
What Is This
Pyra is a personal AI assistant CLI combining a multi-provider AI chat interface with
a plugin/integration system (Stage 2+) and an encrypted vault (Stage 3+).
Current Status
Stage 3 — Memory Database: complete (2026-05-18)
Stage 6 — Daemon infrastructure: in progress (feat/daemon branch)
Next: Stage 4 — Vault Encryption (skipped for now); messaging bots (Stage 6 remainder)
Project Roadmap
Stage 1 — Core CLI ✅ COMPLETE
Working pyra executable with provider setup wizard, streaming chat REPL, .md-based
memory in ~/.pyra/memory/, and hard security boundaries around the vault.
Stage 2 — Plugin Framework ✅ COMPLETE
src/pyra/plugins/ package: base.py, loader.py, registry.py, executor.py, install.py
src/pyra/bundled_plugins/ — ships bundled plugin scripts with pyra
src/pyra/daemon/ stub (CLI surface only; daemon itself is Stage 6)
- Config:
PluginConfig + DaemonConfig added to PyraConfig
- Bootstrap:
~/.pyra/plugins/ and ~/.pyra/logs/ created on startup
- Chat session: AI tool-use loop (up to 10 iterations), approval gate, plugin slash commands
- CLI:
pyra plugin list/install/enable/disable/setup, pyra daemon * (stubs at Stage 2; implemented in Stage 6)
Stage 3 — Memory Database ✅ COMPLETE
src/pyra/memory/database.py: SQLite + FTS5 via memory_meta + memory_fts tables
memory_meta columns: path, category, size_bytes, modified, summary, keywords, embedding BLOB (reserved for Stage 8)
list_memories() queries DB; lookup_memories() uses FTS5 with JSON-index fallback
write_memory() / append_memory() upsert to DB on every write
bootstrap() calls init_db() + migrate_from_files() (one-shot migration of existing .md files)
.md files remain the canonical store; DB is the search index
Stage 4 — Vault Encryption
Encrypt ~/.pyra/vault/secrets/ using age (or GPG fallback). Pyra decrypts in memory
at call time only — no plaintext ever written to disk after initial setup. Secret
rotation support. Per-key passphrases optional.
Stage 5 — Skills System
YAML-defined multi-plugin workflows with event triggers and AI-driven selection.
Skills compose existing plugin tools into automated pipelines with conditional branching
and human-in-the-loop decision points.
Stage 6 — Daemon + Messaging Bots
Always-on asyncio daemon, IPC socket, launchd/systemd service. Bundled bots:
matrix_bot, telegram_bot, signal_bot. Sender allowlist, bcrypt passphrase
challenge, rate limiting (20 msg/hr), injection scanning on all incoming messages,
tool approval over messaging (2-min timeout).
Stage 7 — Security Audit Sub-agent
pyra security audit — sandboxed agent scanning for prompt injection in memory files,
unexpected vault access in security.log, outdated CVEs, permission drift on ~/.pyra/.
Report written to ~/.pyra/security_audit.md (not AI-readable during normal chat).
Stage 8 — Web UI / Advanced Features
Optional local web interface (FastAPI + HTMX or similar). Embedding-based memory search
via sqlite-vec. Multi-profile support (work vs personal).
Plugin Catalog (not stage-gated — ships when ready)
Plugins are developed independently on plugin/<name> branches and merged to main
only when complete. All integrations are standalone Python plugin scripts in
~/.pyra/plugins/ — not hardcoded in src/pyra/. Plugin credentials are stored in
the vault under namespaced keys (plugin:{name}:{key}).
| Plugin |
Branch |
Status |
nextcloud |
plugin/nextcloud |
planned |
email |
plugin/email |
planned |
websearch |
plugin/websearch |
planned |
headless_browser |
plugin/headless_browser |
planned |
server_manager |
plugin/server_manager |
planned |
matrix_bot |
plugin/matrix_bot |
planned |
telegram_bot |
plugin/telegram_bot |
planned |
signal_bot |
plugin/signal_bot |
planned |
ssh_tool |
plugin/ssh_tool |
planned |
docker_tool |
plugin/docker_tool |
planned |
gdrive |
plugin/gdrive |
planned |
onedrive |
plugin/onedrive |
planned |
dropbox_tool |
plugin/dropbox_tool |
planned |
Architecture
Source: src/pyra/
| Module |
Purpose |
cli.py |
Click entrypoint. Subcommands: setup, chat, memory, plugin, daemon |
setup/providers.py |
Provider registry — pure data, no I/O |
setup/wizard.py |
questionary-based interactive setup wizard |
config/schema.py |
Pydantic v2 models — PyraConfig, GeneralConfig, PluginConfig, DaemonConfig; plugin_settings dict |
config/tui.py |
textual-based /config TUI — ConfigApp, GENERAL_FIELDS, launch_config_tui() |
config/manager.py |
ruamel.yaml round-trip config read/write, chmod 600 enforced |
config/dirs.py |
bootstrap() — creates ~/.pyra/ tree, checks vault sentinel every startup |
chat/session.py |
prompt_toolkit REPL loop, AI tool-use loop, plugin slash commands |
chat/planner.py |
TaskPlanner — multi-step plan approval loop, per-step AI execution and verification |
chat/renderer.py |
Streaming + non-streaming markdown via rich, injection warning panel |
chat/history.py |
Conversation list, token budget trimming, tool message support |
memory/database.py |
SQLite+FTS5 — init_db(), upsert(), remove(), search(), list_all(), migrate_from_files() |
memory/reader.py |
list_memories() (DB-backed), read_memory(), lookup_memories() (FTS5), load_context_for_session() |
memory/writer.py |
write_memory(), append_memory() — writes file + upserts to DB |
memory/index.py |
Auto-regenerate MEMORY_INDEX.md + memory_index.json on every write |
vault/reader.py |
get_key(key) — sole accessor of vault/secrets/api_keys.json |
vault/writer.py |
set_key(), delete_key() — only called from setup wizard + plugin setup |
security/boundaries.py |
assert_safe_path(), check_vault_lock(), BLOCKED_PREFIXES |
security/injection.py |
scan_response() — 15 regex patterns, 4 categories, logs to security.log |
utils/paths.py |
pyra_home(), ensure_dir(), safe_chmod(), expand() |
plugins/base.py |
Tool dataclass, PyraPlugin Protocol, BasePlugin helper class |
plugins/loader.py |
Discovers + loads plugins via importlib; failures isolated per plugin |
plugins/registry.py |
Singleton: aggregates tools, slash commands, system prompt additions |
plugins/executor.py |
Approval gate: scan args → prompt → execute → scan result → log |
plugins/install.py |
Copies bundled plugins to ~/.pyra/plugins/ |
bundled_plugins/ |
Standalone plugin scripts shipped with pyra (installed on demand) |
daemon/pid.py |
Atomic PID file — write, read, stale detection (POSIX + Windows), context manager |
daemon/ipc.py |
IPC transport — Unix socket chmod 600 + UID-check (Linux/macOS) or TCP loopback + port file (Windows); newline-delimited JSON protocol |
daemon/service.py |
OS service file generation + install/uninstall — launchd plist (macOS), systemd user unit (Linux), schtasks XML (Windows) |
daemon/core.py |
asyncio event loop entry point, PluginSupervisor (per-task restart, max 10×, 5s back-off, reload), IPC command dispatch, signal handling |
daemon/__init__.py |
Public daemon API exports |
Runtime: ~/.pyra/
Plugin Credential Naming Convention
Plugin credentials live in the vault under namespaced keys:
Examples: plugin:nextcloud:password, plugin:matrix_bot:access_token
The vault's get_key() / set_key() accept any string — the namespace is enforced
by convention in each plugin's setup() method.
Writing a Plugin
- Create
~/.pyra/plugins/<name>/manifest.json:
- Create
~/.pyra/plugins/<name>/plugin.py exporting get_plugin() -> BasePlugin:
pyra plugin enable <name>
Plugin rules:
- Never import from
pyra.vault directly — use the vault_reader/vault_writer callables
- All write/destructive tools must set
requires_approval=True
- Return strings from tool handlers (truncated to 4000 chars by executor)
- Implement
config_fields() for any user-adjustable settings beyond credentials.
Return a list of ConfigField objects — the /config TUI renders them automatically
and saves values to config.yaml under plugin_settings["<name>"].
Plugins that need no configuration can omit this method (base no-op is used).
Security Rules (never break these)
- Never pass config file contents into a system prompt — config may reveal provider/model
- Never bypass
assert_safe_path() — not even in tests (use tmp_pyra_home fixture instead)
- Always
chmod 600/400 after writing any file in ~/.pyra/
- No shell execution from AI-generated text — plugins use explicit approval gates
vault/reader.py and vault/writer.py are the only modules that may open api_keys.json
- API key retrieved inline at call time — never stored as an instance variable or logged
- Tool arguments and results are always injection-scanned before being used or returned to AI
- Plugin directories are validated with
assert_safe_path() before loading (symlink protection)
- Messaging bot security: sender allowlist + bcrypt passphrase + rate limiting (Stage 2.4)
Adding a New Provider
Edit src/pyra/setup/providers.py. Add a new Provider dataclass entry with all required fields.
litellm handles dispatch automatically via the litellm_prefix field.
Add a test in tests/unit/test_providers.py to verify the new entry.
Installing for Development
Running Tests
Commit Convention
Workflow Rules
Testing
- Write tests for every new feature. A feature without tests is incomplete — do not commit without them.
- New tests go in
tests/unit/ for pure-logic helpers and tests/security/ for security-boundary code.
- All existing tests must continue to pass — run
pytest tests/ -v before committing.
- Test pure functions directly; do not test interactive I/O (questionary, Rich output) — only test the logic helpers those flows call.
- For Rich output, capture side effects by monkeypatching
console.print rather than using capsys.
Bugfixes
- Stay under 50 lines changed. Find the root cause and fix it directly.
- If the fix seems to require more than 50 lines, it is probably a refactor, not a bugfix — stop and discuss with the user before proceeding.
- Do not write workarounds, fallback layers, or compatibility shims to route around a bug. Remove the cause.
Committing Changes
- Commit after every logical unit of work — do not batch unrelated changes into one commit and do not wait until the end of a session.
- One commit per concern. If a session touches a file for two different reasons (e.g. a bugfix and a cleanup), those are two separate commits — staged and committed independently, even if the file is the same.
- Use the project commit convention:
feat(module):, fix(module):, test:, docs:, chore: followed by a short description.
- Always
git add only the files relevant to that commit — never git add . blindly.
- Always push after committing — every commit goes to the remote Gitea repository immediately.
Git Worktrees — Required for All Branch Work
Never switch branches in the main working directory. Always use a git worktree so that
multiple sessions (plugins, features, bugfixes) can run in parallel without interfering with
each other or with main.
Each worktree is a full checkout at a separate path. Work on it exactly like the main repo —
commit, push, run tests — without touching the main worktree.
Rules:
- The main working directory (
/Users/nik/Documents/Progamming/pyra) always stays on main.
- Do not run
git checkout <branch> in the main directory — create a worktree instead.
- When a Claude Code session is asked to work on a branch, it must create (or reuse) a worktree
for that branch before making any changes.
Plugin Branches
- Every plugin is developed on its own branch:
plugin/<name> (e.g. plugin/nextcloud), in its
own worktree (e.g. ../pyra-plugin-nextcloud).
- A plugin branch is never merged to
main until the plugin is complete and tested.
main always contains only production-ready core source code (src/pyra/ framework).
- If plugin work uncovers a bug in core Pyra code, fix it on a dedicated
fix/... branch
off main (in its own worktree), merge to main, push, then rebase the plugin branch.
- Plugin branches may be pushed to remote for backup/review at any time.
- Do not merge plugin branches to
main prematurely — a half-working plugin on main
is worse than one that isn't there yet.
Avoid Duplication — Check the Inventory First
Before writing any new utility function, class, or import block, check the Code Inventory section below. Everything listed there already exists and is importable. Writing a duplicate wastes code and introduces divergence.
Code Inventory
Third-party libraries (pyproject.toml dependencies)
| Library |
Min version |
Used in |
Purpose |
litellm |
1.40.0 |
chat/session.py, setup/wizard.py |
Multi-provider LLM completion (streaming + non-streaming) and tool-use dispatch |
rich |
13.0.0 |
chat/renderer.py, cli.py, setup/wizard.py, plugins/executor.py |
Terminal UI — Console, Panel, Markdown, Live, Text |
click |
8.1.0 |
cli.py |
CLI entrypoint, @click.group, @click.command, arguments |
prompt_toolkit |
3.0.0 |
chat/session.py |
REPL input loop — PromptSession, FileHistory |
questionary |
2.0.0 |
setup/wizard.py |
Interactive select / text / password prompts |
ruamel.yaml |
0.18.0 |
config/manager.py |
Round-trip YAML read/write (preserves comments and formatting) |
pydantic |
2.0.0 |
config/schema.py |
Config validation via BaseModel |
httpx |
0.27.0 |
setup/wizard.py |
HTTP GET for local-server connectivity checks |
textual |
1.0.0 |
config/tui.py |
Full-screen TUI framework — tabs, inputs, switches, data tables for /config |
Optional plugin extras (declared in pyproject.toml [project.optional-dependencies]):
| Extra |
Libraries |
Intended for |
nextcloud |
caldav, webdav4, vobject |
CalDAV / CardDAV / WebDAV |
matrix |
matrix-nio, aiofiles |
Matrix bot |
telegram |
python-telegram-bot |
Telegram bot |
ssh |
paramiko |
SSH plugin |
docker |
docker |
Docker plugin |
gdrive |
google-api-python-client, google-auth-oauthlib |
Google Drive |
onedrive |
msal |
OneDrive device-flow auth |
dropbox |
dropbox |
Dropbox |
Standard library modules in use
| Module |
Used in |
Notes |
pathlib.Path |
everywhere |
Default for all paths — never use os.path string joins |
os |
utils/paths.py |
Only for os.name (Windows guard) |
json |
vault/reader.py, vault/writer.py, plugins/loader.py, plugins/executor.py, plugins/install.py |
Vault file, manifests, tool args/results |
re |
security/injection.py |
Compiled injection-detection patterns |
datetime |
security/injection.py, memory/reader.py, memory/index.py, plugins/loader.py, plugins/executor.py |
Log timestamps, file mtimes |
dataclasses |
security/injection.py, memory/reader.py, plugins/base.py |
@dataclass — InjectionWarning, MemoryFile, Tool |
importlib.util |
plugins/loader.py |
Dynamic plugin loading (spec_from_file_location) |
sys |
cli.py, plugins/loader.py |
sys.exit, sys.modules for dynamic module registration |
shutil |
plugins/install.py |
copytree, rmtree for bundled plugin installation |
typing |
plugins/base.py, chat/history.py, plugins/registry.py |
Protocol, Callable, Coroutine, Any, TYPE_CHECKING |
Internal utility functions — import, do not rewrite
utils.paths
| Function |
Signature |
Purpose |
pyra_home |
() -> Path |
Returns ~/.pyra/ |
ensure_dir |
(path: Path, mode=0o700) -> Path |
mkdir -p + chmod in one call |
safe_chmod |
(path: Path, mode: int) -> None |
chmod that silently skips on Windows |
security.boundaries
| Function |
Signature |
Purpose |
assert_safe_path |
(path: Path) -> None |
Raises VaultAccessError if path resolves into vault |
check_vault_lock |
() -> None |
Raises PyraSecurityError if vault sentinel is missing |
Exceptions: VaultAccessError(PermissionError), PyraSecurityError(RuntimeError)
security.injection
| Function |
Signature |
Purpose |
scan_response |
(text: str) -> list[InjectionWarning] |
Runs 15 compiled regex patterns, logs hits to security.log |
redact_api_keys |
(text: str) -> str |
Replaces key-shaped strings with [REDACTED] |
Dataclass: InjectionWarning(pattern_label: str, matched_text: str)
config.manager
| Function |
Signature |
Purpose |
load_config |
() -> PyraConfig |
Reads config.yaml, validates via Pydantic; raises FileNotFoundError if missing |
save_config |
(cfg: PyraConfig) -> None |
Writes config.yaml, enforces chmod 600 |
config_exists |
() -> bool |
True if config.yaml exists |
config_path |
() -> Path |
Absolute path to config.yaml |
config.tui
| Symbol |
Purpose |
launch_config_tui |
() -> None — opens the full-screen configuration TUI; blocks until user presses q/Escape |
GENERAL_FIELDS |
List of _CoreField entries — the single place to add new core settings to the General tab |
config.dirs
| Function |
Signature |
Purpose |
bootstrap |
() -> None |
Creates ~/.pyra/ directory tree and checks vault sentinel; called at every startup |
vault.reader / vault.writer
| Function |
Module |
Signature |
Purpose |
get_key |
vault.reader |
(provider_id: str) -> str | None |
Sole vault reader — never call open(api_keys.json) anywhere else |
set_key |
vault.writer |
(provider_id: str, api_key: str) -> None |
Stores or overwrites a key in the vault |
delete_key |
vault.writer |
(provider_id: str) -> bool |
Removes a key; returns True if it existed |
memory.database
| Function |
Signature |
Purpose |
init_db |
() -> None |
Creates memory.db with memory_meta + memory_fts tables; chmod 600 |
upsert |
(path, *, content, category, size_bytes, modified, summary, keywords) -> None |
Insert or replace one entry in both tables |
remove |
(path: str) -> None |
Delete entry from both tables |
search |
(query: str, limit: int = 20) -> list[dict] |
FTS5 MATCH search; returns [{file, summary, keywords, snippet}] |
list_all |
() -> list[dict] |
All rows from memory_meta ordered by path |
migrate_from_files |
() -> None |
One-shot: populate DB from existing .md files if DB is empty |
memory.reader
| Function |
Signature |
Purpose |
list_memories |
() -> list[MemoryFile] |
Queries DB (memory_meta); falls back to file scan if DB empty |
read_memory |
(name: str) -> str |
Reads memory file by relative path; validates against vault/traversal |
lookup_memories |
(query: str) -> list[dict] |
FTS5 full-text search; falls back to JSON index substring search |
load_context_for_session |
() -> str |
Concatenates all memory files into a system-prompt block |
Dataclass: MemoryFile(name, path, category, size_bytes, modified)
memory.writer
| Function |
Signature |
Purpose |
write_memory |
(name: str, content: str, summary: str, keywords: list[str]) -> Path |
Creates/overwrites a memory .md file, updates index and DB |
append_memory |
(name: str, content: str) -> Path |
Appends to a memory file (creates if missing), updates index and DB |
memory.index
| Function |
Signature |
Purpose |
update_index |
() -> None |
Regenerates MEMORY_INDEX.md and memory_index.json — called automatically by writer functions |
setup.providers
| Symbol |
Kind |
Purpose |
PROVIDERS |
list[Provider] |
All registered providers in display order |
PROVIDERS_BY_ID |
dict[str, Provider] |
Fast id lookup |
get_provider |
(provider_id: str) -> Provider |
Raises KeyError for unknown ids |
Provider |
frozen dataclass |
id, display_name, requires_key, default_model, litellm_prefix, base_url, key_env_var, connectivity_check, group |
plugins.loader
| Function |
Signature |
Purpose |
load_plugins |
(plugins_dir: Path) -> list[PyraPlugin] |
Discovers all valid plugin directories |
load_plugin_by_name |
(name: str, plugins_dir: Path) -> PyraPlugin | None |
Loads a single plugin; returns None on any failure |
plugins.install
| Function |
Signature |
Purpose |
get_bundled_plugins_dir |
() -> Path |
Path to src/pyra/bundled_plugins/ |
install_bundled_plugin |
(name, bundled_dir, plugins_dir) -> None |
Copies bundled plugin dir to ~/.pyra/plugins/, sets permissions |
list_bundled_plugins |
(bundled_dir: Path) -> list[str] |
Names of all bundled plugins that have a manifest.json |
read_manifest |
(plugin_dir: Path) -> dict |
Reads manifest.json; returns {} if missing |
daemon.core
| Function |
Signature |
Purpose |
run_foreground |
() -> None |
Entry point for pyra daemon run — loads config + plugins, writes PID file, runs asyncio loop |
start_background |
() -> None |
Spawns pyra daemon run as a detached subprocess (start_new_session on POSIX, DETACHED_PROCESS on Windows) |
daemon.pid
| Function |
Signature |
Purpose |
resolve_pid_path |
(cfg_path: str) -> Path |
Expand ~ and resolve to absolute Path |
daemon.ipc
| Function |
Signature |
Purpose |
send_command |
(address, msg, timeout=5.0) -> IpcResponse |
Synchronous CLI helper — asyncio.run(IpcClient.send(...)) |
get_socket_path |
(cfg: str) -> Path |
Expand ~ and return Unix socket path |
is_unix_socket |
() -> bool |
True on Linux/macOS (sys.platform != 'nt') |
get_port_file_path |
() -> Path |
Path to ~/.pyra/daemon.port (Windows TCP port file) |
daemon.service
| Function |
Signature |
Purpose |
detect_platform |
() -> Literal["macos","linux","windows"] |
Detect current OS |
find_pyra_executable |
() -> str |
shutil.which("pyra") → sibling fallback → sys.executable -m pyra |
install_service |
() -> None |
Generate + register OS service (reads config for log/pid paths) |
uninstall_service |
() -> None |
Deregister OS service |
render_launchd_plist |
(exe, log_file, pid_file) -> str |
macOS plist template |
render_systemd_unit |
(exe, log_file) -> str |
Linux systemd unit template |
render_schtasks_xml |
(exe) -> str |
Windows Task Scheduler XML template (write as UTF-16) |
chat.renderer — rendering functions and shared console
Import console from here; do not create a second rich.Console() in new code.
| Symbol |
Purpose |
console |
Module-level rich.Console — the single shared terminal instance |
render_streaming_response(stream) |
Renders a litellm streaming response with Live + Markdown, returns final text |
render_text_response(text) |
Renders a complete string as Markdown |
render_injection_warning(warnings) |
Yellow Panel showing detected pattern labels |
render_error(message) |
Red Panel |
render_info(message) |
Dim plain text line |
render_system(message) |
Cyan Panel |
Internal classes
| Class |
Module |
Notes |
PyraConfig |
config.schema |
Top-level config; fields: ai, general, memory, security, plugins, daemon, plugin_settings |
GeneralConfig |
config.schema |
general: block — user_name, assistant_name |
ProviderConfig |
config.schema |
ai: block — provider_id, model, base_url |
PluginConfig |
config.schema |
plugins: block — enabled, require_approval, log_executions |
DaemonConfig |
config.schema |
daemon: block — enabled, socket_path, log_file, pid_file, ipc_port |
MemoryConfig |
config.schema |
memory: block — max_tokens_in_context, auto_load |
SecurityConfig |
config.schema |
security: block — injection_detection, log_injections |
ConversationHistory |
chat.history |
Holds message list; builds API payload via build_for_api(); trims to token budget |
PluginRegistry |
plugins.registry |
Singleton (instance() / reset()); aggregates tools, slash commands, system prompt additions |
ToolExecutor |
plugins.executor |
Approval gate + injection scan + logging; call via execute() or execute_tool_call_batch() |
ConfigField |
plugins.base |
Dataclass — declares one plugin config option (key, label, type, default, options, description); returned by config_fields() |
Tool |
plugins.base |
Dataclass — name, description, parameters (JSON Schema), handler, requires_approval |
PyraPlugin |
plugins.base |
@runtime_checkable Protocol — the plugin interface |
BasePlugin |
plugins.base |
Concrete base with no-op defaults; plugins should inherit this |
TaskPlanner |
chat.planner |
Multi-step plan runner; make_tool_handler() returns the callable wired into the chat session; presents plan for user approval, executes each step via litellm with up to 5 tool-use iterations, verifies output before proceeding |
PluginSupervisor |
daemon.core |
asyncio supervisor — add_task(name, factory), start(), stop(), reload(), status(); restarts crashed tasks up to 10× with 5s back-off |
PidFile |
daemon.pid |
write() (atomic), read(), is_stale(), remove(), context manager; PidFileError(OSError) raised when live PID already exists |