Assistants
An assistant provider is the model behind the content assistant. It is not a copy rewriter: it is given the site’s tools and it calls them, so it can read a field, add a section, create a page and write content, deciding for itself where the change belongs.
A provider’s whole job is to translate one request into one vendor API call. The engine owns the loop, the tools, the staging and the approval. A provider that returns tool calls is done.
Interface
Section titled “Interface”interface AssistantProvider { name: string; label: string; isConfigured(config: AgentConfig): boolean; reply(request: AgentRequest, config: AgentConfig): Promise<AgentReply>;}
interface AgentRequest { system: string; messages: AgentMessage[]; tools: AgentTool[];}
interface AgentReply { text: string; calls: AgentCall[];}messages is the whole conversation so far, in three shapes:
type AgentMessage = | { role: 'user'; text: string } | { role: 'assistant'; text: string; calls: AgentCall[] } | { role: 'outcomes'; outcomes: AgentOutcome[] };Translate all three into the vendor’s format, send, and translate the reply back. Return text, tool calls, or both.
Vendor state
Section titled “Vendor state”AgentCall.signature exists for this. It is an opaque string the engine stores
and hands back untouched on the next turn; nothing but your adapter reads it.
- Google returns a
thoughtSignatureon eachfunctionCallpart and rejects a history whose calls have lost it, withFunction call is missing a thought_signature. Read it intosignatureand write it back out. Google also sends no call ids, so synthesise them in order, and omitparametersentirely for a schema with no properties rather than sending an empty object. - Anthropic pairs a
tool_useblock with atool_resultblock bytool_use_id. - OpenAI pairs
tool_callswith arole: 'tool'message bytool_call_id.
Because every vendor validates the pairing, only trim a history at a user turn. Cutting between a call and its outcome produces a request the vendor rejects outright.
Handled for you
Section titled “Handled for you”| Owned by | |
|---|---|
| Building the system prompt and the content model | Engine |
| Deciding which tools exist and what they do | Engine |
| Running the loop until the model stops calling tools | Engine |
| Capping the number of rounds and trimming history | Engine |
| Staging field writes and showing a diff to approve | Engine |
| One request to one vendor | Provider |
Field writes made during a loop are staged, not committed. They run through the real write path, so permissions, path writability, sanitising and Zod all apply and what is held is the validated value, but nothing reaches the database until a human applies the diff. Structural changes, such as creating a page or adding a section, commit as they are made.
Descriptors
Section titled “Descriptors”What the model knows about a field comes from its ai block, generated from the
field registry rather than written by hand, so it cannot drift from the schema.
{ name: 'title', label: 'Headline', type: 'richtext', help: 'Shown at the top of the page.', ai: { purpose: 'The hero headline. Two short lines; the second is highlighted.', maxChars: 48, keep: ['<span class="block">'], },}| Key | Meaning |
|---|---|
purpose |
What this field is for, in the model’s terms. Required when the field is editable |
maxChars |
Advisory to the model, checked on the way back in |
keep |
Markup that must survive the rewrite |
editable |
Overrides the field type’s default either way |
Closed by default
Section titled “Closed by default”A field type declares a default; a field may override it with ai.editable.
Default editable: true |
Default editable: false |
|---|---|
text, textarea, richtext, stringlist, image, select, toggle, group, list |
url, email, tel, avatar, seoPreview |
The line is not copy against configuration. The assistant is expected to choose an image, pick a layout and turn a block off, because that is the difference between writing content and arranging a page.
What stays closed is anything that points a visitor at a real destination or at a real person: a link, an email address, a phone number, someone’s face. Those are the values a reader cannot check and a diff does not make obvious, and a wrong one is worse than no change at all. Open them one field at a time, where the destination is a path on your own site:
{ name: 'link', label: 'Button link', type: 'url', ai: { editable: true, purpose: 'A path on this site such as /privacy, or #book.' },}This is structural rather than advisory. A non-editable path is rejected by
writeEdits whatever the model returns, and a container marked
editable: false excludes its whole subtree.
aiFields(fields) returns the editable leaves with their descriptors, and
missingAIPurpose(fields) returns the paths marked editable with no purpose.
Both come from @ouncepage/core/fields/registry.
Guarantees
Section titled “Guarantees”- A path outside the editable set is rejected and reported, and the rest of the same write still applies.
- Rich text is sanitised exactly as form input is.
- The whole entry is
safeParsed against the same Zod schema the form uses. - Writing one index past the end of a list appends, checked against a running draft, so a single call may add several items.
- A revision is recorded with
source: 'ai', which history renders with an amber tag.
Cost and CSP
Section titled “Cost and CSP”The call is server side, from the Worker, with the key in the environment. CSP
governs the browser, not Worker fetch, so an assistant provider needs no CSP
change.
The system prompt already carries the whole content model, which is tens of
kilobytes for a real site. That is deliberate: it costs one large cached prompt
and saves the model spending rounds discovering the shape of the site. Do not
add the Zod schemas on top; safeParse enforces them regardless.