Skip to content

Form Copilot — agent-driven editing in the builder

Status: M1 landed (Rust API + web client). M2 pending. Owner: a.kadem Date: 2026-06-15

Implementation status (M1 + M2 in progress)

M1 is built on the Rust API (apps/api-rust, axum + sqlx), not the legacy Elysia apps/api (dead in prod — Caddy proxies all /api/* to the Rust service on 127.0.0.1:3010). The prose below was drafted Elysia-first; the architecture is identical but the real files are Rust:

  • apps/api-rust/src/api/services/ai/deepseek.rs — direct DeepSeek provider (call_deepseek_chat, tools + tool_choice; reuses parse_openrouter_sse).
  • …/services/ai/planner.rssubmitPlan tool, planner prompt, plan_edits.
  • …/services/ai/executor.rs — ref resolution (id/label/tempId), field-op dispatch onto the existing tools.rs executors, mutation events, and copilot_structural_fingerprint (FNV-1a, byte-identical to the web copilotStructuralFingerprint — pinned by a cross-language test).
  • …/controllers/copilot.rsPOST /api/v1/forms/{form_id}/ai/copilot (SSE), registered in …/api/mod.rs.
  • Web client (apps/web) is unchanged in shape: store replay/journal/undo, use-form-copilot SSE hook, CopilotPanel, FormBuilder wiring.

Deviations from the original plan, decided during the port:

  • Gate returns 403 via the existing require_ai_access (professional/enterprise), not 402 — consistent with every other AI route. The web toGateError already treats 403/PREMIUM_REQUIRED as the upgrade gate.
  • Buffered SSE, not chunked RESOLVED — now true SSE streaming (see the M2 "Streaming" entry below). The handler returns an axum Sse body; events flush incrementally with keep-alive frames covering the LLM latency. ai_chat is still buffered; only /copilot was converted.
  • No persistence in /copilot — it computes + streams; the client replays onto the store and the normal Save persists. (User/assistant messages + token usage ARE recorded against the conversation, mirroring ai_chat.)
  • Planner pinned to DeepSeek in M1 (body.model honored only if it's a deepseek* id); OpenRouter routing for the picker is deferred.
  • Dev topology caveat: apps/web Vite proxies /api to :3000; the Rust API binds :3010. To exercise copilot locally, run the Rust binary on :3000 (PORT) or repoint the Vite proxy to :3010.

M2 progress

  • Increment 1 — dependency rules + whole-form generation (done): addRule/ updateRule/removeRule dispatch onto the existing tools.rs rule executors and emit a mutation carrying the full updated dependencyData; the web hook routes those to onDependencyChange (rules live outside the builder store) and snapshots dep data per turn for undo. Whole-form generation needs no new op — the planner emits an ordered addField sequence.
  • Increment 2 — non-gated structural ops (done): setLayout, setTheme, updateFormMeta. setLayout writes schema.layout (validated/normalized: singlesingle-page; unknown types rejected) and replays onto the builder store (checkpoint undo restores it). setTheme (validated against the 9 shadcnThemeNames) and updateFormMeta are pass-through ops — no schema change; the mutation carries theme/name/description and the web hook applies them to editor state (setPresetTheme, name/description), persisted on Save. The request context.formMeta (name/description/ theme) feeds the planner a "Form settings" block; per-turn snapshots of theme/name/ description join dep data so an undo reverts all editor-owned state at once.
  • Streaming — true SSE (done): /copilot now returns an axum Sse body instead of a buffered String. The hard constraint is the request DB scope: req_db_layer releases the per-request connection (and clears RLS GUCs) the instant the handler returns — before the body streams — and the REQ_DB task-local isn't visible in the body future. So the handler does ALL req_conn() reads/auth up front (tier gate, form/conversation lookups, history, user-message persist) with ?, so 4xx/5xx still surface as real HTTP status before any byte. It then captures an owned PgPool clone and the planner inputs, and returns the stream. Inside the stream the DeepSeek plan_edits call runs (keep-alive frames cover the latency), execute_plan runs, the assistant message + token usage persist via the pool's own begin()+set_tenant (stream-safe; req_conn() is NOT), then plan/mutation/error/done events flush. Two router changes were required: api::streaming_routes() is merged WITHOUT the CompressionLayer (it buffers the body, defeating per-event flush) and TimeoutLayer (defensive; Timeout only bounds head production, which is now fast, but exemption is correct for long-lived streams). Behavior change: a planner/provider error is now an in-band error event (HTTP 200) instead of a 5xx, since the response is already committed — the client's onError already handles it. Tests: copilot_stream_tests (event ordering + wire format via axum::body::to_bytes) and http::tests::streaming_route_flushes_incrementally_and_uncompressed (real TCP proof that frames flush incrementally and uncompressed through the layer split). async-stream added to Cargo.toml for the stream! generator.
  • Increment 3 — gated ops + confirm gate (done): sensitive ops are not applied in the main stream. The executor (execute_plan) holds them in result.pending; the stream emits a confirm_needed event carrying an HMAC-signed token (confirm.rssign_confirm_token, BETTER_AUTH_SECRET, domain-separated, 10-min exp, binding op+form_id+conversation_id). The client shows an Approve/Reject card; Approve POSTs the token to a buffered JSON endpoint POST /forms/{id}/ai/copilot/confirm, which verifies the token (rejects tamper/expiry/cross-form replay) and applies the op.
    • setAuthSettings (always gated): on confirm, apply_set_auth_settings UPDATEs respondent_auth_method/allow_anonymous and inserts a form_setting_audit row in one pool.begin()+set_tenant tx — the only copilot op that persists at confirm time (so the audit captures it). Auth methods are constrained to none/email_link/password (the builder UI coerces oauth). The client mirrors the applied value into editor state via onAuthChange; it is an explicit committed change, so it is not part of the per-turn undo.
    • replaceForm (gated only on a non-empty form): normalize_replacement_fields re-mints a fresh unique id for every field/child (robust to planner output), guarantees labels, fills per-type defaults under the planner's values, and validates the layout through the same normalize_layout guard setLayout uses. Compute-only — the client replays setFields+setLayout, clears now-orphaned rules, and the user's Save persists. Because the destructive swap lands after the turn (no per-turn checkpoint), the client snapshots fields/layout/deps at confirm time and the approved card shows an in-app Undo. On an empty form it applies directly (no gate).
    • New form_setting_audit table (migration 0006, drizzle schema, rls.ts): RLS tenant-isolation policy, idempotent owner/grant blocks. Apply with bun run db:migrate. Tests: confirm_tests (token round-trip/tamper/cross-form, gated-op detection), executor replace_form_*, controller gated_op_emits_confirm.

Summary

Move AI form editing out of the standalone chat page (/forms/$formId/chat) and into the form builder. A docked copilot panel sits beside the WYSIWYG canvas. A planner (DeepSeek deepseek-v4-pro, direct API) reads the user's request plus the current working schema and emits an ordered edit plan; a deterministic executor validates each operation and streams it to the canvas as a live mutation, replayed onto the same builder store manual editing uses. Nothing persists until Save; each user turn is one undo step.

Locked decisions

QuestionDecision
SurfaceDocked copilot panel in the builder; deprecate the chat page
Apply modelLive-apply to the canvas store, per-turn undo (no accept/reject phase)
Agent architectureTwo-stage: LLM planner drafts an ordered plan → deterministic executor validates + live-applies each op
Planner backendDirect DeepSeek API (DeepSeekClient) behind a provider interface; default deepseek-v4-pro
Other backendsExisting OpenRouter client kept as alternative planner backends, selectable via the model picker
CapabilitiesFields + structure, dependency rules, layout/theme/meta, auth settings (confirm-gated), whole-form generation
ConcurrencyLast-write-wins on Save; no server lock
Free tierPanel visible (discoverability), input disabled behind an upgrade CTA; server-enforced

Goals

  • Natural-language editing of the open form: "make email required", "add a shipping section with address fields", "show company only when role is business".
  • Whole-form generation from an empty canvas.
  • Agent edits are visually attributable (highlight what changed) and reversible (undo last turn).
  • Reuse, not rebuild: tool executors, conversation persistence, SSE streaming, and the validator layer already exist behind FEATURE_AI_CHAT.

Non-goals (v1)

  • Cross-form or workspace-level operations.
  • Autonomous runs without a user message per turn.
  • Real-time multi-editor collaboration (see Concurrency).
  • Mobile layout for the panel.

Background — what exists today

  • apps/api/src/services/ai/form-agent.ts (single tool-loop, max 10 iterations), llm-client.ts (OpenRouter, multi-model), tools/ (field, dependency, read-only schema tools), validators/, prompts/, conversations.ts.
  • apps/api/src/controllers/ai.ts/api/v1/forms/:formId/ai/*: models list, conversation CRUD, SSE chat stream, /apply (two-phase apply).
  • DB: ai_conversations, ai_chat_messages.
  • Web: pages/forms/chat.tsx (706 lines) — separate page, propose → accept → apply. This is what we replace.
  • Builder: useFormBuilderStore exposes the exact mutation surface the executor needs (addField, updateField, removeField, moveField, reorderFields, duplicateField, setLayout, …); BuilderCanvas renders live store state with selection/highlight affordances.

The today→tomorrow shift: today one LLM loop mutates a server schema copy and the client applies a batch at the end. Tomorrow a planner LLM produces a plan, a deterministic executor applies it op-by-op server-side, and each op streams down as a mutation the client replays live. The LLM reasons; the executor is the only thing that touches schema, so validation and id-minting stay centralized and deterministic.

UX

┌──────────────────────────────────────────────────────────────┐
│ Form Builder                                  [Save][Publish] │
├─────────┬──────────────────────────────┬─────────────────────┤
│ Palette │      LIVE PREVIEW CANVAS     │ Copilot             │
│         │                              │ ┌─────────────────┐ │
│  [T][#] │  ┌────────────────────────┐  │ │ history         │ │
│  [@][▼] │  │ First Name             │  │ └─────────────────┘ │
│         │  │ Email        ✨ added  │  │ Plan ✓ · 5 edits    │
│         │  └────────────────────────┘  │  ✓ add Email        │
│         │                              │  ⟳ require Phone…   │
│         │                              │ > add a phone field │
└─────────┴──────────────────────────────┴─────────────────────┘
  • Panel: third column in FormBuilder, toggleable like Fields/Structure (localStorage-persisted). Holds conversation history (from ai_conversations), the current turn's transcript (planner summary, then a checklist of plan ops ticking off as each applies), the model picker (default deepseek-v4-pro), input, and a stop button that aborts the SSE request.
  • Plan-then-stream: after planning, the panel shows "Plan ✓ · N edits" and the op list; each op then animates onto the canvas as its mutation arrives (ring + "✨ added/updated/moved" chip fading ~3s). Last touched field becomes the selection.
  • Manual editing is primary; the agent is additive. The preview is the editable canvas — drag from the palette, reorder, resize, click-select, edit via the field dialog, all by hand. You never have to prompt for everything; do the obvious edits directly and ask the copilot for the structural/tedious ones. Both paths mutate the same store. (Optional enhancement: inline label rename on double-click so the field dialog is rarely needed — see Mixed-initiative section.)
  • Soft lock: the only time manual editing is blocked is while a turn is actively streaming its plan, via one agentBusy store flag (inputs still scroll). Stop or turn-end releases it. Between turns, editing is fully free.
  • Undo: an "Undo" chip per completed turn reverts {fields, layout, dependencyData, presetTheme, meta} to the turn-start checkpoint.
  • Confirm gates (the only pauses in an otherwise live flow): when the executor reaches a replaceForm op on a non-empty form, or any setAuthSettings op, it emits confirm_needed; the panel shows an inline one-click confirm before applying.
  • The editor header "AI Assistant" button opens the panel instead of navigating to /chat.

Architecture

 Builder (web)                              API
┌────────────────────┐                    ┌────────────────────────────────────┐
│ useFormBuilderStore│   POST /ai/copilot │ copilot controller (Elysia, SSE)   │
│  ▲ replay ops      │ ─────────────────▶ │   │ PlannerInput: {message, schema,│
│  │ journal (u+a)   │                    │   │  dependencyData, uiContext,    │
│  │                 │                    │   │  changeJournal, conversation}  │
│ CopilotPanel       │                    │   ▼                                │
│  │ SSE events      │  plan / mutation / │ ┌─ PLANNER (LlmProvider) ────────┐ │
│  ◀──────────────── │  confirm / done    │ │ DeepSeekClient (deepseek-v4-pro)│ │
│  text plan mutation│                    │ │ → submitPlan(ops[])             │ │
│  confirm_needed    │   POST .../confirm │ └────────────┬────────────────────┘ │
│  done(hash)        │ ─────────────────▶ │              ▼                       │
└────────────────────┘                    │ ┌─ EXECUTOR (deterministic) ──────┐ │
                                          │ │ resolve refs → validate → apply │ │
                                          │ │ to in-memory schema; mint ids;  │ │
                                          │ │ emit mutation per op            │ │
                                          │ └─────────────────────────────────┘ │
                                          └────────────────────────────────────┘

Provider interface

ts
interface LlmProvider {
  name: string;
  // Single-shot: given system + context + user message, return the plan via a
  // forced `submitPlan` tool call (or JSON mode). No multi-tool loop here.
  plan(input: PlannerInput): Promise<EditPlan>;
}
class DeepSeekClient   implements LlmProvider  // direct api.deepseek.com (OpenAI-compatible), DEEPSEEK_API_KEY
class OpenRouterClient implements LlmProvider  // existing client, refactored to the interface

Provider is resolved from the selected model id: deepseek-*DeepSeekClient, anything else → OpenRouterClient. Default model deepseek-v4-pro (hardcoded constant; swap is a one-line change). The executor uses no LLM — the planner is the only model in the loop.

The plan

The planner returns an ordered op list. Refs are symbolic so the planner never needs to know minted ids:

ts
type FieldRef = { id?: string; tempId?: string; label?: string }; // executor resolves

type PlanOp =
  | { op: "addField"; tempId: string; type: WidgetType; parentRef?: FieldRef; index?: number; config: FieldConfig }
  | { op: "updateField"; ref: FieldRef; patch: Partial<FieldConfig> }
  | { op: "removeField"; ref: FieldRef }
  | { op: "moveField"; ref: FieldRef; parentRef?: FieldRef; index?: number }
  | { op: "duplicateField"; ref: FieldRef; tempId: string }
  | { op: "addRule" | "updateRule" | "removeRule"; /* rule payload, refs symbolic */ }
  | { op: "setLayout"; layout: FormLayout }
  | { op: "setTheme"; theme: ShadcnThemeName }
  | { op: "updateFormMeta"; name?: string; description?: string }
  | { op: "setAuthSettings"; authMethod?: RespondentAuthMethod; allowAnonymous?: boolean } // confirm-gated
  | { op: "replaceForm"; fields: BuilderField[]; layout?: FormLayout };                     // confirm-gated if non-empty

interface EditPlan { summary: string; ops: PlanOp[] }

Executor

For each op, in order:

  1. Resolve refs → concrete ids. id is used directly; label is looked up; tempId is resolved via a temp→minted map populated as addField/duplicateField create fields earlier in the same plan. Unresolvable ref → validation error.
  2. Validate with the existing validators/ layer (field types, rule references, layout step↔field consistency, theme name, no cycles).
  3. Confirm gate if op is setAuthSettings, or replaceForm on a non-empty form: emit confirm_needed, wait (bounded) for POST /ai/copilot/confirm. Decline → skip the op and continue. Accept → apply + write an audit-log entry (who/when/old→new).
  4. Apply to the in-memory schema, minting ids server-side.
  5. Emit a mutation event with the canonical op + the minted ids + a one-line summary.

Repair loop (bounded): if any op fails validation, the executor sends the failures back to the planner once for a corrected submitPlan. If the re-plan still fails, emit error listing the bad ops and stop (no partial garbage; the turn's checkpoint is the clean rollback). Default: 1 repair attempt.

Mutation replay (client)

The client replays each mutation onto useFormBuilderStore. Because ids are minted server-side and carried in the event, both sides apply identical ops to identical starting state and stay in lockstep.

opstore replay
addFieldaddField(type, parentId, index, { id }) then updateField(id, config) — needs a new optional id param on the store action
updateFieldupdateField(id, patch)
removeFieldremoveField(id)
moveFieldmoveField(id, parentId, index)
duplicateFieldreplay as addField+updateField with the server-minted id (never the store's own duplicateField — it mints a different id)
addRule/updateRule/removeRulesetDependencyData(next) via the editor's onDependencyChange
setLayoutsetLayout(layout)
setThemeeditor callback → setPresetTheme
updateFormMetaeditor callback → name/description
setAuthSettingseditor callback → settings-panel state
replaceFormsetFields(fields) + setLayout(layout), single undo step

done carries SHA-256 of the server's final schema JSON. The client hashes its own fieldsToSchema(...); on mismatch (a replay bug, not an expected path) it hard-replaces store state from finalSchema included in the event, logs to PostHog, and re-renders — self-healing.

Stream protocol — AiStreamEvent additions

Existing: text, tool_call, tool_result, preview, error, done.

ts
| { type: "plan";           data: { summary: string; ops: { op: string; summary: string }[] } }
| { type: "mutation";       data: { op: string; ids: Record<string,string>; args: …; summary: string } }
| { type: "confirm_needed"; data: { op: "replaceForm" | "setAuthSettings"; summary: string; token: string } }
| { type: "done";           data: { schemaSha256: string; finalSchema?: FormBuilderSchema; usage: {…} } }

The old preview event and /apply endpoint stay during migration, then are deleted with the chat page.

New tools / ops vs. existing

Existing field, dependency, and read tool executors are reused (the executor maps plan ops onto them). New ops needing executor + validator work: setLayout, setTheme, updateFormMeta, setAuthSettings, replaceForm. The planner system prompt (prompts/) gains documentation + few-shot examples for layout/theme/meta/auth and whole-form generation, and is rewritten to output submitPlan rather than loop on tools.

Mixed-initiative editing & live agent context

The user and the agent edit the same form, the same way. Manual edits and agent ops both flow through useFormBuilderStore, so the user is never forced to prompt for everything — they hand-edit what's obvious and delegate the rest.

For this to work, the planner must on every turn see (a) the current state including manual edits made since the last turn, and (b) a diff history of what recently changed and who changed it. The planner input is therefore built fresh from the store at send-time — never from the DB or a cached snapshot:

ts
interface PlannerInput {
  message: string;                       // the user's instruction
  schema: FormBuilderSchema;             // fieldsToSchema(fields, layout) read NOW from the store
  dependencyData: FormDependencyData | null;
  uiContext: {                           // so the user can use deixis ("this field")
    selectedFieldId: string | null;
    lastTouchedFieldId: string | null;   // last field the user OR agent edited
    activeLayout: { kind: "single" | "wizard" | "tabs"; step?: string; tab?: string };
  };
  changeJournal: ChangeEntry[];          // recent diffs, user + agent (see below)
  conversation: AiChatMessage[];         // prior turns for this form
}

Change journal

Every mutation appends a structured diff to a journal slice in the store, regardless of source:

ts
interface ChangeEntry {
  id: string;
  ts: number;
  source: "user" | "agent";              // manual edit vs. an executor op
  op: string;                            // addField | updateField | moveField | setLayout | …
  targetId?: string;
  summary: string;                       // "user added 'Phone' after 'Email'"
  before?: unknown; after?: unknown;     // compact, for update/move
}
  • Manual edits are journaled by instrumenting the store mutation actions (each addField/updateField/… appends an entry tagged source: "user").
  • Agent edits are journaled by the replay layer as each mutation event lands, tagged source: "agent".
  • Each turn sends the window since the last agent turn (plus a small tail, cap ~30 entries). This is what lets the user say "undo what you just did and instead…", "rename the field I just added", or "why did you move that?" — and stops the agent re-doing or clobbering work the user already did by hand.

The journal is ephemeral working context (in-memory, capped, not persisted); durable history is the conversation table plus the per-turn undo checkpoints below. The planner already carries the op vocabulary in its system prompt, so it has both the tools (what it can do) and the diff history (what just happened) in context.

Inline preview editing (optional enhancement, M3)

Beyond drag/resize/select, add lightweight in-place edits so the field dialog is rarely needed: double-click a label to rename, an inline "required" toggle on hover, inline option add/remove for choice fields. Each routes through the same store action (and thus the same journal), keeping manual and agent edits symmetric.

Undo

New useFormBuilderStore slice:

ts
checkpoints: { turnId; fields; layout; dependencyData; presetTheme; meta }[]  // cap ~20
journal: ChangeEntry[]                                                        // cap ~30, user + agent
beginAgentTurn(turnId)   // push checkpoint, set agentBusy
endAgentTurn()           // clear agentBusy
undoAgentTurn(turnId)    // restore checkpoint, drop newer ones

One checkpoint per turn (not per op). The journal (above) is the op-granular companion the planner reads each turn; checkpoints are the coarse rollback. Together they seed a future general manual-undo stack.

Tier gating

  • Free tier: the panel renders (discoverability) but the input is disabled with an inline upgrade CTA — free users never run a turn. Enforced server-side: the /ai/copilot endpoint checks the team's tier and returns 402 for free teams; the client-side disabled input is UX only, not the gate.
  • Paid tiers: unmetered in v1 (the existing per-team rate-limit middleware is the abuse guard). No usage counter to build.
  • Gate lives next to the existing tenant/permissions middleware; tier lookup uses the team subscription tier already on teams.

Guardrails

  • Caps: planner produces ≤ ~40 ops/turn; executor enforces the cap and a token budget; repair attempts capped at 1.
  • Validators run on every op before apply (reused layer). replaceForm payloads additionally round-trip through buildJsonSchemaFromForm semantics server-side.
  • Auth-settings safety: setAuthSettings is always confirm-gated and writes an audit-log entry. This is the one capability with a security-review obligation; flag it for /security-review before launch.
  • Permissions: endpoint requires editor access via the existing middleware chain (same as /apply).
  • Feature flag: reuse FEATURE_AI_CHAT (rename to FEATURE_FORM_COPILOT only if we want to stage the surfaces independently).
  • Telemetry (PostHog) per turn: model, op counts, duration, tokens, undo rate, confirm accept/decline, hash-mismatch count, free-tier CTA impressions/clicks. Undo rate is the key quality signal.
  • All code lives in apps/api + apps/web — no packages/renderer changes — so the sibling-repo sync:packages overwrite hazard does not apply.

Concurrency

Last-write-wins on Save, as with manual editing today. The agentBusy soft lock is per-tab only (stops a user fighting their own agent). Cross-editor clashes are resolved by whoever Saves last. No server lock table in v1.

Migration

  1. Build CopilotPanel + /ai/copilot SSE endpoint (planner+executor) alongside the chat page, both behind the flag.
  2. Editor "AI Assistant" opens the panel; /forms/$formId/chat becomes a redirect into the editor with the panel open.
  3. After stabilization: delete chat.tsx, the preview//apply flow, and the AiProposedChange plumbing.

Conversations persist in the existing tables, so chat-page history carries over.

Testing

  • Parity golden tests (critical): for a corpus of plans, assert executor's final schema ≡ client-store final schema after replay (store run in vitest). Pins the replay mapping and id-minting.
  • Planner contract tests with a mocked provider (scripted submitPlan outputs): ref resolution (id/label/tempId), repair loop, confirm pause/resume, mid-turn abort, hash mismatch recovery.
  • DeepSeekClient integration test (recorded/replayed): request shape, tool-call parsing, error/4xx handling (incl. an unknown-model 404 surfacing cleanly).
  • E2E happy path: "add a phone field after email" → canvas shows it; undo removes it.
  • Auth-gate test: setAuthSettings op pauses, decline skips, accept applies + audits.

Milestones

  1. M1 — planner/executor wire-up: provider interface + DeepSeekClient, planner submitPlan, deterministic executor over the existing field/dependency tools, /ai/copilot SSE (plan/mutation/done), CopilotPanel, replay layer + store id param, agentBusy lock, per-turn undo, change journal (user+agent) + uiContext in PlannerInput, hash check, free-tier server gate.
  2. M2 — full capability: setLayout/setTheme/updateFormMeta/setAuthSettings/ replaceForm ops + validators, confirm gates + audit log, whole-form generation, prompt examples.
  3. M3 — polish & migration: highlight/selection sync, stop button, conversation history UI, repair-loop UX, chat-page redirect then deletion, telemetry dashboards, security review of the auth capability.

Defaults chosen without a question (override any)

  • Planner is single-shot per user turn with 1 repair attempt on validation failure (not a free-running multi-step agent).
  • Executor uses no LLM (fully deterministic); the planner is the only model.
  • setAuthSettings audit-log entry uses a new lightweight audit record (or the existing audit mechanism if one is found during M2).
  • Agent writes LanguageMap labels only when the form is already multilingual; otherwise plain English strings.
  • deepseek-v4-pro wired as a constant default (could not verify against DeepSeek's catalog; if it 404s we adjust the id — surfaced cleanly by the client error path).

Open questions (remaining)

  1. Audit log: is there an existing audit table/mechanism to hang setAuthSettings on, or do we add a minimal form_setting_audit record?
  2. DeepSeek deepseek-v4-pro — confirm it's the intended id once we can hit the live API; do we want a fallback model if it's unavailable in a given region?
  3. Should the model picker expose DeepSeek-only, or DeepSeek default + the existing OpenRouter models as alternates for power users?

KinoForms documentation