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

Browser Turbo, morph refreshes Rails app (Puma) durable_agent engine Client · HTTP commands Read models over pi-durable's tables EventsController · on_change → Turbo ToolsController · Ruby tool classes Sidecar.env from database.yml rake durable_agent:sidecar Sidecar (bundled, Node) HTTP API · 11 handlers pi-durable Harness change tracker + base documents SqliteStorage core, prefix durable_agent_ node:sqlite or PostgresDatabase pi-ai providers + OAuth one process per app, runs no DDL Model provider OpenAI, Anthropic API key or OAuth HTTP commands POST /events GET/POST /tools all bearer token The app's database · Postgres or SQLite durable_agent_* · 9 pi-durable tables + 3 gem tables reads (read-only) commits (only writer) Turbo forms, GET
The sidecar is the only writer of every 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

Browser Rails (Puma) Sidecar Database Model 1 · POST form 2 · POST …/submissions {request_id} 3 · commit submission row 202 {id}; Rails finds the row 4 · 303, page renders 5 · stream request, tool rounds tokens 6 · commit frame, deltas as bases 7 · POST /events {conversations} debounced 100 ms, retried once 8 · broadcast_refresh_to 9 · GET page, Turbo morphs reads durable_agent_* rows
Steps 5 to 8 repeat while the model streams. The broadcast runs inside Puma, so the stock 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.

pi-durable code gem code Harness @earendil-works/pi-durable 1.3.0 ChangeTracker after each commit: the conversations it touched → Events → POST /durable_agent/events withBaseDocuments a delta on a history-latest conversation document is committed as a base SqliteStorage portable storage core, prefix durable_agent_, finds schema version 1 current openNodeSqliteDatabase pi-durable's node:sqlite adapter · WAL, NORMAL, async busy wait ≤ 30 s PostgresDatabase + translate.ts pg.Pool of 2 · one serial queue · one retry on connection loss sqlite:///…/development.sqlite3 postgres://…?sslmode=…
The Harness sees a plain 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.

SQLitePostgres
?$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.

TableHoldsRead by
durable_agent_conversationsone row per conversationConversation
durable_agent_entriesthe transcriptEntry
durable_agent_tasksruns, tool calls, compactionsTask
durable_agent_submissionsevery input, queued to done or unansweredSubmission
durable_agent_documents, _document_revisionspi.live, pi.inbox, pi.agent, pi.usage, pi.providerDocument, Document::Revision
durable_agent_record_ids, _durable_schema, _durable_metadatapi-durable's bookkeeping
durable_agent_reportsharness faults (gem)Report
durable_agent_credentialsprovider sign-ins, AES-256-GCM (gem)sidecar
durable_agent_settingsdevice_id, schema_version (gem)sidecar

Boot

  1. Record the gem's schema version.Missing table: stop with "run bin/rails db:migrate".
  2. Check the harness tables.SELECT id FROM durable_agent_conversations LIMIT 0, else MissingSchema.
  3. Seed the two singleton rows.ON CONFLICT DO NOTHING, so a schema.rb load boots.
  4. Refuse a schema mismatch.Newer pi-durable schemas ship as gem migrations. The sidecar never migrates.
  5. Open SqliteStorage under the prefix.Gem schema_version newer than the gem refuses; older warns.
  6. 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.
  7. 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/abort200 {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/reloadre-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.

VariableFrom
DATABASE_URLthe primary database.yml entry: sqlite:///path or postgres://… with ?host=, ?sslmode=
DURABLE_AGENT_TOKENcredentials.durable_agent.token, ENV, else derived from secret_key_base outside production
DURABLE_AGENT_HOST, _PORT127.0.0.1, 4010
DURABLE_AGENT_RAILS_URLhttp://127.0.0.1:3000; unset: no tools, no events
DURABLE_AGENT_MODELopenai/gpt-6.1-sol; faux/faux-1 is offline
DURABLE_AGENT_WORKSPACE_ROOT, _NAMEstorage/durable_agent; the app name
OPENAI_API_KEY, ANTHROPIC_API_KEYpass 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 paysCredentialConversation
the app, for everyoneOPENAI_API_KEY, connection.set_api_key, or Sign in with ChatGPT in SettingsDurableAgent.start
each user, API keyConnection.new("openai", account: user.id).set_api_key(key)DurableAgent.start(account: user.id)
each user, ChatGPT planConnection.new("openai", account: user.id).start_loginDurableAgent.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
Model toolCall lookup_invoice Sidecar generic Rails tool Rails LookupInvoice#call call {number} pi.tool-result POST /durable_agent/tools/lookup_invoice 200 {result}, else {error} → isError at boot: GET /durable_agent/tools, retried with backoff for up to 60 s The result is committed like any tool result; a crash mid-call reruns only tools declared replay :safe. 120 s timeout.
Authorization, records, queries: Ruby. Durability: pi. 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

1,041
lines of Ruby
2,121
lines of TypeScript
11
HTTP handlers
12
tables, 9 pi-durable's
1
extra process
1
database
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

Decisions

Status

  1. 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.
  2. Ruby engine.Done. 207 Minitest runs per adapter, the example app's included, with a full schema comparison.
  3. Round trip on each adapter.Done. The real sidecar on faux/faux-1 against the test database.
  4. Installer, rake task, bundle.Done.
  5. 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.
  6. A real app on the gem.Done. test/dummy is 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.
  7. 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.
  8. A real-provider run with a tool call.Done. gpt-6.1-sol on a Sign in with ChatGPT credential, in the slopdep app: the model called rename_chat and current_time through the Ruby tools endpoint, the renames landed on the app's rows, answers streamed into the morphing live region, and pi.usage recorded tokens and cost per conversation.

Known limitations