Menu / Data and events

Architecture

Data and events

Postgres is the system of record, the job queue, the event log and the message bus. This page covers the schema, how runs are queued, how events are stored and streamed, and the three NOTIFY channels.

The schema

The schema is one file, packages/db/src/schema.ts, written with Drizzle ORM. It has about 45 tables. Here they are grouped by what they're for:

Teammates and chats

bots
Name, slug, role, instructions, autonomy, model, budget, primary flag
chats
direct, group, scheduled, inbox or helper; the teammate of a direct chat
chat_members
Which teammates are in a group chat
messages
What you wrote: text, attachments, mentions, steering and redo links

Runs and the event log

runs
One turn: trigger, prompt, status, thread and turn ids, final reply, rating
run_events
The ordered log of everything a run did, as jsonb payloads
bot_threads
The Codex thread for each teammate and chat, with what it was made with
artifacts
Screenshots and uploads, as bytes
usage_entries
An append-only ledger of tokens and cost, per run and auth mode

Control

approvals
Pending and decided approvals: kind, title, detail, payload
approval_rules
Always allow rules: a command prefix, an action, or file changes
desktop_takeovers
Takeovers and bot requests for help, with hand-back notes
notifications
What you were told, with read state

Automations and teamwork

schedules
Cron routines and one-time reminders, with their next run time
triggers
Page watches (URL, interval, last text) and webhooks (secret token)
tasks
The shared task board
swarms, swarm_tasks
A job split across helpers, and each helper's part and result
instruction_proposals
A teammate's suggested rewrite of its own instructions
skills
Saved procedures, shared or for one teammate

Memory

memory_items, memory_revisions
Current memories, and every revision of each
memory_embeddings
Optional pgvector embeddings, per revision and model
memory_generations
A counter per scope that fences stale threads
memory_jobs
Durable background jobs: embedding and extraction
memory_contexts
Which memory ids and revisions each run was given
memory_imports, memory_usage
Import markers, and the daily AI request budget
lessons
A compatibility projection of feedback lessons

Secrets, money and channels

credentials
The OpenAI API key, Telegram token and VAPID keys, encrypted
vault_items
Logins and cards, encrypted, with the teammates that may use each
purchases
Payments a teammate made or asked to make, against its budget
connections
MCP servers, with encrypted headers or environment
telegram, telegram_messages
The linked bot and chat, and message-to-chat links
email_settings, emails
Your domain and inbound secret; every email in and out
push_subscriptions
Browsers and phones that get push notifications

App state and auth

settings
One row: monthly cap, time zone, ChatGPT auth source, plan limit
computers
One row: the computer and its egress mode
user, session, account, verification, passkey
Better Auth

Conventions

  • Primary keys are random UUIDs, except run_events.id, which is a bigserial so events have a global order.
  • Timestamps are timestamptz.
  • Structured values (attachments, timeline payloads, approval payloads) are jsonb with a TypeScript type attached with $type<>().
  • Deleting a teammate archives it (bots.archived_at). Rows that belong to it cascade or set null as each foreign key says.
  • Secrets are never stored in plain text. encryptSecret() in @open-bot/shared uses AES-256-GCM with a random 12-byte IV, and stores iv.tag.ciphertext in base64url.

Migrations

Migrations are generated SQL files in packages/db/drizzle/, from 0000_init.sql to 0020_versioned_memory.sql.

Terminal
pnpm db:generate   # write a new migration from changes to schema.ts
pnpm db:migrate    # apply pending migrations

0020 runs CREATE EXTENSION IF NOT EXISTS vector, so the database must have pgvector available. The local Compose file uses the pgvector/pgvector:pg17 image.

The run queue

A queued run is a row in runs with status = 'queued'. Anything that wants a teammate to work calls enqueueRun() (apps/worker/src/context.ts), which:

  1. inserts the run with its trigger, prompt and links (the message, schedule, trigger or parent run that caused it);
  2. appends a first status: queued event, so the chat shows it right away;
  3. sends NOTIFY openbot_worker, 'runs'.

The worker claims runs with UPDATE ... FOR UPDATE SKIP LOCKED, at most one per teammate per chat, oldest first. The query is on the Worker page.

Run states

claimedturnstartedapproval ortakeoveransweredturn completederrorstoppedcancelled before it was claimedqueuedstartingrunningwaitingcompletedfailedinterrupted
A run is active while it is starting, running or waiting. When the worker restarts, every active run becomes interrupted, because a Codex turn can't be resumed without the session that drove it.

runs.waiting_for says what a waiting run waits for: approval or takeover. runs.cancel_requested_at marks a run you stopped, until the worker processes it.

The event log

run_events is append-only. Each row has the run id, the chat id (so a chat's events are one index scan), and a jsonb payload of one of four types:

RunEventPayload (packages/shared/src/timeline.ts)
type RunEventPayload =
  | { type: "status"; status: RunStatus; error?: string; waitingFor?: "approval" | "takeover" }
  | { type: "item"; item: TimelineItem } // insert or replace an item by id
  | { type: "delta"; itemId: string; delta: string } // append text to an item
  | { type: "usage"; inputTokens: number; cachedInputTokens: number; outputTokens: number };

A TimelineItem is one thing the run did, with an id, a status (running, completed or failed) and timestamps. There are fourteen kinds: message, reasoning, command, file_change, plan, web_search, tool, computer (a desktop action with its screenshot), approval, takeover_request, handoff, user_message (steering and hand-back notes), swarm and error.

Items are written whole whenever they change, and the reducer replaces them by id. Deltas only append text. This keeps the reducer simple and makes the log easy to read in SQL.

applyRunEvent(state, payload) in @open-bot/shared folds events into a run's timeline. It is pure and shared: the web app uses it on the server to build a chat's first snapshot, and in the browser to apply live events.

Why a log

  • Streaming. The stream route reads rows after a cursor, so streaming is just a query.
  • Resuming. A tab that reconnects sends its last id and gets exactly what it missed.
  • Replay. Every desktop action points to its screenshot in artifacts, so you can step through what a teammate did, frame by frame, at any later time.
  • Late updates. An approval decided after its run ended still appends an event to that run (appendRunEvent), so the card in the chat updates.

Notification channels

packages/db/src/index.ts defines three channels:

ChannelPayloadSent byListened to by
openbot_workerruns, schedules, triggers or cancelthe web app and the workerthe worker
openbot_chata chat idthe worker, after storing run eventsthe web app's SSE streams
openbot_appa JSON AppEvent, like {"type":"approvals"}the web app and the workerthe web app's SSE streams

AppEvent has about twenty types, one per area of the UI: bots, chats, approvals, takeovers, lease, computer, notification, schedules, triggers, tasks, memory, vault, purchases, connections, email, telegram, skills, profile, lessons and auth. Each one tells open tabs which cached queries to refetch.

Notifications are hints, not data. Every listener reads the rows itself, and the worker sweeps for queued runs and cancellations every 15 seconds, so a dropped notification only delays something.

Usage ledger

usage_entries records tokens per run and auth mode, with an idempotency key of run:<id>:<mode> so a retry can't count twice. Plan usage costs nothing per token, so its cost_micros is 0. API key usage is priced at the model's list prices, in millionths of a dollar. See Models and usage.