Agent state — Postgres tables, views in context

An agent that has to remember structured things — “paused until 14:00”, a streak per direction, today’s challenge and its status, the last seven reminders sent, a counter — needs two properties memory cannot give it: the facts must be queryable and countable, and the current ones must be in front of it every turn without asking.

That is what agent state is. Three sentences hold the whole model:

  • Tables are facts. Your agent creates and evolves them itself with db_execute — DDL included. No deploy, no migration, no PR.
  • Views are what it sees. Any VIEW in public named <namespace>_* is rendered into the agent’s context on every turn, automatically.
  • db_query is deeper. Anything too big for the context stays in a table — tables never render, prefix or not — and the agent reads it when it needs it.

Small → straight into context. Large → summary in context, detail behind a tool. Postgres computes the summary; nothing summarizes it with an LLM.

Related: database.md (your own schema and migrations — the same database, the other half), memory.md (prose memory, which this is not), contract.md § 8 (who owns which PG schema).


1. Turning it on

One line in a role file. Its presence is the switch:

# features/team/nova.agent.md
state: { namespace: nova }
capabilities: [db:read, db:write]

No state.namespace ⇒ the role has no state: no catalog query, no prompt module, no block. The role must also hold db:read and db:write (or '*') in its own capabilities:: the state module drives the db_* tools, and a role that declares state: without them fails the role load rather than being told to use tools it is never offered. Nothing else in the brain needs touching.

What the agent is told. A short state module: everything in public named <namespace>_* is its own, which views render, the determinism rule (§ 3) and the blast-radius rules (§ 7) — plus one line pointing at the engine skill agent-state, the long-form modelling guide (tables vs views, invariants in the schema, sharing, a worked example like § 6). The guide is read on demand, so it costs nothing until the agent is about to design or change its tables. The pointer appears only when the agent can open the skill: always on CLI, and on API only with skill:read (or '*') — add it to an API state role. A brain that opted out of engine skills (skills/.engine-skills.override) vendors a skill named agent-state into skills/agent-state/; doctor Skills warns when a state role would run without the guide. See role-prompts.md § 9.

The namespace is a bare Postgres identifier: lowercase, starts with a letter, [a-z0-9_] after it, at most 32 characters. Nova, a-b and 1x fail the role load rather than failing later — a name that only survives quoted is a different name from the unquoted one your agent would type in CREATE VIEW, and the catalog scan that renders the block looks for the folded one. 32 rather than Postgres’ own 63 leaves half the budget for the part of the view name that says what the view is; a namespace that eats it makes Postgres silently truncate names, and two of your views then collide.

Nothing derives it, and it derives nothing. Not the role id (those carry hyphens and a system: prefix — secretary-maria, system:general — so any normalization would be magic nobody can predict), and not memory.write. A role may use one word for both; that is a coincidence you chose, not a rule.

A heartbeat inherits the block of the role it names in agent:, exactly like memory: — which is the point. Every heartbeat tick is a new agent row with a fresh session; if state were scoped to the row, a “pause for 3h” set from chat would be invisible to the next beat. State is scoped by role.

Two roles may declare the same namespace — a writer and its reviewer, say. That is a choice, and it works; assume a second reader when you write a view.


2. The naming convention

Any view in public whose name starts <namespace>_ belongs to that namespace and is rendered into its block. That is the entire registry: the DDL your agent writes is the declaration, so there is no file to keep in sync and nothing that can drift from the database. DROP VIEW removes it from the block on the next turn with nothing else to update. A <namespace>_* table belongs to the namespace too — the prefix is also what the agent may alter and drop (§ 7) — it just never renders.

In the block?
VIEW public.nova_challengeyes
VIEW public.nova_streaksyes
TABLE public.nova_tno — tables are never rendered
VIEW public.other_yno — different prefix
MATERIALIZED VIEW public.nova_slowno — see below
VIEW public.nova_film, named in a feature context’s data:no — it rides with that context
anything in omnislashno

Views are rendered in relname order, so your agent controls the order of its own context by naming the views. COMMENT ON VIEW becomes the line under the heading — write one; it is also what db_schema shows.

Materialized views are deliberately out of the block. A matview is stale until somebody runs REFRESH MATERIALIZED VIEW, which turns “your current state” into a confident lie. It is still an ordinary relation to the tools — db_schema lists it as kind: 'materialized' with its definition, db_query reads it — it just never renders. Ask for it if you hit a real case for it.

A view a feature context claims leaves the block. When a *.context.md names a view in its data:, that view belongs to the feature, not to the persona: it renders only in the context’s own block, on the turns linked to that feature — a run of its heartbeat, an always context of the role, a context the agent opened — and it no longer spends a slot of the views budget here. Use it for the view only one feature needs (the beat’s working set, a ticket card); keep in state: what the persona needs on every turn. contexts.md is the whole model.


3. What arrives, and when

The block reaches the agent in the conversation, tagged, after the memory blocks:

<agent-state namespace="nova" as_of="2026-09-17T08:14:02.119Z">
## nova_challenge
Today's challenge and the four before it

| day | axis | status | verdict |
| --- | --- | --- | --- |
| 2026-09-17 | body | open | null |
| 2026-09-16 | focus | closed | done, 40 min |

## nova_pause
| key | until | note |
| --- | --- | --- |
| evening | 2026-09-17T17:00:00.000Z | asked for quiet |

## nova_reminders_recent
(no rows)
</agent-state>

Per view: ## <name>, the view’s comment if it has one, then a markdown table. jsonb and array cells arrive as JSON text, and a NULL is spelled null rather than left blank — an empty cell would read identically to an empty string. Dates are calendar days (2026-09-17), timestamps are ISO instants, COUNT(*) and numeric are numbers: the engine maps Postgres types the same way for the block, db_query and db_execute, so what the agent reads here and what it reads from a query can never disagree.

Five things the block says out loud rather than hiding:

  • (no rows) — the view exists and is empty. Not the same as absent.
  • … +1 or more rows — db_query — the view has more rows than rows allows. The count is deliberately not there: the engine reads one row past the budget and stops, so “at least one more” is the only honest fact without a second COUNT(*) on every turn.
  • … N rows shown at the 4000-char state budget, more exist — db_query — the chars ceiling cut this view. The views fill the budget in your order; a view that does not fit whole shows as many of its rows as do fit, cut between rows and never inside one, with this line under the table so a cut table never reads as complete.
  • ## <name> + (not shown — over the 4000-char state budget; db_query it) — a stub, in the view’s own place, when not even its heading, table header and first row fit. The fit then carries on with the next view, so one long view (a pasted paragraph in a cell) costs that view and nothing after it. Keep columns short in the view’s own SQL (left(feedback, 80)) — how long a column may be is the view’s decision.
  • … +1 or more views not shown — db_query — more than views views exist. Countless and nameless for the same reason the row marker is, plus one of its own: the markers sit past the chars ceiling on purpose, and nothing bounds how many views you create, so a marker that named them could outgrow the block it summarises. A stub can name its view because it only ever names a view that was kept, and views bounds those.

Stubs and both row markers are not counted against chars either — each is one fixed sentence per kept view — so the block can run past chars by those sentences, never by a row, and a view whose rows fit is shown whole even when its rows marker would not. The context report (/system/context, agent.context) names every view the budget cut or stubbed for a role (state-truncated), so you learn it without reading the agent’s transcript.

A broken view is ONE line of the block, not a failed turn. It renders as ! <SQLSTATE> <message>, marked so the model cannot read an error as data. The view is the agent’s own DDL, so a broken one is a bug it can fix with db_execute on the very turn it sees it; a turn that refused to start is a turn in which it cannot. One line and not the full driver diagnostics because the block has a chars budget and DETAIL / CONTEXT do not — a view rendering its whole stack trace would push the healthy views out of your own state. Run the same view through db_query and you get every field Postgres filled, HINT included. The other views render as usual: each one is read in its own transaction precisely so a single failure cannot take them down.

No <namespace>_* view yet ⇒ a one-line block, (no <namespace>_* views — create one with db_execute), and the prompt module stays — the honest answer to “you have state” on the way in, and what replaces the old block whole when your last view is dropped.

Taking state: away from a live role withdraws the block, once. Remove the state: block from a role’s frontmatter, or set any of views / rows / chars to zero, and the agents already running under it are sent one line: <agent-state namespace="nova">(structured state is switched off for this role — any state block above is withdrawn)</agent-state>. Re-sending only appends to a transcript, so dropping the block silently would leave the previous one standing as the newest state the agent ever sees. The withdrawal costs exactly one turn; an agent that never received a block never receives one.

When it is re-sent — one view at a time

The render is recomputed on every dispatch, one section per view, and each view is hashed on its own against what the agent’s transcript already holds. On a live CLI session only what moved goes out:

  • A view changed (a row written, a WHERE until > now() crossing its threshold, a view created) → that view alone, as an update that says what it replaces:

    <agent-state namespace="nova" view="nova_challenge" as_of="2026-09-17T09:02:11.004Z">
    (update — replaces this view in the <agent-state> above; other views stand)
    ## nova_challenge
    …
    </agent-state>
  • A view dropped (or pushed out by the views budget) → one line that says the copy above is out of date — not that the fact is gone: <agent-state namespace="nova" view="nova_old">(this view is no longer shown — the copy above is out of date; db_query it if you need it)</agent-state>. A view the chars budget only stubs is still shown as its stub, so “here but not shown” never reads as “gone”.

  • Every view changed, a first turn, or a fresh session → the whole block, which replaces every earlier <agent-state> of the namespace, views it does not repeat included.

  • Nothing changed ⇒ nothing is re-sent, at any length.

After a harness compaction (or any reset of what the engine knows the transcript holds) the next turn starts with <agent-context-snapshot>(every <agent-*> block above is superseded by the blocks below; any not repeated below is withdrawn)</agent-context-snapshot> and re-sends every block whole. On a harness that compacts without telling the engine (Codex, OpenCode) a changed block always goes out whole rather than view by view.

Two properties of that mechanism matter to you:

  1. Each view is its own unit, and state is separate from memory. They move on completely different clocks — MEMORY.md changes rarely, a state view changes whenever a row does. Your agent’s INSERT into one table re-sends the views over it, not the others and not MEMORY.md.
  2. as_of on the tag is outside every hash. It is stamped fresh each dispatch so the agent knows how current the numbers are; if it were inside, every view would differ every turn and the whole block would be re-sent every turn.

Which brings up the one rule your agent has to hold, and the prompt module tells it so:

The render must be deterministic, or you pay for it every turn. Every view ends with ORDER BY — without one, Postgres may hand back the same rows in a different order after an UPDATE, and that alone re-sends the block. And now(), clock_timestamp() and random() never appear in a select list: put time in a WHERE (WHERE until > now()), so the view changes when the fact changes and not on every tick of the clock.

API-mode agents (a modelRef naming an API provider rather than a CLI harness) get the same render with every turn’s message instead, after the replayed history — never in the system prompt, which the provider caches. That path replays its history as text and has no durable transcript to re-send into, so the whole block rides every turn. Determinism is therefore cost-neutral on API — the render sits after the cached prefix either way — and it is CLI agents it pays for, where a view that changes between identical states re-sends on every turn.


4. The budget — three tiers

TierWhereWhat it may set
engine defaultshipped — you write nothingviews: 8, rows: 20, chars: 4000
brain-globalconstitution/MANIFEST.yaml § statethe three numbers, never namespace
per-rolethe state: block in *.agent.md / role-*.mdall four
# constitution/MANIFEST.yaml — every role in this brain, unless it says otherwise
state:
  views: 6
  rows: 15
  chars: 3000
# features/team/nova.agent.md — this one is the chat agent, give it room
state:
  namespace: nova
  chars: 6000
capabilities: [db:read, db:write]

Most specific wins, one key at a time: nova above ends up with views: 6, rows: 15, chars: 6000. Naming one knob never resets the others.

  • 0 means off on every number — views: 0, rows: 0 or chars: 0 and the role builds no block and makes no query at all. (rows: 0 is not “headings only”: a view carrying no rows is a heading and an overflow marker, which is a query per view per turn for nothing.) There is no separate enabled flag to disagree with it.
  • A brain-wide namespace is refused, not ignored: one prefix for every role in the brain is a collision by construction, so the manifest fails to load rather than stripping the key while its author believes it live. The same strictness catches a typo’d knob (view: 4) in either file.
  • This is not memory. An agent whose role declares no memory: block can still have state; they are separate modules with separate declarations and budgets.

To see what an agent is actually carrying, ask it /context in chat. A role with state gets its own line:

State nova · 3 sections injected

— the namespace (the prefix its DDL must use) and whether the block is already in the conversation (N sections injected, one per view), goes out on the next turn (pending — the next turn sends it), or is waiting for a session to start at all. It is a separate line from Session, which answers a different question: that one is about the transcript, this one is about whether the agent can currently see its views. A role without state.namespace shows no such line.


5. Telling several agents of one role apart

Role scope is the default and usually right. When it is not — secretary-stranger serves every unknown contact, an agent_task fan-out carries the parent role — a view can filter on the calling agent:

-- nova_work_items.agent_id is text, like the setting: agent ids are opaque text
-- (`ses_…`, older ones a uuid), so a uuid column could not hold every one of them.
CREATE VIEW nova_my_open_items AS
SELECT id, title, due
FROM   nova_work_items
WHERE  agent_id = omnislash.current_agent_id()
ORDER  BY due;

The engine binds the calling agent on every state transaction: the block’s own reads, and db_query / db_execute when the caller is an agent. It is never a tool argument, so the model cannot name a different agent. For a non-agent caller (a pipeline, the dashboard) omnislash.current_agent_id() is NULL, so a view written this way fails closed — it returns no rows rather than everybody’s. The same function stamps a write: INSERT INTO nova_work_items (agent_id, title) VALUES (omnislash.current_agent_id(), '…').

Read it through the function, not as current_setting('omnislash.agent_id', true). The raw setting is NULL for a non-agent caller only on a database connection that has never served an agent; on a pooled one that has, it is '', so a view comparing against it answers rows stamped '' and a write stamping it slips past a NOT NULL.

This is one statement per transaction, not a second scope and not a second declaration.


6. A worked example

-- role nova: `state: { namespace: nova }` — tables carry the prefix too: it marks
-- them nova's to alter and drop, and a table never renders, so it costs no context.
CREATE TABLE nova_checkins (day date, direction text, done boolean NOT NULL,
  note text, PRIMARY KEY (day, direction));
CREATE TABLE nova_control (key text PRIMARY KEY, until timestamptz, note text);
CREATE TABLE nova_challenges (day date PRIMARY KEY, axis text NOT NULL, text text NOT NULL,
  status text NOT NULL DEFAULT 'open' CHECK (status IN ('open','closed')), verdict text);

CREATE VIEW nova_streaks AS
WITH runs AS (
  SELECT direction, day,
         day - (ROW_NUMBER() OVER (PARTITION BY direction ORDER BY day))::int AS grp
  FROM nova_checkins WHERE done)
SELECT direction, COUNT(*) AS streak, MAX(day) AS last_day
FROM runs GROUP BY direction, grp
HAVING MAX(day) >= CURRENT_DATE - 1 ORDER BY direction;
COMMENT ON VIEW nova_streaks IS 'Current streaks per direction (closed yesterday or today)';

CREATE VIEW nova_pause AS
SELECT key, until, note FROM nova_control WHERE until > now();

CREATE VIEW nova_challenge AS
SELECT day, axis, status, verdict FROM nova_challenges ORDER BY day DESC LIMIT 5;

Read what each line buys:

  • “Pause for 3 hours” is INSERT INTO nova_control … until = now() + interval '3 hours'. The next heartbeat tick sees the row in nova_pause without calling anything, and the chat agent gets the block re-sent on its next turn because the render changed. Three hours later the row leaves the view on its own — the WHERE did it, no expiry job.
  • The streak is Postgres’ arithmetic, not the model’s. “Third morning in a row” is a window function that is right every time, rather than a number an LLM recounts from prose each turn.
  • CHECK (status IN ('open','closed')) is the invariant, in the schema. A model that would otherwise write completed: true on a Tuesday and status: 'closed' on a Thursday gets an error at write time instead of two vocabularies in one table.
  • ORDER BY on every view is what keeps the block’s text stable between real changes.
  • Everything the block does not need — the full check-in log, the whole challenge history — stays in nova_checkins and nova_challenges: tables, so they never render, and they reach the agent only through db_query.

db_execute runs all of this. A ;-batch is one transaction and every statement’s result comes back, in order, so DROP VIEW nova_challenge; CREATE VIEW nova_challenge AS … either swaps the view or leaves the old one standing — never neither.


7. What not to do

  • Don’t write to omnislash.*. It is the engine’s schema. A view may read it — SELECT … FROM omnislash.agents in a state view is legitimate and works — but nothing of yours writes there, with any tool. See database.md § What not to do.
  • Don’t keep agent state in kv_*. It is for pipeline cursors, dedup and last-seen markers: the scope is a string the caller picks, the value has no schema, entries expire on a TTL, and nothing about them ever arrives in an agent’s context. Every one of those is the opposite of what state needs. (This is worth an explicit look if you are moving an existing brain over: a KV key the agent had to remember to read is exactly the thing that becomes a table plus one view.)
  • Don’t add a migration per metric. The whole point is that ALTER TABLE … ADD COLUMN inside a turn is the mechanism. Your db/schema.ts line (database.md) is for tables your code queries; an agent’s own tables belong to the agent. If one later becomes load-bearing for brain code, declare it and generate a migration then — the table keeps its data either way — and take it out of the <namespace>_ prefix while you are at it: the prefix is what tells the agent a table is its to alter and drop (below).
  • Don’t let an agent ALTER or DROP anything outside its prefix. db_execute is moderate risk with no approval card by default, and your own public tables are in reach of it. The prompt module draws the line by name — a rule the agent can check at any moment, where “what did I create” rests on history a fresh session does not have: everything in public named <namespace>_*, tables and views alike, is the namespace’s to create, alter and drop (db_schema(table: 'nova\_%') lists exactly that, and the prompt hands the agent its namespace’s pattern with EVERY _ escaped, since a bare _ is a LIKE wildcard: sec\_ops\_% for sec_ops, whose unescaped sec_ops\_% would also claim secxops_*); the SHAPE of everything else, your brain’s own tables included, is not its to change — it never ALTERs or DROPs there, though it still writes rows where its instructions say so. It never uses CASCADE, so Postgres itself refuses to drop a table or a column that a view still reads, and the views a beat relies on are guarded by the database rather than by a sentence. The engine enforces none of this — it is the prompt’s rule; a brain that wants a hard gate raises db:write to hitl: true in constitution/engine-capabilities.json (contract.md § 7). The corollary for you: a table you set up for an agent — by migration or by another agent — is its own to reshape only under its prefix (it keeps writing rows either way). Name it <namespace>_*, or rename it (ALTER TABLE checkins RENAME TO nova_checkins — the views over it follow by oid).
  • Don’t put a big view behind the prefix. A 500-row view with a <namespace>_ name is paid for on every dispatch until it hits chars and gets cut to its first rows. Tables are different — they never render, so the bulk lives in <namespace>_* tables at no context cost, with a small prefixed view over it. A view that must stay big goes without the prefix, and is then not the agent’s to alter or drop, like everything else outside it.
  • Don’t reach for a “state tool”. There is none, deliberately: modelling state is db_execute, reading deeper is db_query, and the context block arrives by itself.

8. Does an agent’s table survive a redeploy?

Yes. A table or view an agent creates is an ordinary object in public. The engine never drops it, never adopts it into your migration line, and migration adoption ignores it (it keys on the tables your db/schema.ts declares). What a redeploy replaces is the brain image — your code, your db/schema.ts, your constitution — not the database.

The thing that is wiped by a redeploy is an uncommitted edit to db/schema.ts inside a running container, because db/ is image-baked and never hot-synced (contract.md § 2). That is a fact about the file, not about the tables.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.