Lucid
Persist threads, messages, tool calls, and token usage to Postgres, MySQL, or SQLite through @adonisjs/lucid — the six agent tables, the published migration, and the in-memory twin for tests.
The lucid store persists an agent's durable state — threads, messages, tool calls, and the token-usage ledger — through @adonisjs/lucid on Postgres, MySQL, or SQLite. JSON payloads are stored as TEXT and timestamps as epoch-ms big integers, so the schema is dialect-portable. It ships inside @adonis-agora/agent; @adonisjs/lucid is an optional peer, imported lazily only when you select the lucid store.
Publish the migration
node ace configure @adonis-agora/agentconfigure (run by add) registers the provider, publishes config/agent.ts, and publishes the migration that creates the six agent tables.
Run the migration
node ace migration:runSelect the store
store names a key of the stores map. stores.lucid() rides your app's default Lucid connection — the driver resolves the db service for you at boot:
import { defineConfig, stores } from '@adonis-agora/agent'
export default defineConfig({
model: () => aiSdkModel(openai('gpt-4o-mini')),
store: 'lucid',
stores: {
lucid: stores.lucid(),
},
})Pass { connection } to target a non-default Lucid connection:
stores.lucid({ connection: 'agent' })What the migration creates
Five tables (names from the AGENT_TABLES constant):
| Table | Purpose |
|---|---|
agent_thread | one row per conversation: actor/tenant refs, title, persona, transient flag, active_stream_id, soft-delete (deleted_at), timestamps |
agent_message | one row per message: role, content, and JSON columns for tool_calls / tool_results / follow_ups / usage |
agent_tool_call | one row per tool call — PK is the model-supplied toolCallId, not a generated id — with tool name/type, input/output, status, timing, and executed_by_ref |
agent_token_usage | the usage ledger: input/output tokens, cache breakdown, model_id, purpose, and cost_usd |
agent_model_pricing | per-model pricing rows for cost estimation (see Quota & cost) |
configure also publishes create_agent_rag_chunks — the pgvector RAG chunk table, Postgres-only. Delete it if you don't use pgvector RAG.
The migration delegates to the library
create_agent_tables does not contain DDL. It calls createAgentTables(db), the same helper
autoCreateTables runs, so the migration and the auto-created schema can never disagree — and the
migration is idempotent: safe against an empty database, against one the library already
provisioned, and against one left over from a version that predates run tracking (it ALTERs the
missing run_id columns in). It sets disableTransactions = true because the helper takes its own
connection from the pool.
Threads are indexed on (actor_ref, updated_at) for list ordering, messages on (thread_id, created_at) for load, and usage on (actor_ref, created_at) so the daily quota scan stays fast. Booleans are stored as INTEGER (0/1) and timestamps as BIGINT epoch-ms — no dialect-only types.
The model-supplied tool-call id is the primary key
A persisted tool call is addressable by exactly the id the model emitted — the same id a client sends to POST /agent/tool-call/approve. That invariant is what makes HITL approval resolve the right pending call.
Behavior details
forkThreadandtruncateFromrun in a transaction.forkThreadcopies kept messages under fresh ids onto a new thread;truncateFromdeletes doomed messages' tool calls explicitly (not only via FK cascade) so it works on SQLite even withoutPRAGMA foreign_keys=ON.appendMessagebumps the thread'supdated_at, so thread lists reflect the latest activity.quotaTodaysums input + output tokens over the inclusive UTC day. Cache tokens are subsets and are never re-added, so the sum is the whole-day spend.loadThreadForTurnreads the window a turn sends, not the transcript. The newestmessageLimitmessages ordered by the database, projected to the columns a model turn reads (usage,follow_upsandrun_idstay in the table).hasAssistantMessageis a separate one-row existence check over the whole thread. The loop probes for this method structurally, so this is an optimization rather than a contract — see The read a turn makes.
Auto-created tables, and turning that off
stores.lucid() provisions the agent tables on first use, by running the idempotent
CREATE TABLE IF NOT EXISTS DDL. That is the default — autoCreateTables is true unless you say
otherwise, matching the convention @adonis-agora/durable and @adonis-agora/authz follow: a library
manages its own tables so a fresh app, a test database, or a node ace repl script works without a
migration step first. It is also what lets a pricing seed run before the very first agent turn.
The setting governs the pricing store and the governance read-model too, since all three share the same tables.
For a deployment where migrations are the only thing allowed to touch schema, turn it off and run the published migration instead:
export default defineConfig({
store: 'lucid',
stores: { lucid: stores.lucid({ autoCreateTables: false }) },
pricingStore: pricingStores.lucid({ autoCreateTables: false }),
})Provisioning them yourself
If you want the tables created from your own migration or setup hook rather than either mechanism, the DDL is exported:
import { ensureAgentTables, createAgentTables, createTableStatements, AGENT_TABLES } from '@adonis-agora/agent'
// Idempotent: create anything missing, no-op when everything exists.
await ensureAgentTables(db)
// Or take the raw statements and run them however you like.
for (const statement of createTableStatements()) {
await db.rawQuery(statement)
}ensureAgentTables(db) is exactly what autoCreateTables: true calls, so a migration built on it can
never drift from the auto-created shape. AGENT_TABLES is the name map, useful for a migration that
needs to reference a table it did not write.
The in-memory store for tests
For tests and scratch apps, omit store (or select memory) to use InMemoryAgentStore — a single-process, non-durable behavioral twin of the Lucid store. It implements the same AgentStore SPI, so a test exercises the identical code paths without a database. It's exported from the testing kit along with the in-memory sink, quota, and governance-queries doubles.
import { InMemoryAgentStore } from '@adonis-agora/agent/testing'
const store = new InMemoryAgentStore()The durable runner
Run each turn as a replay-safe @adonis-agora/durable workflow — memoized LLM/tool steps, HITL approval that suspends on a signal and survives restarts, and delegation as a tracked child workflow. One config flag.
Streaming & HTTP
The eleven core /agent routes, the five optional surfaces, the SSE envelope, human-in-the-loop approve/reject, and re-attaching to a live run.