Agent Engine stores agent definitions and the
run journal, plus a thin
agent_tools link — the
choice of optional tools. An agent's identity is not a
separate entity but an FK to its owner (agents); the weekly budget is not a counter table but a
sum over agent_runs. Knowledge lives in the Knowledge Store, and the weekly
budget cap lives in platform_settings; the only foreign data kept here is two references —
the owner's user_id and the chosen model's model_id —
everything else is
read from neighbors on the fly.
agentsWhat the employee set up: a name, a prompt (its composition layer), a schedule, and two locks — the owner's switch and the admin's sticky lock. The FK to the owner does double duty: both the delete cascade and the identity under which the agent reads the knowledge base.
| id | BigInteger | PK | — |
| user_id | BigInteger | FK→usersIDX | owner · CASCADE · their ACL = the agent's identity before the KS |
| name | Text | NOT NULL | agent name — for the “my agents” list |
| description | Text | NULL | short description — the agent card subtitle · optional · not prompt: for the human, not the model |
| prompt | Text | NOT NULL | owner layer · composed beneath the platform prompt at runtime |
| schedule | JSONB | NULL | simple schedule — a tagged union (interval / calendar) · NULL = manual run only · named by meaning, not meta |
| model_id | BigInteger | FK→agent_modelsNULLIDX | run model · owner's choice from the allowed list · SET NULL: model removed from the list → NULL = agent stopped, owner picks again |
| enabled | Boolean | NOT NULLDEFAULT | owner switch · true = agent live (scheduled + manual); false — full stop |
| admin_paused | Boolean | NOT NULLDEFAULT | admin's sticky lock · the owner cannot release it (two locks) |
| next_run_at | DateTime(tz) | NULLIDX | computed next slot · NULL = manual only / switched off by owner / locked by admin · the scheduler scans by it |
| meta | JSONB | NULL | flexible agent attributes |
| created_at | DateTime(tz) | DEFAULT | now() |
| updated_at | DateTime(tz) | DEFAULT | now() + trigger · edits to prompt, schedule, stops |
user_id leads straight to the creator's ACL, and the cascade
runs down the same edge. There is no separate table for identities, service
accounts, or a “whose access” choice; v2,
the full identity model.
Linking is cheap: the employee's identity is already reconciled with their account
by the identity.user_id bridge in the
identity model.
enabled is the owner's switch,
admin_paused is the admin's lock; these are
different axes, not one state field. The agent
starts — scheduled or manual — only if it is enabled by the
owner and not locked by the admin. The admin's lock is sticky:
the owner edits their own flag, not the other. Owner deactivation adds no
third axis: it writes the same enabled=false,
and only the owner themselves may restore it
(life
cycle).
model_id points at a row in
agent_models — the list of chat models the admin has allowed
for agents. The owner picks from it; on creation the field is
set (preselected — the list default). There is no implicit
runtime default: NULL does not mean
“take the default” but “no model — agent stopped”. Two paths
lead here: the model is not yet chosen, or the admin removed it from the list —
ON DELETE SET NULL clears the reference, and
model_id IS NOT NULL becomes the fourth condition for
starting.
Referencing a list row rather than the model catalog is deliberate: removal
stops the agent at once, with no “is the model still in the list” check, and
re-adding it does not silently revive it — the owner
confirms the choice.
schedule — a tagged union on type, not free-form JSON
type) — the source
of truth; the surface editor receives it typed. Two
variants. Interval —
{ "type": "interval", "every_hours": N }, a
“every N hours” period. Calendar —
{ "type": "calendar", "cadence": "daily" | "weekly", "weekday": 0–6, "time": "HH:MM" }:
weekday (0 = Monday) matters only for
weekly, and time is the
owner's local time.
NULL — no schedule, the agent is purely manual.
The day encoding and time format are the same as
the platform's curation_* (weekday 0–6, 'HH:MM') —
we do not introduce a second schedule dialect.
agent_runs
A row per run — both the ones that happened and the ones rejected. A state
machine (queued → running → succeeded / failed)
plus the agent's output and token spend. The journal is the home
of the result (output)
and the source of the weekly budget: its tokens_used are summed
against the cap.
| id | BigInteger | PK | — |
| agent_id | BigInteger | FK→agentsIDX | whose run · CASCADE · its history |
| trigger | Text | NOT NULLCHECK | how it started · manual · scheduled |
| state | Text | NOT NULLCHECK | queued · running · succeeded · failed · skipped |
| reason | Text | NULLCHECK | why skipped / failed · machine code, not free text · skipped: budget_exceeded · already_running · failed: iteration_cap · error · stale |
| output | Text | NULL | agent's final answer · the whole output, read on the agent screen |
| tokens_used | BigInteger | NOT NULLDEFAULT | run spend · summed into the weekly budget |
| error | Text | NULL | failure detail on failed |
| started_at | DateTime(tz) | NULL | when it entered running · NULL for skipped |
| finished_at | DateTime(tz) | NULLIDX | when it finished · the weekly budget window is measured by it |
| heartbeat_at | DateTime(tz) | NULL | run worker's liveness · staleness releases the lock of a hung running |
| created_at | DateTime(tz) | DEFAULT | now() · queued |
queued on enqueue,
running on start, terminal at the end — this is
an in-place UPDATE. But the run's progress is carried by the domain
milestones started_at / finished_at /
heartbeat_at (a heartbeat every ~30 s — finer than
a generic updated_at), so a separate
updated_at is redundant: the run journal carries only
created_at. The same run-journal pattern as
sync_runs (Harvester) and curation_runs /
backup_snapshots (Knowledge Store).
UNIQUE (agent_id) WHERE state IN ('queued','running'):
an agent has at most one unfinished run. A race (a manual
start coinciding with an arrived slot, two workers grabbing the row)
is settled by the database itself — the second INSERT of an unfinished
run hits the lock; the engine catches the rejection and writes a
terminal skipped with
reason=already_running (that state is outside the lock's
predicate, so it does not block the write). If a worker dies without
closing the run, unattended the row would hang in running forever: it would
both lock the agent and hold tokens outside the budget. It is cleared by
heartbeat_at: a stale run is reaped into
failed with reason=stale, the lock is
released, and the agent starts again. The same lock and the same
heartbeat are carried by Harvester's sync_runs and Knowledge Store's
curation_runs — a single run-journal
pattern across the platform. The staleness threshold is a module
constant (proximity principle), not a magic number in the reaper's
code: the worker bumps heartbeat_at every ~30 s, a run
is considered hung after ~90 s of silence (three missed beats) — the same
order of magnitude as the neighbors' run journals.
tokens_used, not a counter
SUM(tokens_used) over the runs of all the owner's agents
where finished_at falls in the current week (calendar week,
Sunday 00:00 in the organization's zone —
budget).
Windowing by finished_at means a still-running
run's spend enters the sum only at the end: the run starting next sees
it as zero, and within a week a small cap
overrun is possible, tapering to nothing as runs finish. For
rare personal runs the overrun is narrow and acceptable. At this scale
(personal agents, rare runs) the sum copes without an aggregate;
a materialized counter is an optimization for later. The limit itself
is not stored here — it is a platform value, in
platform_settings (boundary).
skipped is a record, not the absence of a run
state=skipped row is written only when the engine
set out to start a run but the runtime gate closed:
the weekly budget is spent (budget_exceeded) or
the agent's previous run is still going (already_running).
Both are invisible from the agent's static config — which is why the
record is needed: the rejection is visible in the journal (“limit exceeded”) rather than lost
in silence; tokens_used = 0, no loop ran. The locks, by contrast,
stop the agent before the gate: with the owner's switch
or the admin's lock, next_run_at
is cleared, the scheduler does not scan the agent, and the manual
start button is disabled — a durable stop spawns no rows, it is shown by the
agent's status,
not by a stream of skipped.
agent_toolsWhich optional tools the owner attached to the agent on top of the core — a thin many-to-many link between agents and the catalog (tools). The core three KS tools are locked and not stored in the table — every agent has them by default; the rows here are only about the selection on top of it.
| id | BigInteger | PK | — |
| agent_id | BigInteger | FK→agentsIDX | whose selection · CASCADE · goes away with the agent |
| tool_id | BigInteger | FK→toolsIDX | which tool from the catalog · CASCADE |
| created_at | DateTime(tz) | DEFAULT | now() · append-only selection link, no updated_at |
tools.agents_allowed =
false), the link row stays, but the runtime
does not offer it in the toolset, filtering the selection by the current
flag at the run's start. If the tool itself is deleted from the catalog
(or the agent altogether), the link is cleaned up by ON DELETE
CASCADE. In both cases the agent
continues on the KS core; this does not enter the
start gate
— unlike a removed model, which stops the run.
Write access and MCP tool sources are v2.
Agent Engine keeps agents and runs — and only that. Everything
else the agent touches belongs to neighbors: account and identity —
Auth and Knowledge Store; the model registry and budget cap — Admin; the
knowledge itself — Knowledge Store. The only foreign data kept here is
two references — the owner's user_id and the chosen model's
model_id; the budget cap is not even kept as a
reference — the engine reads it from platform_settings at the start of each
run.
agents (definitions) and agent_runs (the journal
with output and spend). It does not store identity — it takes the ACL by
user_id. It does not store the model — model_id
references the allowed list. It does not store the weekly spend
as an aggregate — it sums from runs. It does not store the cap — it reads it
from platform_settings. It does not store knowledge — it reads from the KS under permissions.
users and identities — Auth and
Knowledge Store; allowed models
(agent_models) and the token cap
(platform_settings) — Admin; the body of knowledge, the graph, the vectors
— Knowledge Store, from which the agent only reads.