AI fields and actions
A model can write fields of a record for the people using the app, in two ways:
- An AI field,
.ai({ prompt }), is filled by a job after a create, and again after an update that changes a field its prompt reads. - An AI action,
a.ai(name, { label, prompt, writes }), is a button in the record’s action rail that has the model write the fieldwritesnow.
Both write through the API as the user who wrote or clicked, so their access rules, validation and write hooks apply, and the value lands in a field the user can see. Nothing is written anywhere else.
import { f, l, resource, view } from '@protobase/schema'
// A helpdesk's tickets: a job sums each one up after it is written, and agents ask the model for a priority or a translation.
/** support.tickets */export const tickets = resource('tickets') .table('support.tickets') .fields({ id: f.integer().readOnly().filterable().sortable(), title: f.text(), description: f.text().optional(), priority: f.enum(['low', 'normal', 'urgent']).optional(), // empty until the job fills it, after a create and after a change of the title or description summary: f.text().optional().ai({ prompt: 'Summarise this support ticket in one sentence.\n\nTitle: {title}\n\n{description}' }), }) .primaryKey((r) => r.id)
export const ticketsView = view<typeof tickets>('tickets') .names({ singular: 'Ticket', plural: 'Tickets' }) .title((r) => r.title) .layout((r) => [l.section('Ticket', [r.title, r.description, r.priority, r.summary])]) .actions((a) => [ a.ai('translate', { label: 'Translate to English', icon: 'file-text', prompt: 'Translate into English:\n\n{description}', writes: 'description' }), a.ai('triage', { label: 'Suggest priority', icon: 'tag', prompt: 'How urgent is this support ticket?\n\n{title}\n{description}', writes: 'priority', confirm: 'Replace the priority?' }), ])import { defineConfig } from '@protobase/server'import * as config from './tickets'
// The model comes from PROTOBASE_ASSISTANT_API_KEY; the summaries are filled by the app's workers.export default defineConfig({ config })Prompts
Section titled “Prompts”A prompt names fields of the record as {field}, at least one. The model gets a fixed instruction to answer with the value only, and the prompt with each {field} replaced by the record’s value as text: text as it is, numbers and booleans written out, json as JSON, an empty value as nothing. For an enum field, the prompt ends with the values the answer must be one of. Nothing else of the record or the app is sent. The answer is trimmed, and validated as any write is, so an enum value the field does not have, or text over its .max(), is refused.
When every field the prompt reads is empty, the model is not asked: an AI field is cleared to null and an AI action answers 422. A summary of nothing is never invented.
AI fields
Section titled “AI fields”An AI field is f.text() or f.enum(), .optional() (it is empty until the job fills it), not .readOnly() (the job writes it through the API) and not .sensitive(). Its prompt reads fields of the same resource that are neither sensitive, nor the field itself, nor another AI field, so one fill never sets off another. A config that breaks one of these does not load.
The job ai.fill fills it:
- after a create, when the field is empty and a field the prompt reads holds something;
- after an update that changes a field the prompt reads, unless the same write changed the AI field itself: a person’s own edit wins, and the job’s own write never sets off another;
- as the user who wrote, with the roles they had then, in their tenant; a writer who may not update the AI field, or read the fields its prompt reads, gets no fill, and neither does a visitor sending a public form.
Quick edits in a row leave one waiting job per record and field, with the latest writer. When a field the prompt reads changes while the model answers, the job writes nothing and the newer job fills it; a record deleted since is skipped. A model that fails is retried, up to five tries, the first retry after 30 seconds and each later one twice as long; then the job shows on the failed jobs page. Four fills run at once per worker.
Only writes through the API fill a field: a row written with SQL, or an import that goes around the API, gets no fill.
AI actions
Section titled “AI actions”a.ai(name, { label, prompt, writes, icon?, confirm? }) names an action of the view, shown in the record page’s action rail and runnable from a composed page’s <Action name="..." /> like any named action. writes is a text or enum field of the resource that is not read-only, and the prompt may read that field too, to rewrite or translate it in place. With confirm the button asks first. While the model answers the button shows that it is busy; then the record reloads and a toast says it is done, or why it failed.
Who may run one:
- a signed-in user; a visitor never spends model calls;
- whose view of the resource has the action, so views per role decide who has which;
- who may read the fields the prompt reads, and update the field it writes on this record; otherwise the button is not offered or is disabled.
/api/meta sends the action without its prompt, which stays on the server.
The action runs at POST /api/v1/{resource}/{key}:generate with { "action": "<name>" } (the client’s generate(resource, key, action)), and answers the record as an update does. The record is read, the model answers, and the field is written with the ETag that was read, so an edit made while the model answered is not overwritten: the action answers 412 and writes nothing. Its problems:
| Status | Type | When |
|---|---|---|
400 |
invalid-action |
The body names no action. |
403 |
ai-needs-user |
The caller is not signed in. |
404 |
not-found |
The caller’s view has no AI action of that name, or there is no such record. |
403 |
ai-forbidden |
The caller may not read a field the prompt reads, or update the field it writes. |
422 |
ai-no-value |
Every field the prompt reads is empty, or the model answered nothing. |
502 |
ai-unavailable |
The model failed or took more than 60 seconds. The user sees only that; the endpoint’s message goes to the app’s error reporting (onUnhandledError). |
503 |
ai-not-configured |
No model is set; startup normally catches this first. |
Every check is made before the model is asked.
The model
Section titled “The model”AI fields and actions use the assistant’s model settings: PROTOBASE_ASSISTANT_API_KEY, with PROTOBASE_ASSISTANT_BASE_URL and PROTOBASE_ASSISTANT_MODEL, or the same keys of options.assistant. They use them also when PROTOBASE_ASSISTANT_URL names a chat backend elsewhere, which replaces the chat, not the model, and when options.assistant is false, which turns off the chat only. On OpenRouter they ask for little reasoning.
protobase dev and protobase serve do not start when a resource has an AI field or a view an AI action and no key is set; PROTOBASE_STARTUP_CHECKS=false skips that check, as it does the others. Other commands, such as protobase migration, run without a key.
Every click and every write that calls for a fill is one model call on that key, paid to its provider.
The jobs schema
Section titled “The jobs schema”AI fields are filled by jobs, so a project with one needs the jobs schema, which protobase migration writes, and the app’s workers. AI actions run in the request and need neither.