Architecture
A turn, end to end
What happens between a Player sending a Message and the reply being stored. This is the hot path of the whole system: it is where Provider keys are used, where Tools run, and where the only durable record of a Conversation is written.
Before the stream opens
A Player's Message arrives as POST /api/conversations/:id/messages. Everything
that could refuse the turn happens before any bytes of the reply are sent,
because once the body is open the status code is fixed at 200 and the only way
left to report a failure is an error frame — which is a worse thing to render
than a 400.
In order:
- Session.
requireSessionresolves the better-auth session from the request's cookies, or answers 401. - Body. The body is parsed through a zod object schema. A failure is a 400
with
INVALID_BODY. This parse is also the CSRF defence — see Security posture. - Ownership. The Conversation is loaded scoped by the caller's id. A Conversation that exists but belongs to someone else is a 404, indistinguishable from one that does not exist.
- Participants. The Conversation's participants are read, and the first speaker is chosen — the NPC the Player addressed, or the only one there. A Conversation with no NPC in it is a 400.
- Model. The speaker's Provider and Model are resolved against the Village defaults: an NPC that names neither inherits both. If nothing resolves, or if the resolved Provider has no API key on this instance, the turn is refused with a 400 rather than failing mid-stream. An NPC can outlive the key it was created with, so this is a live condition and not a startup check.
Only then is the Player's Message written. The Conversation row is updated
unconditionally in the same step: its title is set from the first 80 characters
if it does not have one yet, and the write bumps updatedAt, which is what makes
the Conversation list's ordering mean "most recently active".
Assembling the turn
The history is read, attributed and bounded before it is sent.
Attribution. In a Party, each prior reply carries the name of the NPC that wrote it, folded into the text of the turn. Without that, an NPC receives every colleague's reply as an unattributed turn and cannot tell three of them apart or tell which answer was its own. A solo Conversation is left byte-identical — there is one voice and prefixing it with its own name spends tokens on something it cannot be confused about (ADR 0017).
Windowing. The history is trimmed to a token budget — 60,000 by default, estimated at four characters per token — keeping the tail and always keeping the final turn even when it alone exceeds the budget. What falls off the front is written into the Conversation's rolling summary first, so an NPC referring back to something agreed earlier still has it.
Context. The system prompt handed to the Provider is assembled in this order:
- The NPC's identity — name, Job Title and description, as a "Who you are" section. It is unconditional and it goes first, so the Player's own instructions read as elaboration on an established identity rather than as competing with one, and so it is the last thing a Provider trimming the prompt would drop.
- The Player's system prompt for that NPC.
- The Skill index: the name and description of every Skill in scope, and nothing more. Instructions are loaded on demand by a Tool call.
- A note naming any Tool server that could not be reached this turn, so an NPC that quietly lost a capability can say so.
- Recalled Memory, retrieved from the Player's question rather than waiting for the NPC to decide to ask for it.
- The Party roster, when there is more than one NPC present. Roster descriptions are truncated to a sentence or 160 characters, because a roster is paid for on every turn.
- The Conversation's rolling summary.
Tools. In parallel, the NPC's grants are read and turned into a Tool set. Every grant is a row: an NPC can call exactly what a Player enabled for it, and a Tool nobody granted is a Tool the model cannot see. See Tool execution.
Streaming
The reply is sent as Server-Sent Events. AgentsVille speaks its own small protocol
rather than the AI SDK's transport: four frame types, discriminated on a type
field inside the JSON rather than by SSE event name, so the browser parses every
frame with one schema.
The part frame carries a Part rather than naming a kind of content, which is
what stops the frame union growing once per feature — reasoning, Tool calls and
Tool results all arrive through the same frame, and so will whatever comes next
(ADR 0007). A Part type this build does not
recognise is ignored rather than fatal, so an SDK release that adds one does not
take a live reply down.
The response sets X-Accel-Buffering: no alongside Cache-Control: no-cache, no-transform. That header is doing more work than the shipped proxy
configurations are: nginx honours it even with buffering otherwise enabled.
The NPC's Message row is inserted before the first token arrives, so the start
frame can carry a real id and the browser can drop its optimistic one before
anything lands. The stream is read from the Provider's full stream, never the
text-only one, because the text-only stream drops error parts — which made a
Provider failure look like a short answer — as well as reasoning and Tool parts.
The same fold that accumulates Parts on the API runs again in the browser, which is what makes a reloaded transcript identical to the one that was streamed. Deltas extend the run they belong to, so a 400-delta reply is one text Part and not four hundred.
Persistence, including the paths that go wrong
Two invariants hold across all three exit paths.
An abort persists what was already produced. The frames are produced by an
async generator, so cancelling the response propagates into a finally block
that aborts the upstream request and writes whatever had accumulated — text,
reasoning and Tool calls alike. A generator can only act on cancellation at a
yield point, so if the Provider has gone silent the teardown waits for its
timeout; with a Provider streaming tokens it is milliseconds. Anything produced
before the generator unwinds is kept, including a Part that arrived after the
Player left: it was produced, so it was billed, and the stored reply should be
what the Provider actually wrote.
Usage is read from the stream's finish part, never from a promise that may never settle on an aborted generation. Usage is recorded on the clean path, the abort path and the Provider-error path (ADR 0003). It stays null when the Provider never reported it.
Each Message is stored twice over: as an ordered list of Parts in a JSON column, and as the flattened text beside it. Lists, titles and search read the string; the transcript reads the Parts. Rows written before Parts existed have no Parts and still render.
Handoffs in a Party
One Player Message can provoke more than one reply. When an NPC hands over with
message_npc, the named NPC answers in the same stream: the history is extended
with the handoff, the next speaker's own context is assembled from scratch, and
its frames are spliced into the same response.
Each hand-over counts against a hop limit, six by default and stored as a Village
setting. Reaching it emits an error frame saying so, rather than stopping
silently — two NPCs consulting each other forever looks exactly like one that
stopped mid-thought unless something says otherwise.
Regenerating
POST /api/conversations/:id/regenerate deletes the trailing NPC Message and
runs the same path again against the Player's last turn. It does not re-post the
Player's text, because that text was persisted before the stream opened and
re-posting it would duplicate the Message.
The routes themselves are listed in HTTP API.