Picture this. Your agent is mid-task — a user asked it to summarize a 200-page document, it’s been thinking for 90 seconds — and the process restarts. Maybe a deploy, maybe an OOM, doesn’t matter. With in-memory storage, that task is gone. The user sees an error. The agent doesn’t even know it was working on something. That’s the trap: an agent that holds state in process memory is a demo, not a service. The moment uptime matters, you need persistence — and the moment you run more than one replica, you need a store every replica can see. Bindu defaults to in-memory because local laptops don’t have a Postgres handy. Switch to Postgres by setting two env vars and the same handler code keeps running — tasks, contexts, artifacts, and webhook configs all land in a durable store you can query, replay, and audit.Documentation Index
Fetch the complete documentation index at: https://docs.getbindu.com/llms.txt
Use this file to discover all available pages before exploring further.
memory is the default. The only other backend is postgres. There is no
SQLite or file-based backend. Pick via STORAGE_TYPE — no code change in your
handler.How Bindu Storage Works
Every backend implements the same abstractStorage interface in bindu/server/storage/base.py. The TaskManager, workers, and handlers call that interface; the backend behind it is chosen at startup by the factory.
What gets stored
Bindu’s storage layer persists four things — not three. The earlier model in the spec missedwebhook_configs:
Tasks
One row per task in
tasks. State, message history, artifacts, metadata,
owner DID, timestamps.Contexts
One row per conversation in
contexts. Shared message history that spans
multiple tasks, plus arbitrary context_data.Task feedback
Optional ratings and comments per task in
task_feedback. One task can
have many feedback rows.Webhook configs
One push-notification config per task in
webhook_configs. Survives
restarts so long-running tasks can still call back.tasks.artifacts as a JSONB array on the task row.
Backends at a glance
| Memory | PostgreSQL | |
|---|---|---|
| Class | InMemoryStorage | PostgresStorage |
| Storage | Python dicts in process | JSONB tables via SQLAlchemy async + asyncpg |
| Persistence | Lost on restart | Survives restarts |
| Replicas | One process only | Many replicas share one DB |
| Concurrency | Single event loop | Connection pool (pool_min=2, pool_max=10 by default) |
| Migrations | None | Alembic (manual or auto on startup) |
| Retries | @retry_storage_operation decorator | Tenacity wrapper on every call |
| Compare-and-swap | No await between read and write | Single UPDATE ... WHERE state = :from |
| Setup | None | Requires a running Postgres |
STORAGE_TYPE | memory | postgres |
Both backends honour
OwnershipError and caller_did checks the same way.
The protocol surface is identical — the only differences are durability,
concurrency, and operational cost.Configuration
Storage is configured purely from environment variables. Credentials don’t belong in a Pythonconfig dict that ends up in git.
Memory (default)
dict keyed by UUID. Gone when the process stops. Fine for uv run agent.py while you’re building, useless beyond one replica.
PostgreSQL
DATABASE_URL is the canonical name (postgres_url also works). Internally, if you pass a bare postgresql:// URL, Bindu rewrites it to postgresql+asyncpg:// so the async driver is used — but it’s cleaner to write it correctly up front.
All Postgres env vars
All Postgres env vars
| Variable | Default | Meaning |
|---|---|---|
STORAGE_TYPE | memory | memory or postgres. |
DATABASE_URL | none | Full async DSN. Required when STORAGE_TYPE=postgres. |
STORAGE__POSTGRES_POOL_MIN | 2 | Minimum pooled connections (advisory). |
STORAGE__POSTGRES_POOL_MAX | 10 | Max pool size. Passed to SQLAlchemy pool_size. |
STORAGE__POSTGRES_TIMEOUT | 60 | Pool-checkout timeout in seconds. |
STORAGE__POSTGRES_COMMAND_TIMEOUT | 30 | Per-command timeout. |
STORAGE__POSTGRES_MAX_RETRIES | 3 | Retry attempts for transient errors. |
STORAGE__POSTGRES_RETRY_DELAY | 1.0 | Base delay between retries (seconds). |
STORAGE__RUN_MIGRATIONS_ON_STARTUP | false | Run Alembic upgrade head on connect. |
DID / POSTGRES_DID | none | If set, isolates this agent’s tables into a per-DID Postgres schema. |
run_migrations_on_startup defaults to false — the spec previously
suggested tables are created on first run, but in current code production
deployments run Alembic explicitly. Turn it on locally if you want
zero-touch bootstrap.The Storage Interface
Everything goes throughStorage in bindu/server/storage/base.py. The methods your handler ends up exercising:
update_task_state_ifis a compare-and-swap. In Postgres it’s a singleUPDATE ... WHERE state = :from; in memory it’s a comparison with noawaitbetween read and write. This is how cancel-vs-complete races stay correct.OwnershipErroris raised bysubmit_taskwhen a context already exists with a differentowner_did. Handlers catch it and convert toContextNotFoundErrorso they don’t leak existence to the caller.- Terminal states are immutable.
submit_taskon a task already incompleted,failed,canceled, orrejectedraisesValueError— you must create a new task withreference_task_ids.
Using the factory
STORAGE_TYPE=postgres and SQLAlchemy / asyncpg aren’t installed, the factory raises a clear ValueError telling you to pip install sqlalchemy[asyncio] asyncpg.
The Storage Lifecycle
Every state transition is a write. If the process dies betweenworking and completed, the row still exists in submitted or working — the scheduler can re-pick it up and the worker resumes from the last persisted state.
The Postgres Schema
Defined inbindu/server/storage/schema.py via SQLAlchemy Core. Bindu uses imperative mapping — the protocol TypedDicts in bindu/common/protocol/types.py are the model; the schema module is just the table definition.
tasks
tasks
context_id, state, created_at, updated_at, owner_did,
and a GIN index on metadata.contexts
contexts
created_at, updated_at, owner_did, GIN on context_data.task_feedback
task_feedback
task_id, created_at.webhook_configs
webhook_configs
Helpers
bindu/server/storage/helpers/ ships small utilities used by PostgresStorage:
validation.validate_uuid_type/normalization.normalize_uuid— coerce string UUIDs toUUIDand reject bad input early.normalization.normalize_message_uuids— fix uptask_id,context_id,message_id,reference_task_idson inbound messages.serialization.serialize_for_jsonb— recursively convertUUIDtostrso JSONB stays JSON-safe.security.mask_database_url— strip the password before logging (user:***@host).security.sanitize_identifier— used when DID-based schema isolation rewritessearch_path; rejects anything that isn’t[A-Za-z0-9_].db_operations.get_current_utc_timestamp,prepare_jsonb_value,create_update_values— common building blocks forUPDATEstatements.
PostgreSQL Setup
Install the driver
PostgresStorage lazily — without these packages
you’ll get a clear error at startup, not at runtime.Create the schema
Either let Bindu do it on connect:Or run Alembic yourself:Other useful Alembic commands:
Retries, Pooling, Durability
- Retries. Memory wraps each call in a
@retry_storage_operationdecorator (3 attempts, 0.1s → 1.0s backoff). Postgres routes every call throughexecute_with_retryfrombindu.utils.retry, governed bySTORAGE__POSTGRES_MAX_RETRIESandSTORAGE__POSTGRES_RETRY_DELAY. Same Tenacity primitives used elsewhere — see Retry. - Pooling.
PostgresStoragecreates a singleasync_engineper process withpool_size=postgres_pool_max,max_overflow=0, andpool_pre_ping=Trueso dead connections are detected before they bite a request. - Transactions. Multi-step writes (
submit_task,update_task) run insidesession.begin(). The status update and the history/artifacts append commit atomically. - Durability. Memory loses everything on restart, including queued webhook callbacks. Postgres persists every state transition; webhook configs reload on startup via
load_all_webhook_configs.
Real-World Use Cases
Multi-turn conversations
Multi-turn conversations
A user starts research, the agent asks a clarifying question
(
state=input-required), the user replies. Postgres keeps both the task
row and the context’s message_history so a follow-up tasks/send picks
up exactly where the agent paused — even across a redeploy.Long-running tasks with webhooks
Long-running tasks with webhooks
Task takes minutes. Client registers a push-notification config; Bindu
writes it to
webhook_configs. On restart, load_all_webhook_configs
rehydrates the in-memory map so the worker can still call the callback
when the task finishes.Audit and replay
Audit and replay
Every state transition writes a row update.
history and artifacts are
append-only JSONB arrays. Query by owner_did, state, or
created_at range — the indexes are already there.Multi-replica deployments
Multi-replica deployments
With Postgres, N replicas of the same agent share one DB. Whoever wins
update_task_state_if(submitted → working) owns the task. The compare-
and-swap is a single SQL UPDATE — no two workers can both win.Per-agent schema isolation
Per-agent schema isolation
Pass
DID=did:bindu:alice:agent1:... and PostgresStorage rewrites
search_path to a sanitized per-DID Postgres schema. Same database,
isolated tables — handy for multi-tenant hosting.Security Best Practices
Keep credentials out of code
DATABASE_URL belongs in .env (gitignored) locally and in your
orchestrator’s secret manager in prod. Bindu logs the URL with the
password masked via mask_database_url.Least-privilege DB user
The agent only needs DML on its own tables (plus DDL if you let it run
migrations on startup). Don’t give it
SUPERUSER.Enable TLS
Append
?sslmode=require (or stronger) when connecting to managed
Postgres. asyncpg supports the full sslmode ladder.Respect ownership
caller_did is recorded on every task and context. Pass the
authenticated DID through and let OwnershipError keep cross-tenant
leakage out of your error responses.