durable_agent
pi-durable as a Rails gem. One bundle add, one migration, one extra process, one database: the app's own Postgres or SQLite.
A Rails engine plus a bundled Node sidecar. The sidecar runs pi-durable's storage core over tables a Rails migration creates. Rails reads the same tables. One copy of the state. Contract: DESIGN.md.
Install, use
Install
bundle add durable_agent
bin/rails g durable_agent:install # migration, initializer, mount, Procfile.dev line
bin/rails db:migrate # twelve durable_agent_* tables
bin/dev # web, sidecar
Use
conversation = DurableAgent.start # model from config
conversation.say("Summarize the invoice",
request_id: message.id) # idempotent on request_id
conversation.transcript # pi-durable's own entries
conversation.busy? # the pi.live document
# in the view
turbo_stream_from conversation # refresh broadcasts, morph
say is idempotent on request_id and returns the submission row, committed before the sidecar answers. Tools are Ruby classes (below). The app brings its own pg or sqlite3.
Why one database
pi-durable's storage core is portable: SQLite dialect over a small facade, with a conformance suite. The sidecar supplies the facade for SQLite and for Postgres and runs the unchanged core over the Rails tables.
One copy of the state. No mirror, no sync, no relay. Rails reads the tables. The sidecar tells Puma what changed. Puma broadcasts. Crash safety is pi-durable's own.
Runtime shape
durable_agent_* table. Rails owns the schema and reads the rows.One sidecar per app. SQLite for development, test and one host. Postgres for the rest.
What a message does
async cable adapter works. An event is a hint; the tables are the truth.The storage layer
SqliteStorage talks to a facade: exec, run, get, all, transaction, close, ? placeholders. The scheme of DATABASE_URL picks the adapter. Two wrappers sit on the resulting Storage.
Storage. pi-durable's 24 conformance cases run against this stack on each database.SQLite to Postgres
db/translate.ts, 69 lines, memoized by SQL text. The gem's own SQL is written in the SQLite dialect and goes through it too.
| SQLite | Postgres |
|---|---|
? | $1, $2, …; strings and comments skipped |
INSERT OR IGNORE INTO … | INSERT INTO … ON CONFLICT DO NOTHING |
) STRICT | ) |
CHECK (json_valid(col)) | dropped |
ON CONFLICT … DO UPDATE, partial indexes, LIMIT ? | pass through |
Connections run in UTC. int8 parses to a number.
Rails owns the schema
The install migration creates twelve tables with adapter-neutral DDL. Nine are pi-durable 1.3.0's schema under the durable_agent_ prefix, same names, same indexes. Three are the gem's.
| Table | Holds | Read by |
|---|---|---|
durable_agent_conversations | one row per conversation | Conversation |
durable_agent_entries | the transcript | Entry |
durable_agent_tasks | runs, tool calls, compactions | Task |
durable_agent_submissions | every input, queued to done or unanswered | Submission |
durable_agent_documents, _document_revisions | pi.live, pi.inbox, pi.agent, pi.usage, pi.provider | Document, Document::Revision |
durable_agent_record_ids, _durable_schema, _durable_metadata | pi-durable's bookkeeping | |
durable_agent_reports | harness faults (gem) | Report |
durable_agent_credentials | provider sign-ins, AES-256-GCM (gem) | sidecar |
durable_agent_settings | device_id, schema_version (gem) | sidecar |
text, notjsonb.jsonbrejects\u0000; one model output would kill a commit.bigintids, no defaults. pi-durable mints them.- No
STRICT, noCHECK, no foreign keys, no seed rows. pi-durable validates and orders its own writes.
Boot
- Record the gem's schema version.Missing table: stop with "run bin/rails db:migrate".
- Check the harness tables.
SELECT id FROM durable_agent_conversations LIMIT 0, elseMissingSchema. - Seed the two singleton rows.
ON CONFLICT DO NOTHING, so aschema.rbload boots. - Refuse a schema mismatch.Newer pi-durable schemas ship as gem migrations. The sidecar never migrates.
- Open
SqliteStorageunder the prefix.Gemschema_versionnewer than the gem refuses; older warns. - Serve, load tools, then resume.Tools are fetched with retries for 60 s before the Harness resumes, so an interrupted replay-safe tool call reruns. Until then:
503 starting,resumed: false. - Supervise.A failed session or an uncaught exception exits 1. The supervisor restarts.
Two commit wrappers
Documents stored as bases
pi-durable stores a document as a base plus chord deltas and checkpoints a new base only when the definition says so. Rails would have to replay deltas to read a streaming pi.live.
withBaseDocuments intercepts commit. For each delta on a current-only document it reads the value, applies the ops, and commits a base of the same version. The core then keeps one revision row. Rewindable documents pass through. Commits are serialized.
Change tracker, events
ChangeTracker names the conversations a batch touches before the commit. Nothing after the commit can reject it.
Events posts {"conversations": [7], "reports": [3], "auth": ["openai"]} to /durable_agent/events: at most every 100 ms, one retry after 1 s, then drop. Unset DURABLE_AGENT_RAILS_URL disables it. Shutdown flushes.
The contract
Two version numbers in /health: the gem's schema (2) and pi-durable's (1). The sidecar ships inside the gem, so they always match. Everything but /health needs Authorization: Bearer, constant-time on both sides.
HTTP API (gem → sidecar)
GET /health | {ok, schemaVersion, harnessSchemaVersion, database, model, tools, resumed} |
POST /conversations | {model?, instructions?, account?} → 201 {id}; 422 unknown model or invalid account. With account, the conversation runs on that account's credential. |
POST /conversations/:id/submissions | {content, request_id, when_busy?} → 202 {id, status}; 409 busy; 404. Idempotent; row committed first. |
POST /conversations/:id/abort | 200 {ok} |
GET /auth?account= | {provider → {type, source} | null}, the app's or the account's |
PUT /auth/:provider/api_key?account= | {key} → stored encrypted; 422 blank key |
POST /auth/:provider/logins?account= | start; GET …/:id polls, POST …/:id/callback finishes, DELETE …/:id cancels |
DELETE /auth/:provider?account= | sign out |
POST /tools/reload | re-fetch tools → {tools} |
Events (sidecar → gem)
POST /durable_agent/events. Per conversation: config.on_change. Per report: config.on_report. Per credential change: config.on_auth with provider ids, scoped for accounts (openai@u42). Unknown ids skipped, callback errors reported, always 204.
Tools (sidecar → gem)
GET /durable_agent/tools → [{name, description, parameters, replay}], retried at boot for 60 s. POST /durable_agent/tools/:name with {arguments, conversation_id, call_id} → {result} or {error}. 120 s timeout.
Sidecar environment
bin/rails durable_agent:sidecar derives everything from Rails configuration and execs the bundle.
| Variable | From |
|---|---|
| DATABASE_URL | the primary database.yml entry: sqlite:///path or postgres://… with ?host=, ?sslmode= |
| DURABLE_AGENT_TOKEN | credentials.durable_agent.token, ENV, else derived from secret_key_base outside production |
| DURABLE_AGENT_HOST, _PORT | 127.0.0.1, 4010 |
| DURABLE_AGENT_RAILS_URL | http://127.0.0.1:3000; unset: no tools, no events |
| DURABLE_AGENT_MODEL | openai/gpt-6.1-sol; faux/faux-1 is offline |
| DURABLE_AGENT_WORKSPACE_ROOT, _NAME | storage/durable_agent; the app name |
| OPENAI_API_KEY, ANTHROPIC_API_KEY | pass through |
No harness file. Unknown default model: logged, sidecar runs. No credentials: runs settle unanswered. Missing tables: boot stops.
Who pays
Three arrangements. Each is a credential on an account and a conversation started on that account; the app picks per conversation.
The app pays
One credential for everyone: OPENAI_API_KEY in the environment, a key stored with connection.set_api_key, or the operator's Sign in with ChatGPT on the Settings page. Conversations start with DurableAgent.start. The operator's bill or plan carries every user. Right for an app with one operator, or one that bills its users itself.
Each user pays with an API key
The user pastes their own key; the app stores it with Connection.new("openai", account: user.id).set_api_key(key) and starts their conversations with DurableAgent.start(account: user.id). Usage lands on the user's OpenAI or Anthropic bill. No browser flow, no provider program.
Each user pays with their ChatGPT plan
The user signs in with ChatGPT through Connection.new("openai", account: user.id).start_login, approves the app in the browser, and their conversations run on the usage included in their plan, with limits they manage in ChatGPT settings. The app holds a refreshable token per user and never sees a key.
| Who pays | Credential | Conversation |
|---|---|---|
| the app, for everyone | OPENAI_API_KEY, connection.set_api_key, or Sign in with ChatGPT in Settings | DurableAgent.start |
| each user, API key | Connection.new("openai", account: user.id).set_api_key(key) | DurableAgent.start(account: user.id) |
| each user, ChatGPT plan | Connection.new("openai", account: user.id).start_login | DurableAgent.start(account: user.id) |
An app can mix them: a user with a credential runs on their account, the rest on the app's. DurableAgent.auth(account:) says which a user has.
Accounts
An account names whose credential a conversation runs on. It is an app-chosen string, typically a user id. The sidecar carries it in the provider id: openai@u42 is account u42's OpenAI, openai@u42/gpt-6.1-sol its model ref, and no @ is the app's own. pi-ai keys credentials, logins and auth checks by provider id, and each conversation pins its provider in pi.agent, so the three arrangements above share one mechanism.
The sidecar registers a clone of the base provider under the scoped id on demand and at boot from the credentials table: same catalog, same auth methods, one more credential row. The clone's models carry the scoped id, so pi-ai resolves the account's credential; each request goes to the base provider under its own id, so assistant entries record openai while pi.agent keeps openai@u42. An account never falls back to the app's OPENAI_API_KEY. A conversation on an account whose credential is gone settles model_error. Usage per account is the sum of pi.usage over its conversations.
Sign in with ChatGPT is pi-ai's OpenAI OAuth: a public client registered per login, scope chatgpt.tokens.use.direct, the token sent to the Responses API, usage drawn from the signed-in user's plan. The redirect is 127.0.0.1:1455: on the sidecar's host the listener completes the login; elsewhere the user pastes the final URL into the app.
Tools in Ruby
The sidecar fetches definitions and installs them as one extension. POST /tools/reload replaces it in place.
# app/tools/lookup_invoice.rb
class LookupInvoice < DurableAgent::Tool
description "Fetch one invoice by number"
param :number, :string, "Invoice number like INV-1042"
replay :safe # no side effects; may rerun after a crash
def call(number:)
Invoice.find_by!(number:).as_json(only: %i[ number total status due_on ])
end
end
# config/initializers/durable_agent.rb
DurableAgent.configure do |config|
config.model = "openai/gpt-6.1-sol"
config.tools = %w[ LookupInvoice ] # class names survive code reloading
end
DurableAgent::Current.conversation_id and .call_id say who asked.Reading the tables from Ruby
DurableAgent.start(model: nil, instructions: nil, account: nil) # => Conversation, created on the sidecar
DurableAgent.conversation(id)
DurableAgent.auth(account: nil) # => {"openai" => {"type" => "oauth"}, "anthropic" => nil}
DurableAgent.auth_stream(account) # "durable_agent:auth:u42", for turbo_stream_from
conversation.say("Hello", request_id: message.id) # => Submission, the row exists
conversation.abort
conversation.transcript # visible entries, oldest first
conversation.entries / submissions / tasks / reports / documents
conversation.live / busy? / streaming_message / running_tools / inbox / last_failure
conversation.model / provider / account / total_tokens / total_cost / usage_by_model
conversation.parent / parent_at
entry.text / tool_calls / model_ref / stop_reason / total_tokens / cost
entry.duration_ms / timestamp / failed? / error_message
connection = DurableAgent::Connection.new("openai", account: user.id)
connection.connected? / api_key? / subscription? / source
connection.set_api_key(key) / start_login / complete_login(id, url) / disconnect
Every model is readonly?. Ids are pi's. Records stay JSON text (attribute :record, :json). kind, data, model live in the record, so kind filters run in Ruby and return arrays.
The document rule
A document's value is its newest base revision. The base wrapper guarantees no delta follows it for anything Rails reads. If one does, Document#value raises HistoryNotSupported.
JSON-encoded columns
tasks.kind, documents.kind, documents.key_value and submissions.request_id hold JSON strings ("\"pi.live\""). Models decode on read; for_request and of_kind encode.
on_change, on_report and on_auth default to Turbo::StreamsChannel.broadcast_refresh_to. The event names the conversation; the tables hold the rest.
In the box
durable_agent/
lib/durable_agent.rb configure, config, client, start, conversation, auth
lib/durable_agent/configuration.rb defaults, callbacks, token chain
lib/durable_agent/client.rb Net::HTTP; Error, Busy, UnknownModel, Unavailable
lib/durable_agent/tool.rb description, param, replay, coercion
lib/durable_agent/current.rb conversation_id, call_id during a tool call
lib/durable_agent/sidecar.rb env and DATABASE_URL from Rails config, exec
lib/durable_agent/message_text.rb text and tool calls out of pi messages
lib/durable_agent/indexed_string.rb JSON.stringify-compatible encoding
lib/durable_agent/test_helper.rb stub_sidecar, fake_sidecar
lib/generators/durable_agent/install/ migration, initializer
app/models/durable_agent/ conversation, entry, task, submission, document,
revision, report, connection
app/controllers/durable_agent/ tools_controller, events_controller (bearer)
sidecar/src/ main, config, harness, storage, documents, events,
server, auth, credentials, settings, reports, tools
sidecar/src/db/ index, sqlite, postgres, translate
sidecar/dist/sidecar.js one ~3 MB esbuild bundle, Node >= 22.19
Distribution. rake build runs npm ci and esbuild and ships one file, no node_modules. DurableAgent::Sidecar.command is the place to swap in a binary later.
Out of scope
- More than one sidecar. One process per app and database serves every user.
- Compaction, reset, task graph, SSE routes. pi-durable has them; no need yet.
- Forked conversations' inherited entries.
entriesare a conversation's own rows. - A Ruby port of chord deltas. The base wrapper makes it unnecessary.
- UI, MCP. Tools are Ruby classes.
Decisions
- The sidecar never runs DDL. Migrations own every schema change, pi-durable's included.
- Boot fails early. Every step runs in one handler; any missing table is "run bin/rails db:migrate". Seeding before the version check lets a
schema.rbload boot. - The Harness closes the database.
closeis idempotent on both adapters. - Postgres runs in UTC.
-c TimeZone=UTCon every connection. - Postgres needs
CREATEon the schema. pi-durable's oneCREATE TABLE IF NOT EXISTS, a no-op, still checks it. - Rails index syntax reproduces pi-durable's indexes.
order: { id: :desc },where: "head IS NOT NULL", pi-durable's names. - Rails' default primary keys. Rowid alias on SQLite,
bigserialon Postgres, sequence unused. Conversations, entries, submissions get acreated_at. - One serial queue on Postgres, as on SQLite. Pool of two. The checked-out client has an error listener, so a dropped connection rejects the transaction, not the process. Only plain
SELECTs retry. - SQLite waits without stalling. 100 ms synchronous busy wait, then async retries of
BEGIN IMMEDIATEup to 30 s. - Shutdown has a deadline. 10 s for requests, 30 s total, second signal exits.
- Events are hints. Debounced, one retry, then dropped. The tables are correct without them.
- Bases exactly where the core keeps one revision. Rewindable documents keep deltas;
HistoryNotSupportedmakes a violation loud. - Accounts are provider ids.
openai@u42rides every path pi-ai and pi-durable already key by provider: credential rows, logins, the model pinned per conversation. No schema change, no pi-durable change. - Small guarantees. Zero-setup tokens in development and test. Tools as class names survive reloading. A wrong default model does not take the service down.
Status
- Storage over the app's database, both adapters, both wrappers.Done. 127 node:test cases on SQLite, 126 on Postgres, 24 of them pi-durable's conformance cases.
- Ruby engine.Done. 207 Minitest runs per adapter, the example app's included, with a full schema comparison.
- Round trip on each adapter.Done. The real sidecar on
faux/faux-1against the test database. - Installer, rake task, bundle.Done.
- Resilience under real failures.Done. Backend terminated inside a transaction, SQLite lock held from another process, storage failure exiting 1, restart during a replay-safe tool call, failing reads around a commit.
- A real app on the gem.Done.
test/dummyis slopdep, a Nitro Kit chat app with three tools, a settings page and a morphing live page; it is the engine's dummy app and its tests are part of the gem's suite. - Accounts: scoped credentials, API keys, Sign in with ChatGPT per account.Done. Verified live against the HTTP API: an account's key reaches OpenAI (a 401 naming it), faux answers on an account, a per-account sign-in starts as
openai@u42, a stored account credential survives a restart. - A real-provider run with a tool call.Done.
gpt-6.1-solon a Sign in with ChatGPT credential, in the slopdep app: the model calledrename_chatandcurrent_timethrough the Ruby tools endpoint, the renames landed on the app's rows, answers streamed into the morphing live region, andpi.usagerecorded tokens and cost per conversation.
Known limitations
- A report raised while the Harness closes is lost.
- The default
on_changerefreshes the page, up to every 100 ms per streaming conversation. An app replaces regions instead: slopdep sends one morphing stream message per event for the transcript, the live answer and the composer. - pi-durable 1.3.0 reports carry no conversation. They land on
durable_agent:reports. - A revoked OAuth credential reads as connected until a run fails. OpenAI rotates refresh tokens; one grant cannot serve two installations. The next message shows a
model_error. - Smaller. Demodulized tool names can collide; very long request ids or keys can exceed Postgres index limits;
sslmode=preferandallowmean full verification in node; the shutdown deadline and the uncaught-exception exit are untested.