Avi - System Documentation
A personal agent system that runs on any machine. The agent shares the host's terminal, filesystem, browser, and apps, operating like a human user. Everything is extensible through plugins.
Core Principles
- Single Agent - one agent per org (named "avi" by default). No multi-agent support. Configured at the org level, operates across all projects.
- Zero infrastructure - embedded database (SQLite via better-sqlite3), no Docker, no external services.
- Agent uses real apps - no sandboxes. The agent runs as a native process with access to the host machine's terminal, filesystem, and apps.
- Database as config - agent, projects, plugins, and instructions live in the embedded database. No config files on disk. The web UI manages everything.
- Plugin = extension - all external capabilities are plugins. Channels handle messaging; MCP servers handle tools. Any existing MCP server can be wrapped with zero code.
- Multi-model - Vercel AI SDK (latest) for provider abstraction. Anthropic, OpenAI, Google, AWS Bedrock - swap models per org in Org Settings → AI.
- Project isolation - nested project tree with scoped data access. The agent sees the target project and all its children, never parents or siblings.
- Remote UI - running the web frontend on a different machine than the core. The UI is a pure viewer - all execution (tools, agent, plugins, MCP, database) stays on the core host.
Glossary
| Term | Definition |
|---|---|
| Orchestrator | The single Node.js process that manages plugins, the agent, projects, and routing. |
| Plugin | The extension unit. One plugin = one service. Can provide channels (bidirectional conversations), an MCP server (tools), or both. Receives a narrow PluginAPI (env vars, version, log, store, addTool, addChannel, addMcp, onDisconnect) - no access to the agent, projects, or other plugins. |
| Agent | The single AI worker per org (named "avi" by default) with a system prompt. Stateless between invocations. Configured at config.agent (singular). The chat model is an org-level setting on the orgs table (ai_model_chat). |
| Project | A domain boundary with its own directory, instructions, tool/skill permissions, and isolated data in the database. Projects can be nested - children inherit data visibility from parents but not channels. |
| Channel | A bidirectional conversation on a plugin's service, bound to one project via the channels table (no inheritance). Identified by {plugin}:{type}:{id} (e.g., slack:channels:acme-team). Native channels use project:{projectId}:user:{userId}. |
| Tool | A function the agent can call, exposed by a plugin's MCP server or inline tools. For actions (terminal, APIs), not for messaging - messaging goes through channels. |
| Skill | An org-scoped, named prompt template stored in the skills table. Agents can discover, read, create, and update skills - persistent knowledge. |
Architecture
Orchestrator
│
├── Plugins (loaded once at startup, one plugin = one service)
│ ├── PluginAPI - env vars, version, log, scoped store
│ ├── MCP Server - wraps existing MCP server (command + args) and/or inline tools
│ ├── Channels - bidirectional conversations on a service, bound to projects
│ └── Lifecycle - onDisconnect hooks
│
├── Projects (nested tree - data access flows down, channels do not)
│ └── general/ ← default project, always exists
│ ├── personal/
│ │ ├── work/
│ │ │ ├── acme-corp/
│ │ │ │ ├── Directory, Database (scoped)
│ │ │ │ ├── Channels: slack:channels:acme-team (via channels table)
│ │ │ │ └── Tasks: scheduled jobs (via tasks table)
│ │ │ └── side-project/
│ │ └── family/
│ └── ...
│
│ Data access rule: the agent invoked for a project can read/write
│ that project AND all its children. Cannot see parents or siblings.
│
├── Tool Registry (all tools registered at boot, permission-gated per project)
│ ├── System tools: system_skills-*, system_tasks-*, system_notes-*, system_project-*, etc.
│ └── Plugin tools: chrome_page, computer_file-read, github_pr-list, etc.
│
└── Agent (one per org, "avi" by default - invoked per message, scoped to a project)
├── Data access: scoped DB for this project + all children
├── Context: channel summary + recent messages
├── Model: Vercel AI SDK (any provider)
├── Response: automatically sent back to the originating channel
│
├── All permitted tools loaded directly into streamText as native AI SDK tools.
│ No wrapper/dispatch layer. Tool names use underscores (provider-safe).
│
└── Tool Permissions: per-project (via project_tool_permissions table)
Tools are filtered by permissions before invocation.
Only permitted tools are passed to streamText.
Flow: Agent → streamText(tools) → tool.execute()
(tools pre-filtered by permissions at invocation time)
MCP servers are spawned at plugin load time via stdio (@ai-sdk/mcp). After plugin.setup() completes, the system spawns each registered MCP server using createMCPClient + Experimental_StdioMCPTransport, discovers tools via mcpClient.tools(), and bridges them as regular PluginToolDefinition entries. MCP tools are prefixed with the plugin name using underscores (e.g., github_create_issue) and are indistinguishable from inline plugin tools at runtime. Tool calls have a 30s timeout. Spawn failures warn and skip - other plugins continue. Code: src/mcp/index.ts.
System Config
All configuration lives in the database - no config files on disk. The system scaffolds only a ~/.avi/data/ directory and a server_secret file (auto-generated).
AI settings are org-scoped — each org has its own provider keys, chat model, embedding model, and summarization model. These are stored directly on the orgs table: ai_model_chat, ai_model_summarization, ai_model_embedding (plain text model IDs), and ai_provider_keys (AES-256-GCM encrypted JSON blob containing all provider API keys). AI settings are managed via Org Settings → AI tab. The system blocks access until at least one provider key, a chat model, an embedding model, and a summarization model are configured (setup screen shown after registration).
Provider registry is centralized in config/models.ts. Supported providers: Anthropic, OpenAI, Google, AWS Bedrock. Each provider declares a secretKeys array (e.g. provider.anthropic, provider.awsBedrock.accessKeyId). A provider is considered "available" for an org only when all its secretKeys are present in that org's ai_provider_keys. The Org Settings → AI tab renders input fields dynamically from secretKeys.
AI API endpoints are org-scoped under /api/v1/accounts/:accountId/orgs/:orgId/ai/...:
GET/PUT .../ai— read/write model selections (chatModel, embeddingModel, summarizationModel)GET .../ai/models— list providers with availability and modelsGET/PUT/DELETE .../ai/keys[/:key]— manage provider API keys (masked on read)GET .../ai/embeddings/count,POST .../ai/embeddings/reembed— embedding management
The agent, projects, and plugins are stored in database tables and managed through:
- Web UI - settings pages with instructions editor
- HTTP API - versioned at
/api/v1/, with hierarchical URL scoping:/api/v1/accounts/:accountId/orgs/:orgId/...
Pagination
All list endpoints return a consistent paginated envelope:
{ "data": [...], "total": 42, "limit": 50, "offset": 0 }
Query params: ?limit=N&offset=N. Defaults: limit=50, offset=0, max limit 1000.
Startup Sequence
- Scaffold home directory (
~/.avi/data/) if not present - Initialize SQLite database + run migrations (schema only, no data seeding)
- Generate server secret for JWT signing + secret encryption if not present (
~/.avi/server_secret) - Register system tool metadata in the tool registry
- Start web UI + HTTP API on configured port
- Start task scheduler
- First request: user registers → account + org + defaults seeded → setup modal shown
- Subsequent requests: auth middleware resolves user + org → org runtime loaded on demand (config, plugins, tools)
All names (plugin, agent, project) are lowercase alphanumeric + hyphens only: /^[a-z0-9][a-z0-9-]*$/.
Logging
All core modules log through createLogger(source) from src/utils/index.ts. Output goes to console.error by default (visible in dev terminals). Embedders (e.g. Electron) can call setLogSink(sink) before start() to route core logs to a custom destination (file, structured collector, etc.). The sink receives (level, source, message, data?) — the same fields as the formatted console output but without ANSI escapes. The packaged desktop app routes core logs to ~/Library/Application Support/Avi/logs/main.log, while npm run dev uses ~/Library/Application Support/Avi Dev/logs/main.log.
Remote Core
The desktop app can run as a thin client, connecting to an Avi core on another machine (e.g. a Mac Mini or home server) instead of starting its own embedded core. When connected, the Electron window loads directly from the remote core's URL — the entire UI is served from the remote, so same-origin WebSocket and relative API URLs work automatically with zero frontend changes. All agents, tools, plugins, and data stay on the remote host.
Config file: ~/.avi/avi.json (or $AVI_HOME/avi.json if AVI_HOME is set). The ~/.avi/ directory is created automatically if it doesn't exist. Read/written via IPC through the preload bridge (window.desktop.getRemoteConfig, window.desktop.setRemoteConfig).
Two config blocks:
remoteHost — for the machine running the core (e.g. Mac Mini). Binds to 0.0.0.0 so other devices can connect.
{ "remoteHost": { "enabled": true } }
remoteClient — for the machine running only the GUI (e.g. MacBook). Skips the local core, connects to the remote.
{ "remoteClient": { "enabled": true, "url": "http://100.64.1.5:4200" } }
Setup:
- Host machine: Add
remoteHost.enabled: trueto~/.avi/avi.json, restart Avi. The Remote tab in Admin Tools will show "Remote Core: Host Mode Enabled". - Client machine: Open Admin Tools → Remote tab, enter the host URL, click Connect. Or add
remoteClientto~/.avi/avi.jsondirectly. - Disconnect: Via Admin Tools → Remote tab or the system tray menu ("Disconnect from Remote Core"). Clears
remoteClient, starts a local core.
UI indicators: A badge in the bottom-left sidebar shows the current mode ("Remote Core: Client Mode" or "Remote Core: Host Mode") when either mode is active. The Admin Tools → Remote tab shows the current state and connection details.
Networking: Tailscale recommended for secure access — provides encrypted WireGuard connectivity between devices without exposing ports to the internet. Works across any network. Plain LAN also works (same network only, unencrypted).
Health check: GET /api/v1/health — unauthenticated endpoint returning { ok: true, version }. Used by the desktop app to verify the remote core is reachable before loading.
Startup: On launch, the desktop app reads ~/.avi/avi.json. If remoteClient is enabled, it skips the embedded core and loads the remote URL. If unreachable, shows retry/local/quit options. If remoteHost is enabled, the local core binds to 0.0.0.0 instead of 127.0.0.1.
Secure context: The remote UI is served over plain HTTP (not HTTPS). Browser APIs that require a secure context (e.g. crypto.randomUUID) are not available. The frontend uses a uuid() helper (packages/web/src/lib/uuid.ts) with a fallback for insecure contexts.
Data Architecture
SQLite (via better-sqlite3). Single database file (~/.avi/data/core.db). All data is scoped by org_id. No config files on disk. Most tables use created_at/updated_at timestamps. Tables that omit updated_at: accounts, orgs, account_members, org_members, messages.
The query() function from client.ts accepts Postgres-style $1, $2 placeholders and converts them to SQLite ? internally. initDb() and closeDb() are synchronous.
Migrations: Schema evolution is managed by numbered migrations in src/models/migrations.ts. A _migrations table tracks which migrations have been applied. On startup, runMigrations() runs any pending migrations in a transaction. To add a schema change: create a new migration function (e.g. migration002), add it to the migrations array with the next sequential ID. Never modify an already-applied migration.
Foreign keys: All org_id references enforce REFERENCES orgs(id) ON DELETE CASCADE. Project-scoped tables cascade from projects(id). Foreign keys are enforced via PRAGMA foreign_keys = ON.
WAL & checkpointing: WAL mode enabled for concurrent read/write performance. Auto-checkpoint at 1000 pages. On graceful shutdown, PRAGMA wal_checkpoint(TRUNCATE) flushes the WAL back into the main DB file.
Backups: Daily automatic backups via VACUUM INTO to ~/.avi/backups/core-{timestamp}.db. Keeps the 10 most recent. Also runs on graceful shutdown. backupDatabase() is exported for manual triggers.
Identity & Multi-tenancy
users
| Column | Type | Description |
|---|---|---|
id | text, PK | |
email | text, NOT NULL, unique | |
name | text, NOT NULL | Full name |
image | text, nullable | Profile picture URL from Google |
google_sub | text, unique, nullable | Google subject ID for OAuth linking |
emailVerified | boolean, NOT NULL, default false | |
createdAt | timestamptz | |
updatedAt | timestamptz |
device_codes
Short-lived codes for device authorization flow (RFC 8628). Schema: avi.
| Column | Type | Description |
|---|---|---|
device_code | text, PK | Random 64-char hex |
user_code | text, unique | 8-char alphanumeric shown to user |
user_id | text, nullable | Set when approved |
status | text | pending, approved, denied |
expires_at | timestamptz | 15-minute TTL |
auth_codes
One-time codes for exchanging Google OAuth callback for JWT. Schema: avi.
| Column | Type | Description |
|---|---|---|
code | text, PK | Random 64-char hex |
user_id | text, NOT NULL | |
expires_at | timestamptz | 2-minute TTL |
accounts
Billing unit. Auto-created personal accounts cannot be deleted.
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
name | text, NOT NULL | Display name |
owner_user_id | text, NOT NULL, FK → users | |
personal | boolean, NOT NULL, default false | True for auto-created accounts |
plan | text, NOT NULL, default 'free' | free or pro |
orgs
Container for all data. Auto-created personal orgs cannot be deleted or renamed. AI configuration is org-scoped — each org has its own model selections and provider API keys.
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
account_id | text, NOT NULL, FK → accounts | |
name | text, NOT NULL, default 'Default' | |
personal | boolean, NOT NULL, default false | |
ai_model_chat | text, nullable | Primary chat model, format provider:model-id |
ai_model_summarization | text, nullable | Summarization model |
ai_model_embedding | text, nullable | Embedding model |
ai_provider_keys | text, NOT NULL, default '{}' | AES-256-GCM encrypted JSON of provider API keys. Never included in general org API responses. |
account_members / org_members
| Column | Type |
|---|---|
account_id / org_id | NOT NULL, FK |
user_id | NOT NULL, FK → users |
role | text, NOT NULL, default 'member': owner, member |
Auth
Authentication uses Google OAuth only with custom JWT sessions. No password auth. No helper libraries (no BetterAuth, no Passport).
Backend (packages/backend):
- Google OAuth: Direct integration with Google OAuth2 endpoints. Code:
src/auth/google.ts - JWT: HMAC-SHA256 tokens, 30-day expiry. Secret via
JWT_SECRETenv var. Payload:{ sub, email, name, iat, exp }. Code:src/auth/jwt.ts - Middleware:
requireAuthvalidates JWT Bearer token. Code:src/middleware/require-auth.ts - User table:
"user"table withgoogle_subcolumn for Google identity linking - Migration: Existing password users are seamlessly migrated on first Google sign-in by matching email addresses
Auth routes (all under /api/v1):
GET /auth/google— Redirects to Google OAuth consent screenGET /auth/google/callback— Handles Google callback, upserts user, creates one-time auth code, redirects to sitePOST /auth/token— Exchanges one-time auth code for JWT (avoids token-in-URL exposure)GET /auth/me— Returns current user from JWTPOST /auth/device/code— Generates device code + user code for device auth flowPOST /auth/device/token— Poll endpoint for device auth (returns JWT once approved)POST /auth/device/approve— Approves a device code (requires auth)
Dashboard routes (all under /api/v1, require auth):
GET /dashboard/usage— Current billing period usage totalGET /dashboard/billing— Current plan (free/pro)POST /dashboard/billing/upgrade— Switch plan
Device Code Flow (RFC 8628): Used by packages/web and packages/desktop to authenticate. The user signs in via packages/site (the only surface with Google OAuth), enters a code, and the device receives a JWT.
Login surface: Only packages/site (Next.js) handles sign-in. packages/web and packages/desktop use the device code flow. There is no local email/password auth anywhere — packages/core no longer has register, login, or password change routes.
Core (packages/core):
- No local auth routes (register/login/password removed)
- Bridge route
POST /api/v1/auth/bridgeremains — exchanges a remote JWT for a local session token - Local JWT signing/verification via
src/auth/token.ts(for bridge sessions) - Remote identity sync via
src/auth/remote.ts(fetches bootstrap from backend, upserts user locally) src/auth/password.tsdeleted — no password hashing in the codebase
Site dashboard (packages/site):
-
/login— Google OAuth redirect -
/auth/callback— Exchanges one-time auth code for JWT, stores in localStorage, redirects to dashboard -
/activate— Device code entry page (preserves code across Google login redirect) -
/dashboard— Usage summary (current period cost) -
/dashboard/billing— Plan management (free/pro) -
Scoped routes:
/api/v1/accounts/:accountId/orgs/:orgId/... -
Global routes:
GET /api/v1/settings,PUT /api/v1/settings/:key -
Signup flow: Google sign-in creates User → Account (personal=true, plan=free) → Org (personal=true) → seeds default agent ("avi") + project + plugins
Core Tables
All org-scoped via org_id.
agents
One agent per org (named "avi" by default). Configured via config.agent (singular object with id and model). The chat model is stored in app_settings as ai.chatModel, not on the agent row.
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
org_id | text, NOT NULL | |
name | text, NOT NULL | Agent name (default: "avi") |
model | text, NOT NULL | Legacy column, no longer used by application code |
instructions | text, nullable | Agent instructions (included in system prompt) |
color | text, nullable | Agent character color (hex) |
| UNIQUE | (org_id, name) |
projects
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
org_id | text, NOT NULL | |
name | text, NOT NULL | Project name (leaf name only, no slashes) |
parent_project_id | text, nullable, FK → projects(id) ON DELETE CASCADE | Parent project for hierarchy |
instructions | text, nullable | Project instructions |
directory | text, nullable | Custom folder path on disk |
| UNIQUE | (org_id, name) WHERE parent_project_id IS NULL | Root-level uniqueness |
| UNIQUE | (org_id, parent_project_id, name) WHERE parent_project_id IS NOT NULL | Child-level uniqueness |
Hierarchy is defined by parent_project_id, not by the name. Slashes in the "New Project" dialog (e.g. work/acme) are a creation convenience — each segment becomes a separate project with the correct parent. Names are freely renamable without affecting hierarchy.
project_tool_permissions
Junction table - one row per permitted tool per project. No wildcards.
| Column | Type | Description |
|---|---|---|
project_id | text, PK, FK → projects(id) ON DELETE CASCADE | |
tool_name | text, PK | Tool name (e.g. computer_file-read, github_pr-list) |
project_skill_permissions
Junction table - one row per permitted skill per project.
| Column | Type | Description |
|---|---|---|
project_id | text, PK, FK → projects(id) ON DELETE CASCADE | |
skill_id | text, PK, FK → skills(id) ON DELETE CASCADE | |
mode | text, NOT NULL, default 'read' | 'read' or 'write' |
channels
External channel bindings - maps a plugin channel ID to a project.
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
project_id | text, NOT NULL, FK → projects(id) ON DELETE CASCADE | |
channel_id | text, NOT NULL | Channel identifier (e.g. slack:channels:acme-team) |
| UNIQUE | (project_id, channel_id) |
channel_instances
Per-project plugin integrations with credentials and config.
- Slack channel instances require project secret refs for
bot_tokenandapp_token, plus rawworkspace_idandchannel_idvalues. - Telegram channel instances require a project secret ref for
bot_token. - Raw secrets are stored in the
secretstable.channel_instances.credentialsstores secret refs and provider IDs, not decrypted secret values.
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
project_id | text, NOT NULL, FK → projects(id) ON DELETE CASCADE | |
plugin_name | text, NOT NULL | Plugin name |
channel_type | text, NOT NULL | Channel type |
label | text, NOT NULL, default '' | Display label |
credentials | jsonb, nullable | Channel config values, typically secret refs plus provider IDs |
created_at | timestamp | |
updated_at | timestamp |
tasks
Scheduled/managed tasks within projects. Supports 2-level hierarchy (parent tasks with subtasks).
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
account_id | text, NOT NULL | |
org_id | text, NOT NULL | |
project_id | text, NOT NULL | FK to projects |
parent_id | text, nullable | FK to tasks (self-ref, max 2 levels) |
name | text, NOT NULL | Task name |
status | text, NOT NULL | incomplete, complete, cancelled, or recurring |
interval_seconds | integer, nullable | Repeat interval in seconds for recurring tasks |
missed_policy | text, NOT NULL, default skip | How to handle missed runs |
model | text, nullable | Model override for this task |
next_run_at | timestamp, nullable | Next scheduled execution (computed as now + interval_seconds) |
last_run_at | timestamp, nullable | Last execution time |
notes | text, nullable | Markdown notes |
due_date | timestamp, nullable | Due date |
notes
Project-scoped markdown documents. Flat structure (no hierarchy).
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
account_id | text, NOT NULL | |
org_id | text, NOT NULL | |
project_id | text, NOT NULL | FK to projects |
title | text, NOT NULL | Note title |
content | text, NOT NULL, default '' | Markdown content |
skills
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
org_id | text, NOT NULL | |
name | text, NOT NULL | Skill name (lowercase alphanumeric + hyphens) |
description | text, NOT NULL, default '' | One-liner used for discovery |
body | text, NOT NULL, default '' | Markdown instructions; supports $ARGUMENTS substitution |
| UNIQUE | (org_id, name) |
contacts
Org-scoped (shared across all projects).
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
account_id | text, NOT NULL | |
org_id | text, NOT NULL | |
name | text, NOT NULL | Display name |
email | text, nullable | |
phone | text, nullable | |
company | text, nullable | |
title | text, nullable | Job title |
website | text, nullable | |
address | text, nullable | Freeform |
avatar_url | text, nullable | |
source | text, nullable | e.g. "manual", "google" |
notes | text, default '' | Markdown notes |
metadata | text (JSON), nullable | Custom fields |
contact_tags
| Column | Type |
|---|---|
contact_id | text, FK → contacts(id) ON DELETE CASCADE |
tag | text, NOT NULL |
| PK | (contact_id, tag) |
events
General-purpose event log. The type field follows a dotted convention: entity.action (e.g. update, webhook.received).
| Column | Type | Description |
|---|---|---|
id | text, PK, UUID | |
account_id | text, NOT NULL | |
org_id | text | Optional |
project_id | text | Optional |
task_id | text | Optional |
type | text, NOT NULL | Event type |
data | jsonb, NOT NULL, default '{}' | Arbitrary JSON payload |
created_at | timestamp |
messages
| Column | Type | Description |
|---|---|---|
id | text, PK | |
seq | bigint, NOT NULL | Auto-incrementing sequence for ordering |
org_id | text, NOT NULL | |
channel_id | text, NOT NULL | Channel identifier |
project_id | text, NOT NULL | |
role | text, NOT NULL | Message role |
parts | jsonb, NOT NULL, default '[]' | Message parts (Vercel AI SDK format) |
metadata | jsonb, nullable | |
archived | integer, NOT NULL, default 0 | 1 = archived (excluded from history and summaries) |
channel_summaries
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
org_id | text, NOT NULL | |
channel_id | text, NOT NULL | |
summary | text, NOT NULL | Rolling summary |
last_message_id | text, FK → messages, nullable | |
token_count | integer, NOT NULL, default 0 | |
| UNIQUE | (org_id, channel_id) |
plugins
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
org_id | text, NOT NULL | |
name | text, NOT NULL | Plugin name |
env | jsonb, NOT NULL, default '[]' | Array of env var names |
enabled | boolean, NOT NULL, default true | |
| UNIQUE | (org_id, name) |
plugin_store
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
org_id | text, NOT NULL, FK → orgs(id) CASCADE | |
plugin_id | text, NOT NULL | Plugin name (not UUID) |
project_id | text, nullable | NULL for global data, project ID for project-scoped |
key | text, NOT NULL | |
value | json text, NOT NULL | |
| UNIQUE | (org_id, plugin_id, project_id, key) WHERE project_id IS NOT NULL | |
| UNIQUE | (org_id, plugin_id, key) WHERE project_id IS NULL |
secrets
Encrypted secrets (AES-256-GCM). Scoped by project_id - NULL for system-level secrets.
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
project_id | text, nullable, FK → projects(id) ON DELETE CASCADE | NULL for system scope |
key | text, NOT NULL | |
value | text, NOT NULL | Encrypted |
description | text, NOT NULL, default '' | |
| UNIQUE | (key) WHERE project_id IS NULL | System-level |
| UNIQUE | (project_id, key) WHERE project_id IS NOT NULL | Project-level |
embeddings
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
org_id | text, NOT NULL | |
type | text, NOT NULL | tool, note, contact, document |
scope | text, NOT NULL | Plugin name, note ID, or contact ID |
content | text, NOT NULL | Embedded text |
metadata | jsonb, nullable | |
| UNIQUE | (org_id, type, scope, content) |
Vectors are stored in a separate vectors.db file via sqlite-vec, not in the main embeddings table. When no embedding model is configured, the system falls back to LIKE text search. Supported embedding providers: OpenAI (text-embedding-3-small, text-embedding-3-large) and AWS Bedrock (cohere.embed-english-v3 — 1024 dimensions). When switching embedding models, the Org Settings → AI tab has a "Re-embed all data" button that regenerates vectors for all stored content using the new model (old vectors are dropped since dimensions may differ).
Vector resilience: If sqlite-vec loads but the vec0 module is unavailable (observed on some platforms), ensureVecTable catches the error, permanently sets vecDb = null, and logs a one-time WARN. Both writes (storeVectors) and reads (searchEmbeddings) check this flag — when disabled, writes are skipped and searches fall back to text search without generating query embeddings. Embedding input text is truncated to 2000 chars before sending to the model (Cohere embed-english-v3 rejects inputs over 2048 chars).
app_settings
Global settings (non-AI). App UI preferences like app.autoStart, app.startMinimized, app.showDockIcon, app.remoteAccess, app.port. AI model settings have moved to the orgs table (see below).
| Column | Type | Description |
|---|---|---|
key | text, PK | |
value | jsonb, NOT NULL |
usage_daily
Daily token usage rollups per (account, org, project, agent, model). Upserted after each agent invocation.
| Column | Type | Description |
|---|---|---|
id | uuid, PK | |
account_id | text, NOT NULL | |
org_id | text, NOT NULL | |
project_id | text, NOT NULL | |
agent_id | text, NOT NULL | |
model | text, NOT NULL | Full model ID (e.g. anthropic:claude-sonnet-4-6) |
date | date, NOT NULL | Day (UTC) |
input_tokens | bigint, default 0 | |
output_tokens | bigint, default 0 | |
cached_tokens | bigint, default 0 | |
request_count | integer, default 0 | |
cost_micros | bigint, default 0 | Estimated cost in microdollars |
| UNIQUE | (account_id, org_id, project_id, agent_id, model, date) |
Code Architecture
packages/
├── core/ - @avirun/core: backend + API
│ ├── src/
│ │ ├── index.ts - Public API: exports start()
│ │ ├── cli.ts - CLI entry point (arg parsing, signal handlers)
│ │ ├── bin/avi.ts - npx/bin entry point
│ │ ├── auth/
│ │ │ ├── token.ts - JWT sign/verify (HMAC-SHA256)
│ │ │ ├── remote.ts - Remote auth bridge (fetchRemoteBootstrap, sync)
│ │ │ └── middleware.ts - Request auth + org resolution
│ │ ├── orchestrator/
│ │ │ └── index.ts - Core process: loads config, starts plugins, routes messages
│ │ ├── config/
│ │ │ ├── index.ts - loadConfigFromDb()
│ │ │ └── models.ts - Provider registry, model pricing
│ │ ├── router/
│ │ │ └── index.ts - Message routing: channel → project
│ │ ├── agent/
│ │ │ └── index.ts - Agent invocation via Vercel AI SDK
│ │ ├── memory/
│ │ │ └── index.ts - Summary rolling
│ │ ├── tools/
│ │ │ ├── index.ts - Tool registry, permission matching, text search
│ │ │ └── system-tools.ts - All system tool definitions
│ │ ├── mcp/
│ │ │ └── index.ts - MCP server spawning, tool discovery, bridging
│ │ ├── plugins/
│ │ │ ├── index.ts - Plugin discovery, validation, lifecycle
│ │ │ ├── context.ts - PluginAPI implementation
│ │ │ └── web/index.ts - Built-in web UI + HTTP API + WebSocket
│ │ ├── channels/
│ │ │ └── index.ts - Channel message handling, summary management
│ │ ├── scheduler/
│ │ │ └── index.ts - Interval-based task scheduling
│ │ ├── server/
│ │ │ └── index.ts - HTTP server + WebSocket + all API routes
│ │ ├── models/ - Database models (ALL database access goes through here)
│ │ │ ├── client.ts - SQLite instance, migrations, seeding, seedOrgDefaults()
│ │ │ ├── migrations.ts - Schema definitions
│ │ │ ├── user.ts, account.ts, org.ts - Identity
│ │ │ ├── agent.ts, project.ts - Agent and project config
│ │ │ ├── channel-instance.ts - Plugin channel instances
│ │ │ ├── channel-summary.ts - Rolling summaries
│ │ │ ├── message.ts - Message history
│ │ │ ├── task.ts, note.ts, contact.ts - Managed entities
│ │ │ ├── skill.ts - Skill templates
│ │ │ ├── plugin-store.ts - Plugin K/V store
│ │ │ ├── embedding.ts - Vector embeddings (sqlite-vec)
│ │ │ ├── secrets.ts - Encrypted secrets (AES-256-GCM)
│ │ │ ├── settings.ts - App settings (global)
│ │ │ ├── usage.ts - Token/cost tracking
│ │ │ └── event.ts - Event log
│ │ ├── utils/
│ │ │ └── index.ts - Shared utilities (logging, hashing, path helpers)
│ │ └── types/
│ │ └── index.ts - Shared type definitions
│ └── tests/
│
├── web/ - @avirun/web: React frontend (Vite)
│
├── desktop/ - @avirun/desktop: Electron desktop app
│
├── site/ - Landing page (Next.js, static)
│
└── plugins/
├── chrome/ - Chrome browser automation (Puppeteer/CDP)
├── computer/ - File operations, terminal access
├── github/ - GitHub API + git CLI
├── google/ - Gmail, Calendar, Contacts, Drive
├── freshdesk/ - Freshdesk support tickets, contacts, companies
├── hubspot/ - HubSpot CRM sales tools
└── mongodb/ - MongoDB driver wrapper
Rule: No file outside src/models/ may import the database client or execute queries directly.
Routing & Messages
Channel ID Format
- Native channels:
project:{projectId}:user:{userId}- each user gets their own conversation per project - External channels:
{plugin}:{type}:{id}(e.g.,slack:channels:acme-team) - bound to projects via thechannelstable
Routing Flow
- Message arrives (WebSocket or scheduled task)
- Router parses channel ID, performs async DB lookup via
getProjectByChannelId - The single org agent is always used - no agent selection needed
- Result:
{ projectId, channelId }
System Prompt Assembly
When an agent is invoked, the system prompt is assembled in this order:
- Agent instructions - from
agentstable (static, cacheable) - Project instructions - from
projectstable (static, cacheable) - Channel summary - rolling summary of the conversation history
- Context - user name, model name, project files directory, permitted tool/skill names
Recent messages since the last summary are appended as user/assistant conversation turns, not in the system prompt. Prompt caching is enabled via Vercel AI SDK's providerOptions to cache the static prefix.
Concurrency
The orchestrator serializes agent invocations per channel (projectId:channelId) - one invocation at a time for a given conversation. Different channels, including multiple conversations inside the same project, can run concurrently.
Post-response work such as usage logging and rolling summaries runs on a separate per-channel background queue after stream-end, so the next user turn on that conversation does not wait for summary generation before it can start.
Memory & Summarization
Rolling summary system compresses older messages to keep context manageable:
- Threshold: 4000 tokens accumulated after last summary
- Window: Keep 20 most recent messages unsummarized
- Cap: Summary capped at 6000 tokens; older sections merged if exceeded
- Stored in
channel_summarieswithlast_message_idcheckpoint - Model context: the agent loads only messages after
last_message_idand receives older context via## Conversation Summaryin the system prompt - UI history: the full
UIMessagetranscript remains persisted and is still returned by the paginated/projects/:id/messagesAPI for chat history browsing - Tool-heavy chats: rolling-summary token estimation includes bounded text extracted from tool parts, so large tool-call/tool-result histories still trigger summarization instead of growing forever
- Archived channel fallback: when
loadMessagesPaginatedqueries a specific channel that has 0 active messages (all archived by rolling summary), it falls back to returning the channel summary as a synthetic assistant message with IDsummary:{channelId}. Only fires at offset=0. This prevents task channels that ran and were fully summarized from showing "No messages yet" - Non-fatal: summary generation is wrapped in a top-level try/catch — failures are logged but never surface to the user
- Timeout:
generateTextcalls useAbortControllerwith a 120s timeout to prevent indefinite hangs on oversized context - Truncation: conversation text is capped at 80K chars before sending to the summarization model
- Streaming persistence: the new user message is appended to the database before streaming begins so reconnects can see it; the full transcript (including completed tool calls) is rewritten when the response completes or is aborted. On abort, only completed tool calls with results are persisted — in-flight calls are excluded. The
ActiveRunStore(in-memory,src/active-runs.ts) tracks whether a run is active for the/statusendpoint and archive safety. Reconnecting clients see a "working" spinner via/statusand receive live content via WebSocket; onstream-endthe client refetches canonical history from the DB - Tool output safety net: before persisting UIMessages, the orchestrator caps any tool output part exceeding 100K chars. The truncated output includes a note with the original size. This prevents any single oversized tool result from making the channel's history exceed the model's context window on the next turn. Plugin-level caps (e.g. chrome response trimming) handle known cases; this is the general safety net
- History recovery: at invocation time,
recoverPersistedMessagesvalidates persisted assistant tool parts against AI SDK v6 state requirements —inputis required for all completed states (input-available,approval-requested,approval-responded,output-available,output-error,output-denied),outputforoutput-available, eitheroutputorerrorTextforoutput-error, and per-stateapprovalvalidation (approval-requestedrequires{ id }with noapproved/reason,approval-respondedrequires{ id, approved: boolean },output-deniedrequires{ id, approved: false }). Parts that fail are dropped while surrounding text is preserved. If SDK validation still fails after recovery, an aggressive fallback (stripUnvalidatableToolParts) removes all tool parts whose tool name is not in the current runtime registry. Empty assistant message shells are removed. This ensures one poisoned historical row cannot permanently block the channel
System Tools
All system tools are defined in src/tools/system-tools.ts, registered at boot, and permission-gated per project via project_tool_permissions.
All permitted tools (system + plugin) are loaded directly into streamText as native AI SDK tools. No wrapper/dispatch layer. Tool names use underscores natively — providers reject colons.
Skills
| Tool | Description |
|---|---|
system_skills-search | Search or list available skills (filtered by project permissions) |
system_skills-read | Read a skill's full body. Requires read or write permission |
system_skills-create | Create a new skill. Auto-grants write permission to the current project |
system_skills-update | Update an existing skill. Requires write permission |
Tasks
| Tool | Description |
|---|---|
system_tasks-create | Create a task in the current project |
system_tasks-query | Query tasks for the current project |
system_tasks-search | Search task content |
system_tasks-update | Update a task |
system_tasks-delete | Delete a task |
system_tasks-start | Start working on a task immediately |
Notes
| Tool | Description |
|---|---|
system_notes-create | Create a note with title and optional content |
system_notes-query | List notes (metadata, not full content) |
system_notes-read | Read note content by ID. Supports paginated reads via startLine/endLine (1-indexed, inclusive). Always returns totalLines, totalChars, and truncated |
system_notes-update | Update title/content - supports full replace or surgical patch operations |
system_notes-delete | Delete a note (opt-in, not in defaults) |
system_notes-search | Search across all note content in the project |
Patch operations: notes-update accepts an operations array for surgical edits. Operations: replace, insert_after, insert_before, delete, append, prepend. Heading-scoped operations: replace_section, insert_after_section, delete_section (target sections by heading line, e.g. ## April 2026, with optional occurrence for repeated headings). Line-number operations: replace_lines, insert_at_line, delete_lines (1-indexed; apply in reverse document order when issuing multiple line-number ops to avoid offset drift).
Contacts
| Tool | Description |
|---|---|
system_contacts-create | Create a contact with name and optional fields/tags |
system_contacts-query | Search/filter contacts by name, email, company, tag |
system_contacts-read | Read full contact with tags and notes |
system_contacts-update | Update fields, tags, and/or notes |
system_contacts-delete | Delete contact (opt-in, not in defaults) |
Project
| Tool | Description |
|---|---|
system_project-instructions-update | Read or update the project instructions that guide the agent. Omit instructions to read current value; provide it to replace entirely |
Diagnostics
| Tool | Description |
|---|---|
system_logs-get | Read recent lines from the tail of the Avi log file. Params: lines (1-1000, default 200), offset (skip N lines from the tail to page backward), filter (case-insensitive substring). Resolves log path from AVI_LOG_FILE env, then macOS/Linux platform defaults. Filter+offset draws from a recent tail window, not the entire file — sparse old matches may be missed |
Updates
| Tool | Description |
|---|---|
system_updates-publish | Publish an update event for the current project. Optional taskId |
system_updates-query | Query updates scoped to the current project |
system_org-updates-query | Query updates across all projects in the org |
Plugins
A plugin can provide channels (bidirectional messaging), an MCP server (tools), or both. One plugin = one service.
Plugins declare env vars they need. The system provides them at startup along with a PluginAPI:
interface PluginAPI {
env: Record<string, string>; // only their declared vars
log: PluginLogger; // scoped logger (plugin:{name})
globalStore: PluginStore; // plugin-global storage (not project-scoped)
version: string; // system framework version
addTool(name: string, definition: PluginToolDefinition): void;
addChannel(name: string, handlers: PluginChannelHandlers, options?: PluginChannelOptions): void;
addMcp(name: string, config: McpServerConfig): void;
onDisconnect(fn: () => Promise<void> | void): void;
}
// Channel handlers receive an emit callback for inbound messages:
interface PluginChannelHandlers {
onMessage: (emit: (msg: ChannelEmitMessage) => void) => void;
sendMessage: (id: string, text: string) => Promise<void> | void;
}
// Tool functions receive a project-scoped context:
interface PluginToolContext {
projectId: string;
store: PluginStore; // scoped to current plugin + current project
files: PluginFiles; // scoped filesystem access
secrets: PluginSecrets; // explicit secret resolution API
}
interface PluginSecrets {
getValue(keyName: string): string | null;
requireValue(keyName: string): string;
getOptionalValue(keyName: string): string | undefined;
}
interface PluginFiles {
write(filename: string, data: Buffer): Promise<void>;
read(filename: string): Promise<Buffer>;
list(): Promise<string[]>;
path(filename: string): string; // returns absolute path
}
interface PluginStore {
get(key: string): unknown;
set(key: string, value: unknown): void;
del(key: string): void;
list(prefix: string, options?: { sortBy?, order?, limit?, offset?, filter? }): { data, total, limit, offset };
}
Data isolation: Plugin store data is isolated by both plugin name and project_id. A plugin cannot access another plugin's data. The orchestrator controls scoping - plugins cannot override it. globalStore uses project_id = '' for plugin lifecycle data only.
Plugin files: context.files provides filesystem access at <projectDir>/files/. Plugins share the project's files directory. Paths must be relative (subdirectories allowed) and must not contain .. - prevents directory traversal.
Secret handling: Plugin tools resolve secrets explicitly through context.secrets. The model sees secret key names and descriptions but never secret values. Tools accept secret key names as input parameters and resolve actual values server-side inside execute via context.secrets.requireValue(keyName). No magic substitution — secret use is visible in the tool schema and implementation. Channel runtimes (Slack, Telegram) use api.resolveProjectSecrets() for credential resolution at connection time.
Plugins cannot access other plugins, the database, the agent, or projects. They emit messages and the orchestrator handles routing. A plugin's MCP server can be an existing MCP server (wrapped by command + args), inline tool definitions, or both.
Built-in Plugins
chrome - Chrome browser automation via Puppeteer/CDP. No env vars. Connects to a real headed Chrome (not headless) to avoid bot detection. Env overrides: AVI_BROWSER_CDP_PORT (default 9222), AVI_BROWSER_USER_DATA_DIR (default /tmp/avi-browser). All chrome tools trim responses by default to prevent context window overflow (pruned a11y trees, capped console/network output, screenshots saved to file instead of inline base64). Pass raw: true for full unprocessed output (hard-capped at 25K chars, paginated via offset). Tools:
browser-start— Launch Chrome/Brave/Edge with CDP debugging enabledbrowser-stop— Close the browser and free resourcespage— Navigate, create, list, select, or resize pages. Action:navigate | new | list | select | resizeinteract— Click, fill forms, select dropdowns, type, press keys, scroll, run JS, handle dialogs, upload files, wait. Action:click | fill | select | fill_form | type | press_key | hover | scroll | evaluate | handle_dialog | upload | waitsnapshot— Take screenshots (base64 or save to file), accessibility snapshots, or memory metrics. Action:screenshot | accessibility | memorynetwork— Inspect captured network requests. Action:list | getconsole— Inspect captured console messages. Action:list | getperformance— Profile page performance (tracing, metrics, Lighthouse). Action:start_trace | stop_trace | analyze_insight | lighthouse
computer - File operations and terminal access. No env vars. Has its own database - does not write to the main database. Tools:
file-read,file-write,file-edit,file-delete,file-copy,file-move,file-search(mode="files" for glob, mode="content" for grep) — all restricted to the project directory; paths outside it are rejectedterminal-run(synchronous),terminal-start(background, 5000-line rolling buffer),terminal-list,terminal-read(offset-based pagination),terminal-kill
slack - Slack workspace integration. Channel handler uses Socket Mode (bot token + app token) for real-time bidirectional messaging. Tools use the Slack Web API and work with either user tokens (xoxp-) or bot tokens (xoxb-) — the agent picks the right secret based on context. User tokens act as you; bot tokens act as the bot.
conversations-list— List channels, DMs, and group DMs with unread status. Sorted unread-first. Resolves DM user names.conversations-history— Read recent messages from a channel or DM. Returns messages oldest-first with user names resolved.chat-send— Send a message to a channel or DM. Supports thread replies viathread_ts.
github - GitHub API (Octokit) + git CLI (execFile('git', ...)). Tools:
whoami,repo-list,repo-create,repo-get,repo-deletebranch-create,branch-delete,branch-renamepr-create,pr-list,pr-updateissue-create,issue-list,issue-updateactions-trigger,actions-listrelease-list,release-upsertgit-clone,git-push,git-pull,git-commit,git-status,git-log,git-checkout,git-rebase,git-diff— all restricted to the project directory
google - Multi-service via REST APIs (no Google SDK, all fetch). One OAuth client and one refresh token covers all services. Required secret keys: client_id, client_secret, refresh_token. OAuth 2.0 with automatic token refresh.
- Gmail (14 tools):
gmail-review-inbox,gmail-search,gmail-get-message,gmail-list-threads,gmail-get-thread,gmail-list-labels,gmail-manage-label,gmail-get-attachment,gmail-send,gmail-reply,gmail-forward,gmail-create-draft,gmail-modify-labels,gmail-trashgmail-review-inbox: Thorough paginated inbox review — finds every non-draft, non-sent thread in the mailbox within a precise timespan. Uses Unix timestamps internally (UTC for date-only inputs). Discovers ALL threads viathreads.listwith exhaustive pagination, no caps. Default query: broad discovery (-in:sent -in:drafts -in:spam -in:trash) + sent query for reply detection. Withinclude_spam_trash: true, runs additional targetedin:spamandin:trashqueries (Gmail excludes these by default, so they must be explicitly searched). Thread IDs frozen in an immutable stateless base64url cursor on first call; agent pages withnext_cursoruntilhasMore: false. Ordering: unreplied first, then replied; within each group, Gmail's threads.list order (most recent activity). Review pages are AI-safe by default:page_sizenow defaults to10, message bodies are returned as bounded previews by default,bodyTruncatedmarks previews, and callers should usegmail-get-threadorinclude_full_bodies: trueonly for small pages when full content is required. ReturnstotalThreads(frozen at discovery, stable across pages),unrepliedCount,repliedCount,offset,returnedCount,remainingCount,bodyMode, andbodyCharLimit. Total fetches per call capped atpage_size(retries get priority, remaining budget goes to new threads). Failed thread fetches retried 3x per attempt, carried forward in cursor with cumulative failure count; after 3 cumulative cross-page failures a thread moves topermanentlyFailedand stops retrying.partialFailuresshows threads still being retried (withretryCount),permanentlyFailedshows threads that exhausted retries.threadIdsis never mutated; offset advances by new-thread slice size.page_sizevalidated to 1-200. Snapshot consistency: thread IDs frozen at discovery; thread contents fetched live per page. Concurrency-throttled (15 parallel).gmail-searchandgmail-list-threads: Auto-paginate internally (up to 100). Default max_results bumped to 25.gmail-searchreturns full messages grouped by thread.
- Contacts (6 tools):
contacts-list,contacts-search,contacts-get,contacts-other,contacts-create,contacts-delete - Calendar (6 tools):
calendar-list,calendar-events,calendar-get-event,calendar-create-event,calendar-update-event,calendar-delete-event - Drive (5 tools):
drive-list,drive-search,drive-get,drive-download,drive-upload
hubspot - HubSpot CRM sales tools via REST API v3 (no SDK, all fetch). Auth: either access_token directly, or client_id + client_secret + refresh_token for OAuth 2.0 with automatic token refresh.
- Contacts (5 tools):
contact-list,contact-get,contact-create,contact-update,contact-search - Deals (6 tools):
deal-list,deal-get,deal-create,deal-update,deal-search,pipeline-list - Companies (5 tools):
company-list,company-get,company-create,company-update,company-search - Engagements (4 tools):
note-create,task-create,task-list,engagement-list
freshdesk - Freshdesk support tools via REST API v2 (no SDK, all fetch). Auth: API key via HTTP Basic Auth. Required secrets: FRESHDESK_API_KEY (from Freshdesk → Profile Settings → Your API Key), FRESHDESK_DOMAIN (the subdomain only — e.g. if helpdesk URL is acme.freshdesk.com, set domain to acme). The API always uses {domain}.freshdesk.com, not any custom portal domain. The API key inherits the permissions of the Freshdesk user that generated it; to restrict access, create a dedicated agent with a limited role.
- Tickets (8 tools):
ticket-list,ticket-get,ticket-create,ticket-update,ticket-delete,ticket-search,ticket-reply,ticket-note - Contacts (5 tools):
contact-list,contact-get,contact-create,contact-update,contact-search - Companies (5 tools):
company-list,company-get,company-create,company-update,company-search
mongodb - MongoDB database operations and Atlas cloud management. Two auth modes:
- Database tools (via
MONGODB_URI):list-databases,list-collections,collection-schema,find,count,aggregate,insert,update,delete,list-indexes,create-index - Atlas Admin tools (via
ATLAS_PUBLIC_KEY+ATLAS_PRIVATE_KEY):atlas-list-projects,atlas-list-clusters,atlas-get-cluster,atlas-list-db-users
Web UI
A minimal web app ships with the system on localhost:{port} (default 4200). Messages flow through the same channel system as external plugins. The port is a CLI option (--port 4200).
- Chat interface - send messages, see responses with tool call rendering, view conversation history. On opening a project, the last 30 messages are loaded with infinite scroll for older batches. Real-time WebSocket messages merge with loaded history (deduped by ID). Stream-delta updates are batched via RAF (~15 fps) to avoid per-token re-renders. Auto-scroll is RAF-throttled. The Markdown component is memoized (
React.memo+ hoisted config) to avoid unnecessary re-parsing. Clearing history uses echo-suppression (localClearsRef) so the originating client ignores the server's WS echo; post-clear fetches replace local state while normal fetches merge to preserve live streaming. The orchestrator uses generation-based cancellation (cancelGenerationcounter per channel) so stopping an agent only skips invocations queued before the stop. - Project sidebar - navigate between projects, access settings via gear icon. "Agent Settings" link at bottom.
- Agent settings -
/settings/agent/:tab?. General + Instructions tabs. Edit model and instructions. - Project settings - tabs: General, Instructions, Tools, Skills. Tool and skill permissions managed per-project.
- Org settings - General (name, delete) and Analytics tabs. Analytics shows monthly cost chart and daily usage table, filterable by project.
- Admin tools - Database tab for browsing all tables: view paginated rows, inspect records, delete records.
- Right sidebar - icon rail with Tasks, Notes, and Contacts. Only one open at a time. Resizable (200-600px). Each has list + detail view with real-time WebSocket updates.
- Rich text editor -
InstructionsEditorwraps Milkdown (ProseMirror-based WYSIWYG). Used in project instructions, agent instructions, skill editing, task notes, and note content. - Character rendering - the Three.js character renders at display refresh rate (typically 60 fps) and uses adaptive renderer pixel ratio based on canvas size, with small surfaces allowed to render up to DPR 2-2.25 so the robot stays crisp on HiDPI displays. Project chat explicitly caps the character at DPR 2, while larger hero/profile renders can use the same or higher adaptive budget when device DPR allows. Shadow maps stay cached for normal idle rendering, but fast full-body intro animations refresh shadows every frame so self-shadowing does not visibly detach from the model. The current quality/perf balance keeps
PCFShadowMapbut restores a higher1024shadow-map budget, larger shadow radius, and more blur samples than the earlier low-quality pass, which improves softness without returning to the full pre-optimization2048cost. Animation timing usesTHREE.Timerso the UI avoidsTHREE.Clockdeprecation warnings and does not accumulate a giant delta after the page is hidden.
Tasks REST API
| Method | Endpoint | Description |
|---|---|---|
GET | .../projects/:projectId/tasks?status=&sort=&limit=&offset= | List tasks (paginated, with subtask summaries) |
POST | .../projects/:projectId/tasks | Create task |
GET | .../tasks/:taskId | Get single task |
PUT | .../tasks/:taskId | Update task |
DELETE | .../tasks/:taskId | Delete task (cascades to subtasks) |
Notes REST API
| Method | Endpoint | Description |
|---|---|---|
GET | .../projects/:projectId/notes?search= | List notes (paginated) |
POST | .../projects/:projectId/notes | Create note |
GET | .../notes/:noteId | Read one |
PUT | .../notes/:noteId | Update (supports patch operations) |
DELETE | .../notes/:noteId | Delete |
POST | .../projects/:projectId/notes/search | Search |
Contacts REST API
| Method | Endpoint | Description |
|---|---|---|
GET | .../contacts?search=&tag=&company= | List (paginated) |
POST | .../contacts | Create |
GET | .../contacts/:contactId | Read with tags |
PUT | .../contacts/:contactId | Update |
DELETE | .../contacts/:contactId | Delete |
POST | .../contacts/search | Semantic search |
Channel Archive REST API
| Method | Endpoint | Description |
|---|---|---|
POST | .../projects/:projectId/channels/:channelId/archive | Archive all messages in a web channel and clear its summary. Web channels only (project:*:user:*). |
Events REST API
| Method | Endpoint | Description |
|---|---|---|
GET | .../events?projectId=&taskId=&type=&after=&before=&limit=&offset= | Query events (paginated) |
WebSocket broadcasts on mutations: task-changed, note-changed, contact-changed, config-changed, channel-history-cleared. The WebSocket URL auto-selects ws: or wss: based on window.location.protocol for remote/TLS compatibility.
Secret Management
Secrets are stored encrypted (AES-256-GCM). The encryption key is derived from ~/.avi/server_secret. Secrets are never sent to the client in plaintext — the API returns masked values only.
- Org provider keys: Stored as an encrypted JSON blob in
orgs.ai_provider_keys. Managed via Org Settings → AI tab. At runtime,resolveModel()reads provider keys from the org (with a 1-minute cache). Code:src/models/org.ts. - Project secrets: Stored in the
secretstable scoped byproject_id. Plugin credentials (e.g., Google OAuth tokens), managed via project settings. Code:src/models/secrets.ts.
Plugin env vars are separate from the encrypted secrets store. Plugins declare env var names; the orchestrator reads them from process.env at startup and passes only declared vars into PluginAPI.env.
Configuration
Minimal filesystem footprint:
~/.avi/
├── server_secret - JWT signing + secret encryption key (auto-generated)
├── data/ - SQLite database (core.db)
├── backups/ - Daily database backups
└── accounts/{accountId}/
└── orgs/{orgId}/
└── projects/{projectId}/files/ - project files on disk
Config type: Config = { agent: AgentConfig; projects: Record<string, ProjectConfig> } where AgentConfig = { id, model } and ProjectConfig = { id }.
The AVI_HOME environment variable overrides the default ~/.avi path. Use this to run separate dev/test/prod instances side-by-side.
Development
Running Dev and Production Side-by-Side
Dev and production paths are determined automatically — no manual AVI_HOME override is needed:
- Dev (
npm run dev): Uses~/.avi-dev. The desktop app detects dev mode via!app.isPackagedand the core detects it viaNODE_ENV !== 'production'. The desktop setsprocess.env.AVI_HOMEso the core always agrees. Web on port 5173 (Vite), core on port 4201. Electron renderer storage and logs are isolated under~/Library/Application Support/Avi Dev. - Production (packaged Electron app): Uses
~/.avi. The desktop detects production viaapp.isPackagedand propagatesAVI_HOME=~/.avito the core process viaprocess.env.AVI_HOME. - Tests: Each test creates a temp directory and sets
AVI_HOMEper test. Tests that call real AI APIs readANTHROPIC_API_KEYfrom env and seed it into the secrets store.
Electron
- Spawns core process at startup, embeds React UI via BrowserWindow
- Dev mode: loads from Vite dev server (
http://localhost:5173) and uses a separate ElectronuserDatadirectory (~/Library/Application Support/Avi Dev) - Prod mode: serves built web dist
- Tray icon with menu, single instance lock, graceful shutdown
- CSS note: Tailwind v4's Vite plugin strips
-webkit-app-region. Apply it via inline React styles, not CSS files. - Linux support: electron-builder targets AppImage and deb. Icon sourced from
packages/web/public/app-icon.png. GPU hardware acceleration is auto-disabled on Linux (app.disableHardwareAcceleration()) to avoid blank/black window issues with incomplete GPU drivers.
CI/CD — GitHub Actions
- Workflow:
.github/workflows/release.yml, triggered manually viaworkflow_dispatch - Build matrix: macOS ARM (
macos-14), Linux x64 (ubuntu-latest), Linux ARM (ubuntu-24.04-arm) - Release job: creates a GitHub Release tagged
v{version}using the next version derived from existing release tags and the stable S3 publish path, then attaches all artifacts (.dmg,.AppImage,.deb) - macOS code signing: not configured yet — users must right-click → Open to bypass Gatekeeper
Theme System
All colors are CSS custom properties in index.css, exposed to Tailwind via @theme inline. Both :root (light) and .dark define every variable. Use Tailwind classes (bg-background, text-foreground, border-border) - never hardcode colors.
Key semantic roles: background/foreground, sidebar/sidebar-foreground/sidebar-border, primary/primary-foreground, secondary, muted/muted-foreground, border/input, popover.
Dedicated macOS Machine Setup
Guide for running Avi on a dedicated macOS machine (Mac Mini, Mac Studio, etc.) as a headless agent host. This is the recommended setup for Remote UI mode, where the core runs on the Mac and the UI is accessed from another device.
Initial Setup
- Install Avi — follow the standard installation steps.
- Enable Remote Core — add
{"remoteHost":{"enabled":true}}to~/.avi/avi.jsonand restart. - Network access — use Tailscale for secure remote access. The Remote UI connects to
http://<tailscale-hostname>:4200.
Disable macOS Keychain GUI Prompts
On a headless machine, macOS Keychain dialogs block tool calls indefinitely because there's no one to click "Allow." Run these commands once to prevent all Keychain popups:
# Prevent the security daemon from ever showing GUI dialogs
defaults write com.apple.security PKEnableSecuritydUI -bool false
# Lock the login keychain — access attempts fail immediately instead of prompting
security lock-keychain ~/Library/Keychains/login.keychain-db
# Remove any existing credentials that trigger prompts
security delete-internet-password -s github.com 2>/dev/null
security delete-generic-password -s github.com 2>/dev/null
# Disable git's Keychain integration at all levels
git config --global credential.helper ""
sudo git config --system credential.helper "" 2>/dev/null
Why this matters: Any process that touches the Keychain (git, curl, gh CLI, security frameworks) will pop a dialog on macOS. On a headless machine with no display, this dialog hangs forever and stalls the agent's tool call.
Credential Management
Do not store credentials in the macOS Keychain. Use Avi's built-in project secrets instead:
- Add API keys, tokens, and credentials via Org Settings → AI (for AI providers) or Project Settings → Secrets (for project-specific credentials like GitHub PATs, database URIs).
- Secrets are stored encrypted (AES-256-GCM) in Avi's database — not in the macOS Keychain.
- When the agent runs a tool call, project secrets are automatically resolved and injected as environment variables.
For GitHub specifically:
# Add your GitHub PAT as a project secret in Avi's UI, then:
# The agent will use GH_TOKEN env var automatically in tool calls.
# No need for `gh auth login` or Keychain-stored credentials.
Defense-in-Depth
The computer plugin automatically injects these environment variables into every spawned process:
| Variable | Value | Purpose |
|---|---|---|
GIT_TERMINAL_PROMPT | 0 | Prevents git from prompting for input |
GIT_CONFIG_NOSYSTEM | 1 | Ignores system git config (may set osxkeychain) |
GIT_ASKPASS | /bin/echo | Returns empty string instead of prompting |
SSH_ASKPASS | /bin/echo | Same for SSH |
GCM_INTERACTIVE | never | Git Credential Manager: never show UI |
These are set automatically — no user configuration needed. They prevent any subprocess from triggering a Keychain prompt even if the system-level settings are not configured.
macOS Permissions (TCC)
macOS requires explicit permission grants for apps to access protected resources. On a headless machine, TCC prompts block tool calls indefinitely. Grant these permissions once during initial setup while you have screen access:
- Full Disk Access — System Settings → Privacy & Security → Full Disk Access → add Avi.app (and/or Terminal if running from CLI). Required for the agent to read/write files across the filesystem.
- Accessibility — System Settings → Privacy & Security → Accessibility → add Avi.app. Required for UI automation tools (AppleScript, clicking, typing).
- Automation — Granted automatically when the first AppleScript command runs. Click "OK" on the one-time prompt.
- Screen Recording (optional) — System Settings → Privacy & Security → Screen Recording → add Avi.app. Only needed if you want the agent to take screenshots.
After granting these, Avi's tool calls inherit the permissions and no further popups appear.
Note: On macOS Tahoe 26+, TCC permissions cannot be granted programmatically without MDM enrollment. The one-time interactive setup is required.
Auto-Start on Boot
For packaged macOS apps, the preferred path is the in-app Launch Avi at login setting in Avi's user settings. This supports both Avi and Avi Beta and is disabled for local dev runs.
If you need a manual fallback for an installed app in /Applications, use a per-user LaunchAgent. Do not use these instructions for npm run dev or other unpackaged local runs.
To start Avi automatically when you sign in on macOS:
- Open System Settings → General → Login Items
- Add the Avi app
- Alternatively, create a LaunchAgent:
cat > ~/Library/LaunchAgents/com.avi.agent.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.avi.agent</string>
<key>ProgramArguments</key>
<array>
<string>/Applications/Avi.app/Contents/MacOS/Avi</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ProcessType</key>
<string>Interactive</string>
</dict>
</plist>
EOF
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.avi.agent.plist
launchctl enable gui/$(id -u)/com.avi.agent
launchctl kickstart -k gui/$(id -u)/com.avi.agent
For Avi Beta, update ProgramArguments to:
<string>/Applications/Avi Beta.app/Contents/MacOS/Avi Beta</string>
Notes:
LaunchAgentsrun in the logged-in user session and are appropriate for Avi's tray/window behavior.LaunchDaemonsare not supported for Avi's current Electron architecture.- Avoid enabling both the built-in login item and a manual
LaunchAgentunless you intentionally want redundant startup mechanisms.
Recommended System Settings
- Energy Saver → Prevent automatic sleeping — keep the machine awake so scheduled tasks and remote connections work
- Sharing → Screen Sharing — enable for occasional maintenance via VNC/Screen Sharing
- Automatic login — enable for the user account running Avi so LaunchAgents start on boot without requiring a password