Chat Turns #
Summary #
A turn is one user message and the assistant reply it asks for. The web UI starts a turn by posting a message. A direct-PDS client starts one by writing a user message with a generation object, which the server notices through sync. Either way, one function, startTurn, runs it. It claims the reply by writing a pending placeholder at the reply's deterministic key, resolves the model, builds the prompt from the conversation's branch, and streams the reply through the Vercel AI SDK. While generating, it forwards progress to the web UI over server-sent events. When the model finishes, it writes the final reply record. Regenerating, editing, and cancelling are variations on the same flow.
Motivation #
This is the core of the app. It has to honor the direct-write conventions in lexicons/README.md exactly, so a conversation behaves the same whether the web UI or a hand-written record started it. Claiming the reply with a create-only write at a deterministic key guarantees one generation per attempt, even when the web UI and sync notice the same message.
Design #
Starting a turn from the web UI #
POST /api/conversations/:skey/messages with:
{ parent?: string; parts: Part[]; generation?: { model?: ModelRef; effort?: Effort; tools?: string[] } }
parts are text parts plus the attachment parts returned by the attachments API. The server builds the user message record, with a new TID key, role: "user", the parent, content as plainContent, the generation request with attempt 0, and createdAt. It writes the record with createMessage, then calls startTurn when a generation request is present. The response returns the user message key and the reply key.
An edit is a new message with the same parent as the one being edited. The web UI sends it through this same route.
POST /api/conversations/:skey/messages/:rkey/regenerate with an optional model and effort rewrites the user message with putMessage. It raises generation.attempt by one and applies the new model and effort. Then it calls startTurn.
POST /api/conversations/:skey/messages/:rkey/cancel aborts the running generation for that reply key, if this server is running it.
Starting a turn from a direct write #
Chat-turns listens for message:changed events from sync. It calls startTurn when all of these hold:
- The message is a create or an update of a
usermessage authored by the conversation's owner. - The message has a
generationobject. - No message exists at the reply key for its current attempt.
- The event is live, or the message's
createdAtis within the lastturns.backfillMinutes, default 60.
A conversation's first sync is a backfill, which only starts turns for recent messages. A conversation created by another client still gets answered, while importing or migrating old messages never triggers a flood of generations.
Whether a reply exists is checked with getRecord at the reply key, through the record store.
A turn that has been requested but not yet claimed, for example one waiting for the rate limit, is recorded in a turn_request table: conversation URI, user message key, attempt, and time, with the first three as the primary key. The same turn requested twice, say by the safety net and a notification, is therefore queued once. The row is deleted when the reply is claimed. On startup, the server retries every row newer than turns.backfillMinutes and drops older ones, so a restart does not lose queued turns. The table holds references only, never content.
When sync emits message:invalid for a user message that carries a generation request, the server writes an error reply at <key>.r<attempt>, using the attempt from the raw record or 0. Its error message quotes the validation error, so the direct client sees why nothing was generated.
startTurn #
startTurn(user, conversation, userMessageKey) runs the turn in the background and returns once the reply is claimed:
- Load. It reads the conversation's info record and every message from the record store, once per turn, and keeps them in memory only for the turn. The reply key is
<userMessageKey>.r<attempt>, withattemptdefaulting to 0. - Choose the model. The model reference is
generation.model, else the model of the nearest completed assistant reply up the branch, else the user'sdefaultModelpreference, else the admin default. The effort isgeneration.effort, else thedefaultEffortpreference. - Rate limit. A user may start
turns.ratePerMinuteturns per minute, default 10. Past that, the turn waits in an in-memory queue per user and starts when the limit allows, without claiming the reply yet. The web UI's stream shows aqueuedstatus meanwhile. - Claim. It calls
createMessageat the reply key with a placeholder:role: "assistant", the parent key, emptyplainContentparts, the chosen model and effort,status: "pending", andcreatedAt. If that throwsRecordExists, another runner, possibly another deployment watching the same space, owns the attempt, andstartTurnstops. On success it records the reply URI in a localturn_claimtable. - Resolve. The providers spec resolves the model reference to an AI SDK model. A failure rewrites the placeholder with
status: "error"and a message saying why. - Build the prompt. The branch is the chain of parents from the user message to the root. The instructions are the admin's base prompt, the
customInstructionspreference, and the conversation'ssystemPrompt, joined by blank lines. The base prompt isturns.systemPromptwith its placeholders filled:{{appName}},{{date}},{{time}}, and{{timezone}}, in the time zone from the user'stimezonepreference, or UTC when it is missing or unknown. The default names the app, gives the date, and says replies are rendered as Markdown without raw HTML and with images loaded only on click. An emptyturns.systemPromptleaves the base prompt out. Each message becomes an AI SDK message:- User text parts become text parts. Attachments follow the attachments spec.
- Assistant replies with status
completeorcancelledcontribute their text parts, tool calls and results, and reasoning according to the provider's replay policy. Replies with statuserrororpendingare skipped.
- Filter. It runs the
messages:beforeModelhook. - Generate. It calls
streamTextwith the model, instructions, messages, the tools for the turn,stopWhen: isStepCount(turns.maxSteps)(default 8), the mappedreasoningvalue, and an abort signal. The signal fires on cancel or afterturns.timeoutSeconds, default 600. The tools are the ones named ingeneration.tools, or, when a message names none, the tools the user has switched on, as the web-search spec describes. - Stream. It reads
result.stream, builds the reply's parts, and publishes events to the turn's stream buffer. - Finish. It builds the final record: parts in the order the model produced them, usage from
result.usage(input, output, andoutputTokenDetails.reasoningTokens), and a status. It runs themessage:afterModelhook, then writes the record withputMessage. After that it runs theturn:afterhook.
Mapping stream parts to lexicon parts #
| AI SDK stream parts | Lexicon part |
|---|---|
text-delta runs between text-start and text-end |
textPart |
reasoning-delta runs between reasoning-start and reasoning-end |
reasoningPart |
tool-call |
toolCallPart, with input JSON-encoded |
tool-result or tool-error |
toolResultPart, with output JSON-encoded unless already a string, and isError for errors |
source with sourceType: "url" |
sourcePart |
Other parts are ignored. When a stream part carries providerMetadata, it is JSON-encoded into the lexicon part's providerData. For text and reasoning, the metadata from the part's end event wins, since that is where providers attach signatures.
Ending states #
- Complete. The stream finished normally. Status
complete. - Error. The stream yielded an
errorpart, or anything in the turn threw. Statuserror, with a short message from the error that never includes API keys or request headers. The partial parts are kept. - Cancelled. The abort signal fired. Status
cancelled, with the partial parts kept. - Too large. If the final record's JSON is over 900 KB, the largest tool result outputs are uploaded as blobs and referenced from each part's
outputBlob, andoutputkeeps a short summary. If the record is still too large, the turn ends with statuserror.
On startup, the server rewrites any pending reply in its own turn_claim table as error with the message "interrupted", then clears the table. Pending replies claimed by other deployments are never touched.
Streaming to the web UI #
GET /api/conversations/:skey/messages/:rkey/stream is a server-sent event stream for a reply key, built with Hono's streamSSE. It first replays the events buffered so far, then sends live events:
| Event | Data |
|---|---|
part-start |
The part index and type |
delta |
The part index and appended text |
part |
A finished tool call, tool result, or source part |
queued |
The turn is waiting for the rate limit |
status |
The final status and error, if any |
The stream closes after status. If this server is not running that reply, the stream sends one status event with the reply's stored status and closes. The web UI then reads the record through the conversation API. A buffer is dropped a minute after its turn ends.
Turn context #
Hooks receive a turn context holding the user's DID, the conversation's URI and info, the user's preferences, the user message, the reply record as it stands, the resolved model reference and provider, the effort, and the names of the tools offered this turn. Tools are resolved before messages:beforeModel runs, so a plugin can add guidance for its own tools only when they are offered.
Configuration #
The turns admin setting, from the admin-settings spec, holds ratePerMinute, maxSteps, timeoutSeconds, backfillMinutes, and systemPrompt. A turn reads them when it starts, so a change applies to the next turn. Each turn holds the plugin runtime it started with until it ends, as the admin-plugins spec describes, and the runner starts no turn for a user without access.
Scope Boundaries #
- No multi-instance deployment. Streams, cancellation, and rate limits are in-process.
- No built-in tools. Tools come from plugins.
- No tool approval prompts.
- No streaming for direct-PDS clients. They see the placeholder, then the finished record.
- No context window management beyond what the provider enforces.
Edge Cases and Decisions #
- The pending placeholder is written with a create-only call, so the web UI and sync noticing the same message produce one generation.
- Rebuilds and backfills only start turns for messages newer than the backfill window, so missed notifications are recovered without replaying history.
- A rate-limited turn is delayed, not answered with an error, so direct clients are not left with a dead-end reply.
- Several deployments can watch the same spaces. The create-only claim picks one winner, and each server only cleans up its own claims.
- Replies that errored or are still pending are left out of later prompts. Cancelled replies keep their partial content in the prompt.
- The model falls back to the last completed reply's model on the branch, so switching models sticks for later turns, as the lexicons README says.
- A turn interrupted by a restart becomes
errorwith "interrupted" on the next startup, not a retry, because retrying could spend money twice. - Claimed replies are recorded in a local
turn_claimtable, keyed by conversation and reply key. - The stream channel opens as soon as a reply is claimed, so a browser subscribing right after sending never sees an unknown stream.
- Stream writes are chained and flushed before a stream closes, so the final status is never dropped.
- The send route returns the user message key, the reply key, and whether the turn was claimed, queued, or already answered.
- Tools from a tool source are named
<source id>_<tool name>, since providers only accept letters, digits, underscores, and dashes. - The default base prompt gives the date but not the time. The time changes every turn, and a changing start of the prompt defeats provider prompt caching for the whole conversation. Admins who want it can add
{{time}}. - The time zone is a preference so turns started from direct PDS writes still use it. The web app saves the browser's zone on sign-in and with every settings save.