Agora
State store

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/agent

configure (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:run

Select 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:

config/agent.ts
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):

TablePurpose
agent_threadone row per conversation: actor/tenant refs, title, persona, transient flag, active_stream_id, soft-delete (deleted_at), timestamps
agent_messageone row per message: role, content, and JSON columns for tool_calls / tool_results / follow_ups / usage
agent_tool_callone 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_usagethe usage ledger: input/output tokens, cache breakdown, model_id, purpose, and cost_usd
agent_model_pricingper-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

  • forkThread and truncateFrom run in a transaction. forkThread copies kept messages under fresh ids onto a new thread; truncateFrom deletes doomed messages' tool calls explicitly (not only via FK cascade) so it works on SQLite even without PRAGMA foreign_keys=ON.
  • appendMessage bumps the thread's updated_at, so thread lists reflect the latest activity.
  • quotaToday sums 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.
  • loadThreadForTurn reads the window a turn sends, not the transcript. The newest messageLimit messages ordered by the database, projected to the columns a model turn reads (usage, follow_ups and run_id stay in the table). hasAssistantMessage is 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:

config/agent.ts
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()

On this page