agents
The schema for a set of agents: what each one is, which model it runs on, which prompt it uses, which MCP servers it may reach, and which agents it delegates to.
Everything is a row. Nothing is compiled in, so a change through an API is visible to the next request without a restart — which is the requirement the rest of this follows from.
One read gives a runnable agent
let agent: AgentView = JSON.parse<AgentView>(findById(database, agentsFull(database), "a1"));
{"id":"a1","agentName":"lead","enabled":true,
"prompt":{"id":"p2","promptName":"lead","version":2,"body":"You lead, briefly."},
"config":{"id":"c1","modelId":"m1","temperature":0.2,"maxTokens":8192,"topP":0.95},
"servers":[{"id":"s1","serverName":"filesystem","transport":"stdio","endpoint":"mcp-fs","enabled":true}],
"subAgents":[{"id":"a2","agentName":"scout","enabled":true}]}
One query, not five, and no N+1: each relation is a correlated subquery the database nests, so an agent with three servers and two sub-agents is still one row.
The decisions worth knowing
A model name is a column. models.api_name holds claude-opus-5; label is what a human picks from a list. Renaming the label never changes a request, and moving an agent to a newer model is an UPDATE.
A prompt is versioned and never edited. A change writes a new row with the next version; the agent points at one. Rolling back is an UPDATE, and the version you rolled back from is still there.
That invariant needs guarding, not just stating. persist is an upsert — the right default for a mapper and the wrong one for a create — so POST /prompts carrying an id that already exists used to replace that version's text in place and answer as though it had made a new one, while every agent pointing at it silently changed behaviour. Now the version and the id are both assigned by the server, and a taken id is refused by name. The same guard is on every create in the API: a POST that overwrites is a PUT wearing the wrong verb.
Config is separate from the model, because two agents on one model routinely want different knobs. extra carries whatever a provider accepts that the named columns do not, so an unfamiliar parameter needs no migration.
Sub-agents read one level at a time. subAgents names an agent's children, not its grandchildren. A cycle in the delegation graph is therefore a row rather than an infinite query — and the suite inserts one to prove it. What refuses to walk the cycle is the run; see Delegation below.
The schema is generated from the mappings. schemaPlan builds each CREATE TABLE with createTableSql, so the schema a program expects and the schema a migration builds cannot drift. Only the two link tables are written by hand, because they hold keys rather than an entity.
Serving it
api.ts is the schema behind a rest controller:
GET /agents every agent, ordered, with ?enabled=true
GET /agents/:id the whole agent in one query
POST /agents create
PUT /agents/:id edit the whole row — name, description, model config,
prompt, enabled, default
POST /agents/:id/servers attach an MCP server
DELETE /agents/:id
Every read goes to the database. Nothing is cached and nothing is compiled in, so a change — through this API or from anything else touching the same tables — is visible to the very next request. That is met by not doing the thing that would break it, rather than by machinery.
The database it opens
AGENTS_PG_HOST set means PostgreSQL: AGENTS_PG_DATABASE (default agents), AGENTS_PG_USER (default agents), AGENTS_PG_PASSWORD. Unset means SQLite at AGENTS_DB_FILE (default /tmp/agents_api.db) - zero setup, right for trying it, wrong for keeping anything. Migrations run at boot either way; a migration that fails stops the server rather than being logged.
Two features ask for PostgreSQL by name: knowledge (pgvector - SQLite has no vector type) and the indexing queue. Everything else runs on both.
AGENTS_PG_HOST=127.0.0.1 AGENTS_PG_PASSWORD=... ./api # postgres
./api # sqlite, throwaway
Credentials, over the API
GET /providers which providers have a key — names only
GET /providers/:provider {"provider":"mistral","configured":true}
PUT /providers/:provider/key {"apiKey":"sk-..."} — stored encrypted
DELETE /providers/:provider/key
A key can be written and named; it can never be read back. Nothing in this module returns an envelope or a plaintext, so a listing endpoint cannot leak one by accident, and configured answers the only question a caller actually has.
The server refuses to start without a usable LUMEN_MASTER_KEY, rather than serving with credentials it cannot open and failing every provider call later, far from the cause.
More than one person, if a proxy says so
There are no users here, no roles and no login. There is one column — a thread's owner — and one opaque tag, which a proxy in front sets as an X-USER header. Every /threads/:id/... route resolves the id through ownedThread, the sidebar filters on owner in SQL, and a thread belonging to somebody else answers 404, the same as one that never existed.
It is off unless you say otherwise:
AGENTS_TRUST_PROXY_AUTH=on # anything but 1/true/yes/on is off
Unset — the default, and what every deployment has run so far — the header is not read at all. Not parsed, not compared. Nothing is scoped, nothing is stamped, and the behaviour is what it was before the column existed.
Set, the header is the whole of the identity, so :8100 must not be reachable by anything but that proxy. Whoever can send an X-USER chooses who they are. Firewall first; turn this on second. The tag is the uuid of a JSON X-USER document if it is one, and otherwise the header verbatim — so an nginx with basic-auth in front, setting X-USER: alice, is a working multi-user deployment.
A JSON document the engine cannot read a uuid out of — an anonymous user, a renamed field — is 401 at the door, not a caller with no tag. The proxy said it authenticated somebody it cannot name, and the only other answer would be to guess, which used to mean handing over everything owned by nobody.
Rows written before the gate went on carry owner = '' and belong to nobody: the filter is exact equality, never '' OR tag, because the other reading hands the whole of the box's history to whoever authenticates first. Claiming them is deliberate — scenarios/backfill_owner.py.
One exception, on purpose: /preview/:token is a capability. Whoever holds the link reads that conversation's artifacts, owner or not. That is what makes a link shareable with someone who has no account, and POST /threads/:id/artifacts/:slot/rotate is how you take it back.
The ceilings, and who may knock
Three byte caps, all of them defaulting to the number they were when they were constants — set none of these and nothing changes:
AGENTS_ARTIFACT_BYTES_MAX=524288 # one artifact body (a large deployment runs 29360128 — see TELEGRAM-FILES.md)
AGENTS_THREAD_BYTES_MAX=104857600 # one thread's artifacts, every version
AGENTS_UPLOAD_BYTES_MAX=1048576 # one workspace file
The upload cap is new rather than moved: POST /threads/:id/files had none, and neither did the model's own write_file. Both go through putFile, so one number closes all three doors, pull from the corpus included. Anything unreadable — 512MB, 0, a stray quote — is the default, because this is read while the process starts and a typo in a unit file should not be a dead engine. The engine enforces the same limits for everyone; per-tenant quotas belong to a control plane, fed by /usage below.
AGENTS_API_TOKEN= # unset: no token wanted, as today
Set, every route wants Authorization: Bearer <token> and answers 401 without one — before the router matches, so an unauthorised caller cannot even map the paths. Defense in depth and nothing more: the firewall is what isolates :8100. It matters because with the trust gate on, reaching the port at all means choosing an identity with no forgery required, and firewall-only means one missed rule is the whole breach.
/healthz is the one route it never covers — a probe that needs the secret cannot tell "the engine is down" from "the secret is wrong".
GET /healthz {"version":"0.2.0","migration":"76","docker":true}
GET /usage?owner=u-alice
{"owner":"u-alice","bytes":12288,"inputTokens":40,"outputTokens":9}
There is no summary ok in the health document. The process refuses to start on a schema it could not migrate and refuses to start without a usable master key, so an answer at all means the two fatal things are fine; docker being down degrades scripts and nothing else. A boolean over facts of different weights has to lie about one of them.
/usage is generic accounting — no plan, no quota, no price. Bytes are the owner's artifact versions, exactly, from the byte column each one already carries; workspace files are not counted, because summing them means asking SQL for a length, which counts characters and would under-report every non-ASCII upload. Tokens are summed from the run rows, which is why runs carry input_tokens and output_tokens at all — every run had those numbers in hand and dropped them. Both go out as JSON numbers straight from the database's own sum, never parsed: an int here is i32, and two billion tokens is a month.
?owner= is a filter and never an escalation. A scoped caller asking about a tag it does not hold gets the same 404 a thread it does not own gets.
Collecting abandoned opens, if you ask for it
AGENTS_SWEEP_IDLE_MS= # unset: nothing is ever deleted
Nothing in this engine has ever deleted a thread row, and unset it still does not. Set to a number of milliseconds, a background thread deletes threads older than that which hold nothing at all — no turn, no artifact, no step, no uploaded file and no run — and waits the same interval between passes. The last two clauses are the ones an "empty means no turns" reading gets wrong: a thread opened by dropping a file in has no turn until the first question, and a thread whose first round failed at the provider has only its runs row while the person is still looking at the error with Retry in front of them.
It is not on a request path and will not be. A read that deletes rows is, under scoping, one person's sidebar deleting somebody else's conversations.
Talking to what the rows describe
mcp.ts mounts an MCP server from its row, and provider.ts calls a model from its row. Neither file names a server or a model.
mounting demo-mcp at http://127.0.0.1:8200
initialize ok=true
tools add, echo
add(2,40) 42
disabled ok=false demo-mcp is disabled
model Mistral Small -> mistral-small-latest
ok=true status=200
disabled Mistral Small is disabled
In both, the last line is the point: enabled was flipped in the database and the next call refused, with no restart.
MCP is HTTP-transport only. The other transport is stdio, which needs to spawn a process, and Lumen has no subprocess API — so a stdio server has to be fronted by something speaking HTTP. The client says exactly that rather than failing obscurely.
Credentials
A provider's API key is a row, encrypted:
storeCredential(db, { provider: "mistral", apiKey: "sk-...", masterKey: masterKey(), now: now });
complete(model, config, prompt, text, credentialFor(db, "mistral", masterKey()));
stored for mistral
envelope yV23Bn0vGDrN3+lEt04PhvRyL4jYo1JVWajaKcaW7m3J…
plaintext in the row? false
call ok=true status=200
wrong master ok=false no API key for mistral
The ciphertext is a row; the key that opens it is not. LUMEN_MASTER_KEY comes from the environment, so the database never sees it. Encrypting a credential with a key stored beside it protects nothing. The trade is real and intended: losing the environment loses the credentials.
AES-256-GCM, so it is authenticated — a row edited in the database refuses to open rather than decrypting to something plausible that would then be sent to a provider. A test does exactly that edit.
Every failure to open answers the same way: absent, wrong master key, and tampered all return "". A caller that could tell them apart could use this table to test master keys against.
An empty key is refused at the door, because an empty plaintext encrypts to a valid envelope that decrypts to "" — indistinguishable from a failure.
providersWithCredentials returns names only; nothing in this module returns an envelope, so a listing endpoint cannot leak one by accident.
Running an agent
let answer = runAgent(db, "a1", "What is 2 plus 40?", masterKey());
answer.text // "42"
answer.promptVersion // which prompt actually served it
answer.modelApiName // which model actually served it
agent=calculator prompt=v1 model=mistral-small-latest
ok=true status=200
reply 42
after UPDATE: prompt=v2 ok=true
disabled -> calculator is disabled
The prompt, the model, its wire name, the temperature and the key are all rows. Rolling a prompt back or moving an agent to another model is an UPDATE, and it takes effect on the next call.
Each row is read on its own rather than through agentsFull. A relation that matches nothing is null, and a run needs its prompt, config and model to exist — so a dangling reference is named rather than turned into a parse failure against a type that declares them present.
The run reports which prompt version and model answered, so a caller records what happened rather than what it assumed would.
Consuming it, end to end
The whole consumer surface is four calls. Open a thread on an agent, say something, read the transcript, watch the steps:
curl -X POST :8100/threads -d '{"agentId":"a1"}'
# -> {"id":"t1",...}
curl -X POST :8100/threads/t1/messages -d '{"text":"What changed this week?"}'
# -> the reply, with the run recorded
curl :8100/threads/t1 # the transcript
curl :8100/threads/t1/steps # tool calls, retrievals, delegations as they ran
A thread opened with {"agentId":"a1","title":"...","context":"..."} via /threads/from-story is seeded with text the CALLER supplies - a reader attaching an article, a pipeline attaching a report. The engine does not know where the text came from.
Knowledge rides the same pattern (PostgreSQL only): upload, let the queue index, and agents whose scopes match retrieve it on their own.
curl -X POST ':8100/documents?model=m-embed' \
-d '{"id":"d1","source":"handbook","scope":"/policies","body":"..."}'
# -> 202 Accepted: queued
curl ':8100/jobs' # the indexing journal
curl ':8100/documents?scope=/policies' # queued and failed float to the top
Sub-agents are rows, not configuration files: an agent's subAgents list (see the one-read view above) is who it may delegate to, and the Delegation section below is how a run decides to. /completions is the fourth surface, for callers that want one answer rather than a conversation.
Calling tools
An agent reaches an MCP server because a row links the two. That link is all it takes — the tools are fetched from the server on each run, described to the model in its own format, called, and their results fed back until the model stops asking.
mounted 2 tools from 1 server(s)
- warehouse_stock: How many units of a part are in a named warehouse.
- part_price: The unit price of a part in euros.
user We need 40 units of A-114. Is there enough in Rotterdam, and what
would 40 cost?
-- what the model did (context) --------------------------------
0 ok parts.warehouse_stock {"part": "A-114", "warehouse": "Rotterdam"}
-> 37 units of A-114 in Rotterdam.
1 ok parts.part_price {"part": "A-114"}
-> A-114 costs EUR 12.50 per unit.
rounds 2, stopped: final
-- what the user sees (conversation) ---------------------------
agent There are 37 units of A-114 in Rotterdam, so you're short by 3.
The cost for 40 units would be EUR 500.00 (40 × €12.50).
examples/run-with-tools.ts, against a live model and a live MCP server. Nothing in it names a tool: INSERT INTO agent_mcp_servers is what gave the agent both of them.
A run has two kinds of state and they are different things. The context is everything the model was shown — every turn, every call, every result. The conversation is what a person reads. Neither is a filter of the other: the context holds a tool's four thousand lines of output that nobody wants to read, and a transcript holds nothing about the six calls behind one sentence. AgentRun carries both, separately, and text is the only field meant for display.
Tool results are asked for, not cached. Each run asks every linked server what it offers. That is a round trip per server per run, and it is deliberate: a cached list is a list that is wrong the first time somebody deploys a tool.
What could not be mounted is reported, never silent. A disabled server, a stdio one, an unreachable one — the agent still answers, and notes says what it answered without. A tool the model was never told about is a failure that looks exactly like a bad answer.
A failed tool call goes back to the model, not to the caller. A tool that reports an error, or a name the model invented, comes back as a result it can act on. Stopping the run instead would turn a recoverable mistake into a dead one.
The step budget bounds calls, not just rounds. One reply can ask for an unbounded number of calls, so both are counted against the same budget.
Three formats, not one abstraction: OpenAI and Mistral wrap a tool in a function object and send arguments as a string holding JSON; Anthropic names the schema input_schema, carries calls as content blocks, and needs every result of one turn inside a single user message. Those are the differences, and wire.test.ts is the record of them.
Delegation
An agent's children are tools too. A parent that can ask a specialist is the same shape as a parent that can read a file — a name, a description of when to use it, one argument — so one loop, one trace and one budget cover both.
user Can we ship 40 units of A-114 from Rotterdam today, and what is the bill?
0 ok parts-desk.ask_parts-desk {"question":"What is the price and stock level
of part A-114 in Rotterdam?"}
-> A-114 costs €12.50 per unit, and there are 37 units in Rotterdam.
1 ok parts-desk.ask_parts-desk {"question":"Can we ship 40 units of A-114
from Rotterdam today?"}
-> No, only 37 units are available in Rotterdam.
lead We cannot ship 40 units today, as only 37 are in stock; the bill for
37 would be €462.50.
examples/delegate.ts. The lead reaches no MCP server at all — two rows give it everything: one links the desk to the parts server, one makes the desk the lead's child.
A child is a separate run. It has its own prompt, its own model, its own tools and its own context. It cannot see the parent's conversation, which is why the argument's description insists the question repeat every name it depends on. That is not a style note: asked "stock of A-114?" with Rotterdam left out, a child picked a warehouse, its lookup failed, and it answered "no stock" — which the parent passed on as fact. The wording that stops it is in delegateSchema, and the reason is in the comment beside it.
A cycle is refused by name, not by the depth limit. agent_sub_agents accepts a cycle deliberately — the schema suite inserts one — so a run also refuses to enter an agent already on its path. Stopping three levels down would report the wrong cause.
Past the depth limit an agent runs alone, rather than not at all. Refusing the whole run because a child was out of reach turns a bounded plan into no answer.
What went wrong below surfaces above. A child's own notes are re-reported under its name, and a child that answered after its tool calls failed is recorded as such — in the notes, never in the result, because the result goes to the model and a warning it can quote is a warning that reaches the user as an answer.
Evaluations
Cases live in a Langfuse dataset, so the people who write them do not need a programmer. Running them is a request, not a deployment — the agent, its sub-agents, its tools and the judge are all rows.
let out = runEvals(db, {
agentId: "a1",
judgeAgentId: "judge1",
dataset: "parts-desk-evals",
runName: "nightly",
master: master,
maxItems: 50,
}, tracerFor(db, master));
A case says what to ask, what a good answer looks like, and — when it cares — the route the run should take:
{"input": {"question": "Can we ship 40 units of A-114 from Rotterdam today?"},
"expectedOutput": {"answer": "No — only 37 units are in stock in Rotterdam.",
"tools": ["warehouse_stock", "part_price"],
"agents": ["parts-desk"]}}
The route is scored separately from the answer, and that is the point:
PASS answer 1 tools 0.5 agents 1 Can we ship 40 units of A-114 today?
route : tools [warehouse_stock] agents [parts-desk]
MISSING tools : part_price
The answer was right and the run never called part_price, so it had no basis for the bill it quoted. An outcome-only suite records that as a clean pass and finds out when the prices change. The mirror case is an agent that answers a stock question from its own head — agents 0, no delegation — which reads as a good answer until the stock moves.
So tool-use and delegation go to Langfuse as their own scores beside correctness. Averaging them into one number would hide both failures.
What was reached is collected from the whole tree. AgentRun.steps holds only what that agent did; a tool called inside a sub-agent appears nowhere in it. calledTools and calledAgents accumulate through the delegations, because "did this run reach the stock tool" is a question about the tree and not about the top of it.
A case that names no route cannot fail one — it scores 1, rather than being dragged to zero by a check it never asked for.
The judge is an agent, so which model judges and how strictly are rows. With none configured, a built-in judge compares the numbers in the reference against the answer and says that is what it did; a suite that refuses to run until someone sets up a judge is a suite nobody sets up a judge for. A judge that was configured and failed to run is reported rather than fallen back from — those are different problems, and grading anyway would hide one. A judge answering in prose has not judged, which is not the same as scoring zero.
The backend is a row, not an assumption. trace_config.backend names it — langfuse, otlp, braintrust, langsmith, phoenix, arize — and the tracing package turns that name into how a request authenticates, which extra headers it wants, which attribute scheme it reads, and which encoding it takes.
Researching five vendors changed the design twice. One extra header was not enough: Arize AX wants space_id and api_key together, so extras are a list. And an attribute prefix was the wrong abstraction entirely — OpenInference names things by meaning, not by vendor (openinference.span.kind, input.value, llm.model_name), which no prefix substitution reaches. What varies is the whole scheme.
Sending to a real Phoenix then found a third thing, which no amount of reading had: it answers 415 Unsupported content type: application/json. OTLP defines a JSON mapping and a protobuf one, and a receiver may implement either. This package writes JSON, so a protobuf-only backend is refused before the request is made rather than after someone else's server rejects it. Against a plain OpenTelemetry collector the same run produces the same 15-span tree with the standard gen_ai.* attributes and no langfuse.* at all; against Langfuse it carries both. Datasets are the one thing that does not generalise — they are not an OpenTelemetry concept, so a backend either has an API for them or says it has none, and evaluations report that rather than building a URL that 404s.
Langfuse's own LLM-as-a-judge can run alongside this one: it scores server-side on new dataset runs, needs an LLM connection configured in Langfuse, and in v3.224 its evaluator config has no public API. Both scores then sit on the same trace, and they do disagree at the margins — which is an argument for keeping both rather than trusting either alone.
Retrieval
Documents live in folders and an agent reads the folders it is granted.
uploaded /engineering/plume chunks=1
uploaded /hr/policies chunks=1
engineer granted /engineering
people granted /hr
-- eng: How many days of annual leave do we get?
read /engineering/plume/plume_relations distance 0.377
said Your documents do not cover it.
-- hr: How many days of annual leave do we get?
read /hr/policies/leave_policy distance 0.147
said You get 28 days of annual leave, including public holidays.
examples/scoped-rag.ts, against PostgreSQL. One table, one embedding model, one question — and the engineer cannot see the HR document. The isolation is the agent_scopes rows, not separate corpora, which is what makes it survive somebody adding a folder.
That was a disclosure before scopes existed: retrieve filtered on model_id alone, so any two agents sharing an embedding model read each other's documents. A demo with one corpus never shows it.
Scopes match on segment boundaries. /specs covers /specs/plume and must not cover /specifications. A string prefix would hand a folder to anyone granted a name that starts the same way.
An empty grant reads nothing, never everything. The alternative makes revoking an agent's access the most destructive edit available, and the failure looks like the system working.
Retrieval happens in the run, not in the caller. It was a thing you did before calling runAgent, which meant an agent's knowledge lived in whoever remembered to fetch it — two callers of the same agent got different behaviour. Now agent_scopes says what it may read and agent_retrieval says how.
Every way it can come up short is in notes — not PostgreSQL, switched off, no embedding model, no scopes, no credential, nothing close enough. An agent that answered without its documents looks exactly like one that answered from them.
One prompt, one answer: POST /completions
For a caller that is a service rather than a conversation. Name a model choice from the operator's menu (or a config directly), send system and input, get the reply text and the token counts back. No router on purpose: a router costs a second completion to pick a model the caller here has already picked.
POST /completions
{"modelConfigId":"c1","system":"Answer briefly.","input":"What is Plume?","maxTokens":400}
-> {"ok":true,"text":"...","model":"...","inputTokens":12,"outputTokens":48,"runId":"..."}
Every call lands in the runs table under route note completions, failed ones included - a failed call spends real time and often real tokens, and an unrecorded failure is invisible spend. A daemon calling the port directly names itself in X-USER like any other trusted-proxy caller.
Retrieval, the older way
pgvector, so PostgreSQL only — SQLite and MySQL have no vector type. An agent runs anywhere; it retrieves against Postgres.
let width = embedText(embedModel, "probe", key).dimensions; // ask the model
createDocuments(db, width);
indexDocument(db, embedModel, {
id: "d1", source: "plume", scope: "/specs/plume",
body: "Plume maps records to tables…",
}, key);
let found = retrieve(db, embedModel, ["/specs/plume"], "Why is an unordered page refused?", 2, key);
runAgent(db, "a1", asContext(found.found) + "\nQuestion: " + question, masterKey());
embedding width 1024
indexed 3 documents
retrieved plume/d2 distance 0.19052260549339184
retrieved plume/d1 distance 0.23477740073896414
Plume refuses an unordered page because two requests for the first twenty rows
can overlap or skip records when the database answers in any order.
The embedding model is a row like every other. models carries kind — "chat" or "embedding" — and dimensions, the width of the vectors it produces. Pointing a corpus at a different embedding model is an INSERT and an UPDATE, not an edit to any file.
That makes three things checkable that were not:
- A chat model cannot embed. A provider offers both and they answer different endpoints; refusing here beats a 404 from the provider.
- A corpus needs a width.
createDocumentsrefuses a model that does not say how wide its vectors are, rather than creating a column of some default. - A search only sees its own model's chunks.
documents.model_idrecords which model embedded each one, andretrievefilters on it. Two models' vectors sit at the same width and are not comparable; mixing them returns confident nonsense rather than an error.
And what the model says it produces is checked against what it returned, so a mismatch is a sentence rather than a wire error about column widths.
The query vector is bound, not interpolated: it came from a provider's reply and is data like any other. distance is returned so a caller can decide what is too far, rather than trusting the ranking blindly.
A turn is accepted, then streamed
POST /threads/:id/messages used to run the whole turn on the request that started it. A turn that delegates to a container outlives any proxy deadline, so a request held open for one is a request that gets abandoned while the work carries on — the caller is told it failed, the engine finished it, and both are looking at the same turn.
It answers 202 now, naming the round the turn will appear under:
{ "accepted": true, "threadId": "...", "seq": 0, "state": "running" }
The turn runs on a worker of its own with its own connection, and three routes carry the rest:
| route | what it gives |
|---|---|
GET /threads/:id/steps | the round as it stands, now with state |
GET /threads/:id/answer?seq= | what the turn produced, once it has |
GET :8101/feed | every change, pushed, as server-sent events |
state is running from the moment the turn is accepted — before a step exists, which running alone could never say — and done once it settles.
The feed
thread_feed holds one row per thread and kind, carrying the millisecond it last changed. beginStep, endStepAt, recordPartial, recordThought and the artifact write path bump it; the row is overwritten rather than appended to, so a hundred token writes in a second coalesce into one notice and a subscriber that has been away sees one row per thread that changed rather than a backlog.
feed-server.ts serves that on its own port (AGENTS_EVENTS_PORT, 8101), over packages/sse. It tails the table once for every subscriber, so the cost of watching is no longer per watcher: a console that polled a running round seven times a second, per browser, now reads it once when a browser starts watching and is pushed everything after that.
A round event carries the whole round view; an artifacts event carries the thread id alone, so a reader with its own credentials does the reading.
Resuming. The event id is the stamp, and a subscriber reconnects with Last-Event-ID. The tail resumes at >= cursor rather than > cursor, which delivers what shares that millisecond twice rather than never — a notice is a statement about the current state, so delivering one twice costs a reread and losing one costs the turn. Because the rows are overwritten rather than appended, there is no window to fall out of: any cursor, however old, replays exactly the threads that have changed since.
A stream closes itself after ten minutes and the subscriber reconnects with its cursor. Socket.write cannot report a peer that has gone, so a bounded life is what stops a vanished subscriber holding a thread and a connection forever.
The console
A web frontend for this package lives in app/: a Lit element built on LumenUI's <nr-chatbot>, served by LumenJS, talking to the API above through one origin. The console is its own server, so /api and /preview are forwarded by the app rather than by an nginx in front of it — one place those rules live, in app/server/api-proxy.ts.
Run everything — database, API, indexer, console — with one command:
cd packages/agents
cp .env.example .env # set LUMEN_MASTER_KEY (openssl rand -hex 16)
cd app
docker compose up --build
Console on http://localhost:8080, the API behind it on /api, PostgreSQL with pgvector underneath so documents and retrieval work. It publishes to loopback: nobody signs in to this deployment, so whoever reaches the port is the operator. app/compose.yaml says what to change to expose it, and what to put in front of it when you do.
One image, ghcr.io/nuralyio/agents-console, serves every deployment. The AUTH variable is read once at boot and chooses between three: none is the box above; builtin gives the console its own sign-in and its own user store; proxy trusts an X-USER set by an authenticating gateway in front. Nothing under app/src/ knows which one it is running under — see app/CLAUDE.md, and GATEWAY.md for the hosted arrangement.
The console has a chat surface per conversation, a workspace panel over the thread's files, a Knowledge page — folder rail, the folder's sources, and upload, where typing a new path is how a folder is created, since a scope exists by carrying documents — and a settings overlay reached from the account block: agents (an edit form per agent), models, prompt versions, MCP servers, provider keys and tracing.
One PUT per resource
A row is edited by sending the row. There is no route per attribute: the rules that must hold — exactly one default agent, exactly one enabled embedding model — live in the row's own PUT, so they hold whichever way the row is written. A rule enforced at one door and not another is not enforced.
PUT /agents/:id the whole agent row
PUT /models/:id the whole model row
PUT /servers/:id the whole MCP server row
What is not an attribute keeps its own route: POST /models/:id/test calls the model and reports what happened; PUT /servers/:id/auth carries a token that never becomes a column, since it goes to the encrypted store; the link collections (/servers, /sub-agents, /scopes) add and remove relationships, which a row cannot express; and /retrieval is its own record.
Indexing is a queue
Uploading a document writes a job and answers 202 — the work is taken, not done. A worker drains the queue: agents-indexer, its own process in the compose file, because embedding a corpus is one model call per chunk and must not compete with serving requests. Two workers can run against one database; the claim is a single UPDATE … FOR UPDATE SKIP LOCKED, so they never take the same job.
The queue is a table rather than a broker. PostgreSQL is already required for documents, so a job table adds no service — and the rows are the journal the console renders, which a broker would not keep. GET /jobs is that journal; GET /documents?scope= puts queued and failed rows above the indexed ones, so a file you just dropped is visible instead of looking lost.