Database
How andromeda-js stores what it does: one shared SQLite file for runs, learning and services, three small side databases, conversation files on disk, and optional Qdrant and Postgres. This page lists every table and column, how the tables refer to each other, and a worked example that follows one chat turn through them.
This page describes the TypeScript framework (andromeda-js). The Python framework uses the same table layouts for the services both frameworks have, but keeps its own files in its own project's .andromeda/ folder. See Shared files.
Where data lives
All of Andromeda's own state lives in the app state directory: .andromeda/ in the andromeda-js checkout, or ~/.andromeda for a bundled build. Set ANDROMEDA_STATE_DIR to move it. The workspace you point Andromeda at doesn't change where these files go.
| Store | Kind | Holds | Written by |
|---|---|---|---|
.andromeda/andromeda.db | SQLite (WAL) | Usage ledger, learning, background tasks, schedules, triggers, artifacts, conversation search, sync bookkeeping | TUI, REPL, API, daemon, dashboard |
.andromeda/cache.db | SQLite (WAL) | Shared key/value cache (web fetches, embeddings) with expiry and LRU | Every process |
.andromeda/db-stats.db | SQLite (WAL) | Per-minute query timings and the slow-query log for the dashboard's Database page | Every process (the profiler) |
.andromeda/web-auth.db | SQLite (WAL) | Web app accounts and sign-in sessions | api serve |
.memory/ | JSON / JSONL files | Conversation threads, titles, goals and working memory | The memory store |
Qdrant andromeda_memory, andromeda_downloads | Vector DB | Embedded messages for semantic recall, and ingested pages and documents | Memory, downloads |
Postgres andromeda_ledger | Optional | A shared copy of the usage ledger across machines and containers | Ledger sync |
The shared file name comes from storage.database_path (DATABASE_PATH, default andromeda.db). A relative path is resolved against the state directory. Artifacts can be split into their own file with ANDROMEDA_ARTIFACTS_DB.
Every SQLite file is opened the same way (openDatabase in src/libs/utils/sqlite.ts): its folder is created, journal_mode=WAL lets readers work while one process writes, busy_timeout=5000 makes a writer wait up to five seconds for a lock instead of failing, and every query is timed by the profiler. Because WAL relies on shared memory, a host process and a Docker container must not open the same file; the containers use their own state directory and share history through Postgres instead.
Tables in andromeda.db
Each feature creates its own tables with CREATE TABLE IF NOT EXISTS when it starts, so a fresh file fills in as features are used. Types are SQLite's: TEXT timestamps are ISO 8601 UTC, REAL timestamps in the learning tables are Unix seconds, and columns holding lists or objects store JSON text.
Usage ledger: runs and what happened in them
Written by UsageLedger (src/libs/observability/usage.ts) through the trace recorder. It powers the usage dashboard, /usage, the audit views and the Command Center. Turn it off with usage.enabled: false.
| Table | Key | Columns | One row is |
|---|---|---|---|
runs | id TEXT | parent_id, agent, source (interactive, api, whatsapp, schedule…), thread_id, prompt, started_at, ended_at, status (running, completed, failed, canceled), error, result, pid, host | One agent run: a chat turn, a subagent, a review or a scheduled job |
calls | id INTEGER | run_id, at, engine, model, role, source, kind, prompt_tokens, completion_tokens, cached_tokens, cache_source, duration_ms, gen_ms, prompt_ms, ttft_ms, load_ms, tool_calls, think, purpose, ok, error | One model request, with tokens and timings |
tools | id INTEGER | run_id, at, tool, outcome (ok, error, denied), duration_ms, arguments (redacted JSON), result_chars, preview, error | One tool call |
events | id INTEGER; unique (run_id, event_id) | run_id, event_id, at, type, attributes (JSON) | One step in a run's event history, in order |
logs | id INTEGER | at, level, message, entity_type, entity_name, process, run_id, data | One structured log record; pruned after logging.retention_days |
media | id INTEGER | run_id, at, kind (stt, tts, image, video, voice_turn), engine, model, source, channel, duration_ms, first_ms, audio_ms, chars, outcome, error, cold, details | One speech, image or video job and its timing |
feedback | id INTEGER | run_id, span_id, at, agent, source, type, value_num, value_text, comment, user_id, tags | A rating or comment on a run (thumbs, score, note) |
reviews | run_id TEXT | status (needs-review, complete), tags, reason (manual, feedback), note, flagged_at, reviewed_at, updated_at | A run flagged for human review; at most one per run |
cache_stats | (minute, cache) | hits, misses, saved_ms, load_ms | Cache hits and misses for one cache in one minute |
counters | name | value | Internal markers, such as one-off backfills already done |
Learning
Written by Experience (src/libs/learning/experience.ts) when learning.experience.enabled is on (the default). See Learning.
| Table | Key | Columns | One row is |
|---|---|---|---|
experience_runs | id INTEGER (autoincrement) | created_at, resource_id, thread_id, prompt, steps (JSON), output, model_calls, tool_calls, tool_errors, duration, used_recipes (JSON list of recipe ids), learned_recipe, feedback (−1, 0, 1), trace_run_id | A finished run, kept so a recipe can be learned from it |
experience_recipes | id INTEGER (autoincrement) | created_at, updated_at, task, steps, source, embedding, prompt, prompt_embedding, uses, wins, losses | A reusable "how I did this" recipe, matched to new prompts by embedding |
experience_tool_fixes | id; unique (scope, tool, error_key) | created_at, updated_at, error, failed, fixed, hits | A tool error and the corrected call that worked, offered the next time the same error appears |
Services
The durable services that survive restarts. See Background tasks and Schedules.
| Table | Key | Columns | One row is |
|---|---|---|---|
background_tasks | id TEXT | agent_id, tool_name, arguments, status (queued, running, paused, completed, failed, cancelled), result, error, attempts, max_retries, timeout_seconds, thread_id, resource_id, checkpoint, notify, notified, created_at, updated_at, started_at, completed_at | A tool call running outside the agent loop. Running tasks go back to queued after a restart. |
schedules | id TEXT | agent_id, cron, prompt, name, timezone, thread_id, resource_id, metadata, status (active, paused), next_run_at, last_run_at, created_at, updated_at | A cron schedule that runs a prompt |
triggers | id TEXT | agent_id, name, kind (gmail, calendar, file, webhook…), prompt, config, status, secret, state, next_check_at, last_fired_at, last_error, recent_fires, created_at, updated_at | An event watcher that runs a prompt when something happens |
Older databases may also contain a schedule_runs table. The current code doesn't write to it; schedule results are recorded as runs with source schedule.
Conversation search and artifacts
| Table | Key | Columns | One row is |
|---|---|---|---|
conversation_threads | (resource_id, thread_id) | count, title | How many messages of a thread are indexed, so only new ones are added |
conversation_text | FTS5 virtual table | content (searched), plus unindexed resource_id, thread_id, idx, role, created_at | One message's text, for keyword search across past sessions. Tokenized with unicode61, ignoring accents. |
artifacts | id TEXT (a + 6 hex) | kind, source, title, text, fingerprint, path, created_at | A saved page or document, or a file a tool made (image, audio, video, screenshot), that the agent can refer back to. See Artifacts. |
SQLite creates conversation_text_config, _content, _data, _docsize and _idx to back the FTS5 table. Don't write to them directly.
Bookkeeping
| Table | Purpose |
|---|---|
andromeda_migrations | One row per legacy database file already imported (source path, migrated_at), so imports run once |
sync_outbox | Changes waiting to be pushed to Postgres: seq, tbl, key, op (upsert, delete) |
sync_map | Rows imported from other machines: (origin, tbl, key) → local_id, seq |
sync_state | Sync cursors, the lease that lets one process sync at a time, and whether history was seeded |
sync_flags | Set while importing, so imported rows don't go back into the outbox |
How the tables refer to each other
The tables in andromeda.db are linked by shared ids, not by FOREIGN KEY constraints. SQLite doesn't enforce foreign keys unless a connection turns them on, and these tables are written by several processes, so a row can be written even when its parent hasn't been yet. Joins on these ids should allow for a missing parent (use LEFT JOIN). The only enforced foreign key is in web-auth.db. The run id is the hub: everything that happens during a run is tagged with it.
runs is a subagent pointing at its parent run. Dashed arrows join on a conversation thread, which lives in .memory/ rather than in a table.| Column | Refers to | Relationship |
|---|---|---|
calls.run_id, tools.run_id, events.run_id, logs.run_id, media.run_id, feedback.run_id | runs.id | Many per run. run_id is empty for work outside a run, such as a dashboard Playground call or a startup log line. |
reviews.run_id | runs.id | Zero or one per run (it is the primary key) |
runs.parent_id | runs.id | A subagent's run points at the run that started it, giving a tree. Indexed as runs_parent. |
experience_runs.trace_run_id | runs.id | Ties a learning record to its traced run, so feedback on the run scores the recipes it used |
experience_runs.learned_recipe | experience_recipes.id | The recipe learned from this run, if any |
experience_runs.used_recipes | experience_recipes.id | A JSON list of the recipes offered during the run; their wins and losses move with feedback |
thread_id (and resource_id) in runs, experience_runs, background_tasks, schedules and conversation_* | A conversation file in .memory/ | Stored at .memory/<resource_id>/threads/<thread_id>.json. resource_id is whose memory it is (the owner, or a WhatsApp contact); thread_id is one conversation in it |
background_tasks.agent_id, schedules.agent_id, triggers.agent_id | An agent registered at runtime | A name, not a table row: the agent that does the work |
sync_outbox.tbl + key, sync_map.tbl + local_id | Any synced ledger row | Filled by triggers on the ledger tables; see Postgres |
web_sessions.user_id | web_users.id in web-auth.db | Enforced, ON DELETE CASCADE: removing a user ends their sessions |
Indexes
Indexes follow the queries the dashboard and services run most: time ranges (calls_at, logs_at, media_at, feedback_at, runs_started), everything for one run (calls_run, tools_run, logs_run, media_run, feedback_run, runs_parent), and the due-work polls (schedules_due on (status, next_run_at), triggers_due on (status, next_check_at), background_tasks_status). reviews_status serves the review queue and artifacts_source finds an existing copy of a page by (source, fingerprint).
Example: one chat turn, row by row
This example follows a single turn through the database. You type "Summarize the open PRs and screenshot the PR list" in the TUI. The agent calls a model, starts a subagent to read the PRs, takes a browser screenshot, and you give the answer a thumbs up. The ids below are illustrative.
- The turn starts. The trace recorder inserts the run with
status = 'running':runs id = 'run_7f3a' parent_id = NULL agent = 'andromeda' source = 'interactive' thread_id = 'tui-2026-10-15' prompt = 'Summarize the open PRs…' started_at = '2026-10-15T09:00:00Z' - The model is called. Each request adds a
callsrow with its tokens and timings, and each step in the run appends aneventsrow with the nextevent_id, using Temporal-style event names:calls id = 1201 run_id = 'run_7f3a' engine = 'ollama' model = 'llama3.2:3b' prompt_tokens = 2140 completion_tokens = 96 ttft_ms = 410 tool_calls = '["spawn_subagent"]' events run_id = 'run_7f3a' event_id = 2 type = 'ActivityTaskScheduled' attributes = '{…}' - A subagent runs. It gets its own run, pointing back at the turn, and its own calls and tools:
runs id = 'run_91c0' parent_id = 'run_7f3a' agent = 'researcher' status = 'completed' tools run_id = 'run_91c0' tool = 'git_log' outcome = 'ok' duration_ms = 84 result_chars = 3120 - A screenshot is taken. The tool call is recorded with redacted arguments, and because the tool made a file, the file is registered as an artifact:
tools run_id = 'run_7f3a' tool = 'browser_screenshot' outcome = 'ok' duration_ms = 920 artifacts id = 'a1b2c3' kind = 'screenshot' source = 'browser_screenshot' path = '…/pr-list.png' - The turn ends. The run is closed, the messages are appended to
.memory/<resource>/threads/tui-2026-10-15.json, and the conversation index adds the new messages toconversation_textand updatesconversation_threads.count. With Qdrant memory, the turn's messages are embedded and stored as points that share oneturn_id.runs id = 'run_7f3a' status = 'completed' ended_at = '2026-10-15T09:00:14Z' result = 'There are 3 open PRs…' - Learning records it. Because the run used several tools, an
experience_runsrow is written withtrace_run_id = 'run_7f3a', and a recipe may be learned intoexperience_recipes. - You rate the answer. A
feedbackrow is added forrun_7f3a, and the matchingexperience_runs.feedbackmoves to 1, crediting the recipes it used. A thumbs down would also add areviewsrow withreason = 'feedback'andstatus = 'needs-review'.
Querying it
Open the file read-only with the sqlite3 shell (sqlite3 -readonly .andromeda/andromeda.db) or the dashboard's Database page. Read-only access is safe while Andromeda runs, because WAL lets readers work alongside the writer.
-- The last five turns, with model calls, tokens and tool calls for each.
-- Subqueries avoid the row multiplication you'd get from joining calls and tools together.
SELECT r.id, r.agent, r.source, r.status, r.started_at,
(SELECT COUNT(*) FROM calls c WHERE c.run_id = r.id) AS model_calls,
(SELECT COALESCE(SUM(c.prompt_tokens + c.completion_tokens), 0) FROM calls c WHERE c.run_id = r.id) AS tokens,
(SELECT COUNT(*) FROM tools t WHERE t.run_id = r.id) AS tool_calls
FROM runs r
WHERE r.parent_id IS NULL
ORDER BY r.started_at DESC
LIMIT 5;
-- The subagents a turn started.
SELECT child.id, child.agent, child.status
FROM runs child
WHERE child.parent_id = 'run_7f3a';
-- Which tools fail most, and how long they take.
SELECT tool, outcome, COUNT(*) AS calls, ROUND(AVG(duration_ms)) AS avg_ms
FROM tools
GROUP BY tool, outcome
ORDER BY calls DESC;
-- Keyword search across every past conversation (FTS5).
SELECT thread_id, role, snippet(conversation_text, 0, '[', ']', '…', 12) AS excerpt
FROM conversation_text
WHERE conversation_text MATCH 'qdrant'
ORDER BY rank
LIMIT 5;
-- Learned recipes and how they've done, with the run each was learned from.
SELECT rec.id, rec.task, rec.uses, rec.wins, rec.losses, e.trace_run_id
FROM experience_recipes rec
JOIN experience_runs e ON e.learned_recipe = rec.id;
-- What the scheduler checks each tick (served by the schedules_due index).
SELECT id, name, cron, next_run_at
FROM schedules
WHERE status = 'active' AND next_run_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
ORDER BY next_run_at;
Don't write to these files by hand while Andromeda is running. Use the app's commands (/schedules, /tasks, andromeda usage reset) so the sync triggers and in-memory state stay correct.
The side databases
cache.db
One table, shared_cache, keyed by (namespace, key) with value, expires_at and last_access. Processes share web fetch and embedding results through it. shared_cache_expiry finds expired entries to drop, and shared_cache_lru on (namespace, last_access) evicts the least recently used once a namespace is full. Hit and miss counts go to cache_stats in andromeda.db. It is kept apart because it churns constantly and can be deleted at any time.
db-stats.db
The query profiler's output. db_query_stats holds per-minute totals keyed by (bucket, pid, db, sql): calls, total_ms, max_ms, rows, errors and the process name. db_slow_queries logs each query slower than logging.database.slow_ms (default 50 ms). The db column names which database file the query ran against. Statements are stored with their placeholders, never with the values bound to them. Each process flushes every 10 seconds, and rows older than logging.database.retention_days (default 7) are pruned. Set logging.database.enabled: false to turn profiling off.
web-auth.db
Accounts for the web app, written by api serve:
| Table | Columns |
|---|---|
web_users | id, username (unique, case-insensitive), display_name, password_hash (scrypt), salt, role (admin, user), active, created_at |
web_sessions | token_hash (SHA-256 of the bearer token; the token itself is never stored), user_id → web_users.id on delete cascade, expires_at (30 days), created_at |
web_auth_meta | key, value; records legacy_owner_id, the first account, which owns conversations made before accounts existed |
Outside SQLite
Conversation files
Message history isn't in SQLite. The local memory store writes each thread to .memory/<resource_id>/threads/<thread_id>.json (or .jsonl when the store uses the append format), with .meta.json for the title and timestamps and .goals.json for goals. conversation_text is a search index built from these files and can be rebuilt from them.
Qdrant
With memory.provider: qdrant, messages are embedded into the andromeda_memory collection (memory.qdrant.collection) and ingested pages and documents into andromeda_downloads (downloads.collection). Point payloads carry the same ids as the SQLite tables, so a hit can be traced back to its conversation:
| Point | Payload |
|---|---|
| Conversation message | resource_id, thread_id, message_id, index, role, created_at, and a turn_id shared by every message saved in one turn (the prompt, its tool calls and the reply) |
| Document chunk | source, title, path, folder, chunk, chunk_count, created_at, and a doc_id shared by every chunk of one source, so a hit can fetch the chunks around it |
A point's id is a UUIDv5 of its content and payload without created_at, so storing the same text again reuses the existing point instead of making a duplicate. The web app's Memory page browses, searches and prunes both collections.
Postgres (optional)
Set usage.postgres_url (USAGE_POSTGRES_URL) to share the usage ledger between machines and containers. SQLite is still what each process writes and reads; Postgres is a meeting point:
- Triggers on
calls,tools,logs,feedback,media,runs,eventsandreviewsappend every change tosync_outbox. - Every couple of seconds, one process per database (holding the lease in
sync_state) pushes the outbox into a single Postgres table,andromeda_ledger, keyed by(origin, tbl, key)with the row asJSONB. - Rows with an integer id (calls, tools, logs, feedback, media) belong to the ledger that made them:
originis that ledger's id. Other machines import them under new local ids and remember the mapping insync_map. Rows with a natural key (runs, events, reviews) are shared underorigin = '*'. - Pulls take everything other writers changed since the last pull.
sync_flagsstops imported rows from being pushed back.
Cache counters, schedules, tasks, triggers, learning and artifacts stay local. If Postgres is unreachable, changes wait in the outbox. History from before you turned sync on is sent on the first sync.
Schema changes and legacy files
There is no migration framework. Each store updates its own tables at startup:
- New columns are added in place. The store reads
PRAGMA table_infoand runsALTER TABLE … ADD COLUMNfor anything missing. For example,callsgainedrun_id,cached_tokens,ttft_ms,thinkandload_msthis way, andrunsgainedresult,pidandhost. - Data fixes run once and are idempotent, such as clearing cache estimates that were wrongly recorded as full hits.
- Legacy per-feature files (
usage.db,experience.db,background_tasks.db,schedules.db,triggers.db, and the workspace's.andromeda/artifacts.db) are imported intoandromeda.dbat startup bymigrateLegacyDatabases. It attaches each old file, copies its tables while keeping their ids (INSERT OR IGNORE, only columns both sides have), and records the file inandromeda_migrationsso the import runs once. The learning tables are renamed toexperience_runsandexperience_recipesbecause usage also has a table namedruns. The old files are kept as a rollback copy.
To start fresh, stop every Andromeda process and delete .andromeda/andromeda.db together with its -wal and -shm files. andromeda usage reset clears only the usage ledger.