Agora
State store

Lucid

Persist threads, messages, tool calls, and token usage to Postgres, MySQL, or SQLite through @adonisjs/lucid — the eleven 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 eleven 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' })

Independent proposal storage

LucidAgentStore and the testing InMemoryAgentStore also implement the optional ActionProposalStore capability, with the worker discovery and outcome admission that independent approvals (actionApprovalMode: 'independent') need. It stores proposals separately from runs, with an immutable input/card snapshot, scoped reads, decision audit and recoverable execution leases. Approval and queued work are written atomically in the same row. In the default blocking mode nothing writes proposals.

Create proposals with deterministic ids and stable idempotency keys. Every read/decision/claim requires the full tenantRef (explicitly null when absent), actorRef and threadId scope. Identity fields are limited to 255 UTF-16 units. listActionProposals accepts a decision filter and a limit from 1 to 1000 (default 100). The application must authorize the deciding actor before calling the store; possession of a proposal id is never authorization.

decideActionProposal returns applied, unchanged, conflict, not_found or expired; matching decisions do not replace the original audit. claimActionProposal issues a token and generation. Renewing or settling requires both and an unexpired lease. Crash recovery advances the generation while preserving the idempotency key; the domain must honor that key because delivery is at least once.

The additive agent_action_proposal table is provisioned with the rest of the schema. With autoCreateTables: false, run the library-delegating migration before using this capability. Repeated provision preserves existing history and proposals. The clock can be injected through LucidAgentStoreOptions.clock for server-side coordination/testing; never accept a decision timestamp from the client.

configure publishes two more forward-only migrations for this: add_action_proposal_discovery (the discovery index, plus a bounded backfill of existing proposals) and add_action_proposal_runtime (outcome storage on messages, outcome delivery on proposals, UI capabilities on queued messages). Stop older writers before running them. See Database rollout.

For an existing installation whose old migration is already recorded, create a new additive migration: its up calls createAgentTables(db.connection(this.db.connectionName)), with static disableTransactions = true as in the published stub. Running migration:run alone will not rerun the old migration. Its down must not call dropAgentTables, because that would erase the existing conversations; keep the additive upgrade forward-only.

What the migration creates

Eleven tables (names from the AGENT_TABLES constant; agent_run, the run lifecycle, is described under Run tracking):

TablePurpose
agent_threadone row per conversation: actor/tenant refs, title, persona, transient flag, active_stream_id, soft-delete (deleted_at), timestamps
agent_action_proposalindependent proposal snapshot, exact identity hashes, decision, version fence and embedded execution work/lease
agent_messageone row per message: role, content, and JSON columns for tool_calls / tool_results / follow_ups / usage / ui, plus the step's streamed reasoning and reasoning_ms
agent_tool_callone row per tool call — the model-supplied toolCallId is in call_id, and the PK is that id scoped to its assistant message (<message_id>:<toolCallId>), because a provider only promises the id is unique within one response. A row written before call_id existed has it NULL and the bare id as PK. 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)
agent_queued_messagethe message queue: messages sent while a turn was running, per thread, in run order (position), with everything the send carried (actor, attachments, agent, model, page context, interrupt). The thread's pause lives in agent_thread.queue_pause
agent_confirm_tokenconfirm tokens already spent, for single-use confirmed writes (LucidConfirmTokenStore): the token's SHA-256 (PK), actor, tool, expires_at
agent_stream_framethe frame buffer of the Lucid token sink (tokenSinks.lucid()): one row per stream frame of a run, numbered per run (seq), purged after a TTL. Empty unless that sink is selected
agent_channel_statetext channels' short-lived state (lucidChannelStore): provider message ids already taken, questions in progress, outcomes relayed — key (PK), value, expires_at. Empty unless a channel uses it

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.

The indexes follow the reads the store and the governance read-model make:

TableIndexed onRead by
agent_thread(actor_ref, updated_at)thread lists
agent_message(thread_id, created_at), (thread_id, seq), (run_id)loading a thread, a transcript in seq order, a run's messages
agent_tool_call(message_id), (run_id), (call_id, run_id), (status, created_at)a message's calls, a run's calls, lookups by the model's call id, the approvals inbox
agent_token_usage(actor_ref, created_at), (thread_id), (created_at), (run_id)the quota windows, per-thread usage, the spend rollups over a date range, a run's usage
agent_run(started_at), (actor_ref, started_at), (status, started_at), (thread_id, started_at)the run list and its filters, a thread's recent runs
agent_stream_frame(created_at, run_id)the Lucid sink's TTL purge

Each index is named after its table and columns (for example agent_message_thread_seq_idx, agent_token_usage_created_idx) and is looked up by name in the catalog before it is created, so an index you created yourself under the same name is left alone.

Booleans are stored as INTEGER (0/1) and timestamps as BIGINT epoch-ms — no dialect-only types.

The DDL is written once and spoken in each dialect's own terms. On MySQL that means backtick identifiers, LONGTEXT where the others say TEXT (MySQL's TEXT stops at 64 KB, and a long answer or tool output would fail the insert), no IF NOT EXISTS on an index (the catalog is read first), and tables created ENGINE=InnoDB with utf8mb4_bin — the foreign keys need InnoDB, and the binary collation makes actor_ref = 'alice' miss 'ALICE', exactly as on Postgres and SQLite. The foreign keys are table constraints, which MySQL enforces (it silently ignores a column-level REFERENCES), so deleting a thread takes its messages, tool calls, usage and runs with it on every dialect. CI runs every Lucid store suite on SQLite, Postgres 16 and MySQL 8.4.

Messages carry a per-thread seq, assigned on append, which is what orders a transcript: two messages of one turn routinely share a millisecond, and created_at alone let the database return them in either order. A row written before the column existed holds 0 and sorts first, by created_at, as before. With autoCreateTables: false, add a migration whose up() calls createAgentTables again — it only adds what is missing.

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.

New indexes on a large table

createAgentTables issues a plain CREATE INDEX. On Postgres that blocks writes to the table while the index builds, which on a large agent_message or agent_token_usage can stall live turns for as long as the build takes. To avoid it, create the missing indexes yourself first with CREATE INDEX CONCURRENTLY under the same names (for example CREATE INDEX CONCURRENTLY "agent_message_thread_seq_idx" ON "agent_message" ("thread_id", "seq")); the next boot or migration finds them and skips them. MySQL 8 builds a secondary index online, and SQLite holds the database's write lock for the build either way.

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. It and usageBetween (the quota's day and month windows) are one SUM in the database over the (actor_ref, created_at) index, so the store fetches one row per window rather than the window's ledger.
  • modelForThread reads one column. A send that names no model reads the model pinned on its thread through this optional AgentStore method, as personaForThread and defaultAgentForThread do for theirs, instead of loading the thread. A custom store without it is read through getThread.
  • 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 itself: no migration of yours is needed, on a fresh database or across an upgrade that adds a column. 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.

When it runs. Once, as the app starts (the provider's start hook) — before the HTTP server takes a request and before a test runner opens a transaction. An ace command (migration:run, list:routes, a worker) is not provisioned at start; there, and for a store you build by hand, it happens on first use instead. Both paths are the same idempotent call.

What it costs. Against a schema that is already current it only reads: tables are CREATE TABLE IF NOT EXISTS, columns are probed, and an index that exists is found in the catalog rather than re-issued — so nothing takes a table lock or waits for a transaction someone else has open. It is safe with several processes starting at once (web and worker, a rolling deploy): a statement that loses the race is re-checked against the database and counted as done.

Where it runs. On a connection of its own. If the app has a global transaction open (testUtils.db().withGlobalTransaction() in a Japa suite), the DDL runs outside it, so the schema is not rolled back with the test. SQLite is the exception: it has a single writer, so the DDL stays on the transaction's connection.

If the database is not reachable at start, the failure is logged as a warning and the first use retries.

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.

An older create_agent_tables migration with its own DDL

A create_agent_tables migration published before it delegated to the library calls this.schema.createTable(...) with no existence guard. It throws on any database the library provisioned first — which now includes a fresh test database, since the app starts before testUtils.db().migrate() runs. Replace its body with the delegating one (await createAgentTables(db.connection(this.db.connectionName)), static disableTransactions = true); editing an applied migration is safe, Lucid tracks it by file name.

Upgrading with autoCreateTables: false

createAgentTables also ALTERs in the columns a newer release added to an existing table (the agent_message columns reasoning, reasoning_ms and ui, agent_run.parent_run_id, the agent_run columns ttft_ms, error_code and model_id, sent_at on agent_message and agent_queued_message, the run_id columns, agent_thread.queue_pause) and creates the tables one added (agent_queued_message, agent_confirm_token, agent_stream_frame, agent_channel_state), and the indexes a newer release added (see the table above). With autoCreateTables: true that happens as the app starts. With it off, nothing runs it for you: after upgrading, add a migration whose up() calls createAgentTables again (a copy of the published create_agent_tables migration under a new timestamp does exactly that) — it only adds what is missing.

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()

Independent work discovery upgrade

LucidAgentStore implements the privileged worker discovery capability: claimNextActionProposal({ workerId, leaseMs }) claims queued work or recovers an expired lease, and expireActionProposals({ limit }) expires a bounded batch of due pending proposals. These methods search across scopes and must stay behind worker infrastructure, away from public routes. The store uses its trusted clock and the same version-fenced proposal update for snapshots and indexed discovery metadata. Recovered work keeps its idempotency key; handlers must tolerate at-least-once delivery.

For an existing deployment, stop old writers, run the forward-only add_action_proposal_discovery migration, then start the new writers and discovery workers. The migration calls createAgentTables to add the nullable discovery columns and indexes, then calls backfillActionProposalDiscoveryIndex({ limit: 500 }) until no legacy rows remain. Backfill preserves proposal payloads, timestamps and decision audits and fences updates against concurrent changes. Repeating an interrupted migration is safe. Mixed old and new writers during this upgrade are unsupported because old code cannot maintain the new indexes.

If provisioning outside migrations, explicitly call createAgentTables(db) and run the same bounded backfill loop. autoCreateTables: false continues to leave provisioning to the caller. Discovery never scans unindexed legacy payloads or secretly backfills them.

On this page

Partner

Your product as the official integration for Agent, right here.

Become a partner →