AI Suggestions
Pilotiq core ships a queue-based seam — PendingSuggestionsContext — for
any external mutator that wants to propose a field-value change to
the user instead of applying it directly. The seam is generic: AI agents
are the headline producer (@pilotiq-pro/ai mounts the queue inside
<AiUiProvider>), but the same primitives serve form-recovery flows,
undo stacks, bulk imports, or any other "would you like to apply this?"
UX an ecosystem author needs.
This guide is for plugin authors building custom field renderers,
alternate overlay UIs, or non-AI producers on top of the seam. Typical
pilotiq users don't reach for these APIs — the chat sidebar, FieldShell
overlay, and Tiptap chip all wire themselves automatically when
@pilotiq-pro/ai is installed.
Looking for the user-facing toggle?
Pilotiq.aiSuggestionsMode('auto' \| 'review')and the field overlay UX it produces are documented in Pro › @pilotiq-pro/ai › Consent & approval. This page covers the underlying queue + applier seam that mode rides on top of.
#Anatomy
producer → PendingSuggestionsContext → applier → field state
(queue) (registered per field)Three roles:
- Producer — pushes a
PendingSuggestiononto the queue viausePendingSuggestions().push(...). Doesn't need to know how to apply the value; just tags it withfieldName(+ optionalformId,meta.editorRangefor richtext, etc.). - Queue —
PendingSuggestionsContextholds the list across the panel tree. Subscribers filter by field. Multi-form pages discriminate viaformId. - Applier — registered by the field renderer (or an editor adapter)
via
registerPendingSuggestionApplier(formId, fieldName, fn). Aggregate consumers (e.g. a chat-sidebar pill that lives outside the form's React tree) callapprove(id)on the queue, which looks up the applier and runs it.
Pilotiq core's <FieldShell> registers a generic applier for every
non-richtext field automatically — it uses useFieldState.setValue for
controlled (live) forms and a DOM fallback for uncontrolled ones. The
@pilotiq/tiptap adapter registers an editor-command applier for
richtext fields. So the typical pilotiq install already has appliers
registered on every field; producers can call approve(id) and trust
something will run.
#The PendingSuggestion shape
import type { PendingSuggestion } from '@pilotiq/pilotiq/react'
interface PendingSuggestion {
/** Stable id; producer-supplied. Re-pushing with the same id replaces. */
id: string
/** Field name this suggestion targets. Matched verbatim by readers. */
fieldName: string
/** Form scope. Multi-form pages stamp the form's id; readers in form A
* drop suggestions meant for form B. Optional — when both producer and
* consumer omit it, suggestions are global. */
formId?: string
/** Snapshot of the field's value at production time. */
currentValue: unknown
/** Proposed replacement. */
suggestedValue: unknown
/** Optional attribution surfaced on the diff overlay / inline chip. */
source?: { agentSlug?: string; agentLabel?: string }
/** Wallclock ms; producers fill on push. */
createdAt: number
/** Field-type-specific extras. Sparse. The Tiptap bridge looks for
* `meta.editorRange = { from, to }` on richtext suggestions to drive
* the inline diff range. */
meta?: Record<string, unknown>
}#Reading the queue
import {
usePendingSuggestions,
usePendingSuggestionsForField,
} from '@pilotiq/pilotiq/react'
// Aggregate consumer (chat sidebar pill, bulk-action menu, etc.)
const { list, push, approve, dismiss, approveAll, dismissAll } =
usePendingSuggestions()
// Field-renderer subscriber
const { list, dismiss } = usePendingSuggestionsForField(fieldName, formId)usePendingSuggestionsForField filters automatically:
s.fieldName === fieldName(verbatim)- if both
formIdargs are non-undefined, they must match; otherwise the entry passes (so global suggestions reach scoped readers and vice-versa)
Outside a <PendingSuggestionsProvider> (the typical case in headless
tests, marketing-site previews, or panels without @pilotiq-pro/ai
installed), the context resolves to a frozen no-op API: list is empty,
methods do nothing. Producers can call freely without a guard.
#Producing — push(s)
const { push } = usePendingSuggestions()
push({
id: 'seo-1',
fieldName: 'metaDescription',
currentValue: 'Old description',
suggestedValue: 'A snappier description with keywords',
source: { agentSlug: 'seo', agentLabel: 'SEO' },
})push is idempotent on id — re-pushing with the same id replaces
the prior entry. Useful for producers that re-emit (e.g. an AI tool
that hadn't finished but is now retrying); consumers see one stable
queue position.
If id is omitted, the provider generates one and returns it.
#Approving — applier registry
The cross-tree apply path:
import {
registerPendingSuggestionApplier,
type PendingSuggestionApplier,
} from '@pilotiq/pilotiq/react'
// Field renderer (typically inside FieldShell or its equivalent):
useEffect(() => {
const apply: PendingSuggestionApplier = (suggestion) => {
// Mutate THIS field with suggestion.suggestedValue.
// Any scheme works — React state, DOM mutation, editor command, …
}
return registerPendingSuggestionApplier(formId, fieldName, apply)
}, [formId, fieldName])The registry is a module-level Map<string, applier> keyed by
(formId, fieldName). Form-scoped registrations always win over the
wildcard for the same field name. Re-registering with the same key
replaces the prior entry — most-recently-mounted renderer wins (typical
in multi-instance form scenarios where an old form unmounts after a
new one mounts during navigation).
registerPendingSuggestionApplier returns an unregister function for
useEffect cleanup.
When a queue consumer calls approve(id):
- Provider finds the suggestion by id.
- Looks up an applier for
(suggestion.formId, suggestion.fieldName). - Calls the applier. Failures are swallowed with a
console.warnso a busted applier doesn't strand entries. - Drops the suggestion from the queue.
If no applier is registered (e.g. the target form isn't currently
mounted), approve falls through to a plain dismiss — the queue
still clears.
approveAll(filter?) runs the same resolution per matching entry.
#Rendering — overlay registry
Pilotiq core's <FieldShell> reserves a slot below every non-richtext
input for a registered overlay component. Plugins fill the slot via
registerPendingSuggestionOverlay(C):
import {
registerPendingSuggestionOverlay,
type PendingSuggestionOverlayProps,
} from '@pilotiq/pilotiq/react'
function MyDiffOverlay({ suggestion, onApprove, onReject }: PendingSuggestionOverlayProps) {
return (
<div>
<span>{String(suggestion.currentValue)}</span>
<span>→</span>
<span>{String(suggestion.suggestedValue)}</span>
<button onClick={onApprove}>Approve</button>
<button onClick={onReject}>Reject</button>
</div>
)
}
// Once at boot — typically from a plugin's `register(panel)` step.
registerPendingSuggestionOverlay(MyDiffOverlay)Only one overlay component can be registered at a time — the
registry is a single slot, not a list. The last register* call wins.
Most apps install only @pilotiq-pro/ai's overlay (<PendingSuggestionOverlay>);
register your own only if you want to replace it.
<FieldShell> mounts the overlay below the input whenever a matching
suggestion exists in the queue. Richtext fields are skipped — they
render the diff inline via the Tiptap chip instead.
The overlay is responsible for applying the value on Approve. If
your overlay reads useFieldState, it can call setValue directly;
otherwise mirror the DOM-fallback pattern in pilotiq core's FieldShell
(Object.getOwnPropertyDescriptor(proto, 'value')?.set to mutate the
input in a way that triggers React's onChange handlers).
#Worked example: a custom file-upload field with AI-suggested filenames
A custom field renderer that participates in the queue end-to-end:
import { useEffect, useState } from 'react'
import { useFieldState } from '@pilotiq/pilotiq/react'
import {
usePendingSuggestionsForField,
registerPendingSuggestionApplier,
} from '@pilotiq/pilotiq/react'
function CustomFilenameInput({ name, formId, defaultValue }: {
name: string
formId?: string
defaultValue: string
}) {
const [value, setValue] = useState(defaultValue)
const fieldState = useFieldState(name)
// Register an applier so cross-tree approve (chat pill, bulk menu)
// can mutate this input.
useEffect(() => {
return registerPendingSuggestionApplier(formId, name, (s) => {
const next = String(s.suggestedValue)
setValue(next)
if (fieldState.controlled) fieldState.setValue(next)
})
}, [formId, name, fieldState])
// Mount an inline overlay when a suggestion targets this field
// (instead of using FieldShell's slot — useful when your renderer
// wants tight UI control).
const { list, dismiss } = usePendingSuggestionsForField(name, formId)
const suggestion = list[0]
return (
<div>
<input
name={name}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{suggestion && (
<div className="rounded border bg-primary/5 p-2 text-sm">
<span>Suggested: {String(suggestion.suggestedValue)}</span>
<button onClick={() => {
setValue(String(suggestion.suggestedValue))
dismiss(suggestion.id)
}}>
Apply
</button>
<button onClick={() => dismiss(suggestion.id)}>
Dismiss
</button>
</div>
)}
</div>
)
}The renderer:
- Subscribes to its slice of the queue with
usePendingSuggestionsForField. - Renders an inline overlay when a suggestion arrives.
- Registers an applier so out-of-tree consumers (chat pill, bulk approve) can also mutate this input.
- Falls back to
useFieldState.setValuewhen the form is controlled.
Producers — anywhere in the panel tree — push suggestions:
const { push } = usePendingSuggestions()
push({
id: `filename:${recordId}`,
fieldName: 'filename',
currentValue: currentName,
suggestedValue: 'better-keyword-rich-name.jpg',
source: { agentLabel: 'SEO' },
})#When to skip the seam
The queue is the right call when:
- multiple users / agents may compete to update the same field,
- the user should see a diff before applying, or
- you want a single Approve/Reject UI surface across multiple producers.
Skip it for:
- direct user input (you want immediate updates, not a queue),
- batch backend processes that don't need user approval (write to the source of truth — DB, Y.Doc — and let the form re-fetch).
#Reference
PendingSuggestionsContext,usePendingSuggestions,usePendingSuggestionsForField,PendingSuggestion,PendingSuggestionsApi— all exported from@pilotiq/pilotiq/react.registerPendingSuggestionOverlay/getPendingSuggestionOverlay— overlay registry.registerPendingSuggestionApplier/getPendingSuggestionApplier— applier registry.
For the AI consumer side (chat pill, FieldShell overlay UI, Tiptap chip), see Pro › @pilotiq-pro/ai. For the Tiptap extension that powers richtext inline diffs, see Packages › @pilotiq/tiptap.