Skip to content

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 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.

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 thoughtSignature on each functionCall part and rejects a history whose calls have lost it, with Function call is missing a thought_signature. Read it into signature and write it back out. Google also sends no call ids, so synthesise them in order, and omit parameters entirely for a schema with no properties rather than sending an empty object.
  • Anthropic pairs a tool_use block with a tool_result block by tool_use_id.
  • OpenAI pairs tool_calls with a role: 'tool' message by tool_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.

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.

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

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.

  • 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.

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.