Skip to main content
The Suggestions API adds suggestion mode to any input, editor, or custom component in your app. When it’s on, edits from a human (or an AI agent) aren’t written straight to your data — they’re captured as proposed changes that a reviewer accepts or rejects from the comment dialog, diff-style, like Google Docs suggestions. On accept, your app applies the change. You wire any DOM element as a suggestion target with a single attribute. The SDK captures before/after values, persists each as a typed Suggestion (backed by a CommentAnnotation with type: 'suggestion'), and surfaces accept/reject actions on the comment dialog.
The accept/reject UI renders on the Velt comment dialog, so your app needs Velt Comments set up — that’s where reviewers act on a suggestion.

How it works

Suggestion mode turns ordinary edits into reviewable proposals instead of letting them write straight to your app. Understanding the pipeline makes every step below make sense:
1

You enable suggestion mode

The SDK starts watching the page. It installs delegated focusin / change / focusout listeners, so every element tagged with data-velt-suggestion-target="<targetId>" is tracked — including elements added to the DOM later.
2

A user focuses a target

The SDK snapshots the target’s current value as the oldValue for that edit session.
3

A user edits and commits

On commit, the SDK reads the new value, compares it to the snapshot, and — only if they differ — creates a pending Suggestion (stored as a CommentAnnotation with type: 'suggestion'). Focusing and blurring without changing anything never creates a suggestion.
4

A reviewer accepts or rejects

Accept/reject buttons render on the comment dialog. Accepting moves the suggestion to accepted; rejecting moves it to rejected. Because that UI lives on the comment element, the outcome is emitted there as suggestionAccepted / suggestionRejected.
5

Your app applies the change

Your suggestionAccepted handler reads commentAnnotation.suggestion.newValue and writes it into your own state or backend. The SDK never mutates your data for you — it captures intent and orchestrates review; applying the change is your code’s job.
“Commit” depends on the input type. For text-like inputs (text, number, date, textarea, contenteditable) the commit signal is focusout, so each focus session produces at most one suggestion. For atomic inputs (<select>, checkbox, radio) the commit signal is change, because the selection itself is the intent. This is deliberate — the SDK captures intent, not keystrokes.
Accept/reject outcomes are emitted on the comment element, not the SuggestionElement. Accepting fires suggestionAccepted and rejecting fires suggestionRejected — subscribe with useCommentEventCallback (React) or commentElement.on() (other frameworks). The SuggestionElement emits the rest of the lifecycle: suggestionCreated, suggestionStale, targetEditStart, and targetEditCommit. See step 4.

Setup

Follow these four steps to add suggestions to your app. Steps 1–2 are the minimum to capture suggestions; step 3 controls how edits become suggestions, and step 4 applies them once accepted. All Suggestions methods live on the SuggestionElement singleton. Get it once and reuse it (the React hooks shown below wrap this for you, so you rarely need the element directly):
import { useSuggestionUtils } from '@veltdev/react';

const suggestionElement = useSuggestionUtils();

1. Define suggestion targets

A target is any element you tag with data-velt-suggestion-target="<targetId>". The targetId is a stable, app-owned identifier — use the same id you use in your own state, not a random UUID (which changes across re-renders and breaks matching).
<input data-velt-suggestion-target="row.123.qty" type="number" defaultValue="5" />
When do you need a getter? When the SDK reads a target’s value, it tries, in order: a registered getter → the native form value (.value / .checked) → textContent. For a single primitive input that’s enough, so a plain <input> needs no registerTarget call. But when one target represents a complex value (an object spanning several controls — e.g. a table row with qty and price), there is no single .value to read. Register a getter so the SDK can snapshot and diff the whole object:
import { useRegisterTarget, useUnregisterTarget } from '@veltdev/react';
import { useEffect } from 'react';

function EditableRow() {
  const { registerTarget } = useRegisterTarget();
  const { unregisterTarget } = useUnregisterTarget();

  useEffect(() => {
    registerTarget({
      targetId: 'row.123',
      getter: () => ({
        qty: Number(document.getElementById('qty-input').value),
        price: Number(document.getElementById('price-input').value),
      }),
    });
    return () => unregisterTarget('row.123');
  }, []);

  return (
    <div data-velt-suggestion-target="row.123">
      <input id="qty-input" type="number" defaultValue="5" />
      <input id="price-input" type="number" defaultValue="99" />
    </div>
  );
}
A getter must return edit-time state, not persisted state. The SDK calls your getter to snapshot oldValue on focus and to read newValue on commit. If your getter reads from app state that only updates after the user commits (common when suggestion mode is on), both reads return the same value, the diff short-circuits, and no suggestion is ever created. Read from the live source the user is editing — usually the DOM (input.value). For controlled inputs whose state updates on every keystroke, reading from that state is fine.
registerTarget() returns void. To remove a registration, call unregisterTarget(targetId) — don’t expect an unsubscribe function back.

2. Enable suggestion mode

Enabling suggestion mode is what activates the whole pipeline above. It’s global for the current user and not persisted — a page reload returns to normal editing until you enable it again. Pass an optional EnableSuggestionModeConfig to hook into the capture flow (see step 3).
import { useEnableSuggestionMode, useDisableSuggestionMode } from '@veltdev/react';

function Toolbar() {
  const { enableSuggestionMode } = useEnableSuggestionMode();
  const { disableSuggestionMode } = useDisableSuggestionMode();

  return (
    <button onClick={() => enableSuggestionMode()}>Suggest changes</button>
  );
}
To reactively reflect the current state in your UI (for example, to highlight a “Suggesting” toggle), observe it rather than reading it once:
import { useSuggestionModeState } from '@veltdev/react';

const isSuggesting = useSuggestionModeState(); // boolean, updates reactively

3. Capture edits as suggestions

Once a commit is detected, you decide how it becomes a suggestion. There are three approaches, from least to most control. Pick one per target — they’re mutually exclusive for a given edit.

Auto-commit with onTargetEditCommit (simplest)

Provide onTargetEditCommit in the enable config. Return an object and the SDK immediately creates the suggestion using your summary / metadata. This is the default path most apps want. onTargetEditStart is informational in v1 (its return value is reserved for future use).
const { enableSuggestionMode } = useEnableSuggestionMode();

enableSuggestionMode({
  onTargetEditStart: ({ targetId, oldValue }) => {
    // Informational: a user just started editing `targetId` (oldValue snapshotted).
  },
  onTargetEditCommit: ({ targetId, oldValue, newValue }) => {
    // Returning a result auto-creates the suggestion.
    return {
      summary: `${targetId}: ${oldValue}${newValue}`,
      metadata: { source: 'inline-edit' },
    };
  },
});
Return null (or omit onTargetEditCommit) to not auto-commit and instead handle the commit yourself via the targetEditCommit event below — useful when you need to validate or prompt the user before creating the suggestion.

Defer the commit with the targetEditCommit event

If you skip onTargetEditCommit, subscribe to the targetEditCommit event. Its payload carries a pre-bound commitSuggestion builder: call it to finalize (optionally overriding summary / metadata), or don’t call it to drop the edit. This lets you gate suggestion creation behind your own logic.
import { useSuggestionEventCallback } from '@veltdev/react';
import { useEffect } from 'react';

function CommitGate() {
  const commitEvent = useSuggestionEventCallback('targetEditCommit');

  useEffect(() => {
    if (!commitEvent) return;
    const { details, commitSuggestion } = commitEvent;
    if (isValid(details.newValue)) {
      commitSuggestion({ summary: `Update ${details.targetId}` });
    }
  }, [commitEvent]);

  return null;
}

Drive it manually with startSuggestion / commitSuggestion

For non-DOM flows — custom widgets, canvas elements, or an “AI proposes a change” button — bypass auto-detection entirely. Call startSuggestion(targetId) to snapshot the current value, then commitSuggestion(config) to create the proposal.
import { useStartSuggestion, useCommitSuggestion } from '@veltdev/react';

function ProposeButton() {
  const { startSuggestion } = useStartSuggestion();
  const { commitSuggestion } = useCommitSuggestion();

  const propose = async () => {
    startSuggestion('row.123'); // snapshot oldValue now
    const { id } = await commitSuggestion({
      targetId: 'row.123',
      newValue: { qty: 7, price: 99 },
      summary: 'Bump qty + price',
      metadata: { source: 'manual' },
    });
    console.log('Created suggestion', id);
  };

  return <button onClick={propose}>Propose change</button>;
}
commitSuggestion requires suggestion mode to be enabled and the targetId to be registered (tagged or via registerTarget). It rejects (and creates nothing) when mode is off, the target is unknown, or newValue is deeply equal to the snapshot — guarding against no-op suggestions.

4. Apply changes when a suggestion is accepted

This is the step you can’t skip. The accept/reject buttons live on the comment dialog, so the outcome is emitted on the comment element as suggestionAccepted (and suggestionRejected). The SDK records the outcome and persists the suggestion — but it’s your handler that applies newValue to your data. Read the change from commentAnnotation.suggestion.
import { useCommentEventCallback } from '@veltdev/react';
import { useEffect } from 'react';

function ApplyAcceptedSuggestions() {
  const accepted = useCommentEventCallback('suggestionAccepted');
  const rejected = useCommentEventCallback('suggestionRejected');

  useEffect(() => {
    const suggestion = accepted?.commentAnnotation?.suggestion;
    if (!suggestion) return;
    applyToYourState(suggestion.targetId, suggestion.newValue); // your code writes the change
  }, [accepted]);

  useEffect(() => {
    if (rejected?.commentAnnotation) {
      console.log('Rejected:', rejected.rejectReason);
    }
  }, [rejected]);

  return null;
}
If the target DOM node can’t be resolved when a reviewer accepts, the suggestion goes to stale instead — listen for that on the SuggestionElement with useSuggestionEventCallback('suggestionStale') (React) or suggestionElement.on('suggestionStale') (other frameworks).
Make your accept handler idempotent. It can fire more than once — across reconnects, multiple tabs, and multiple clients all viewing the same document. Applying newValue should be safe to run repeatedly (e.g. set the field to newValue rather than incrementing it). If your handler throws while applying, the SDK marks the suggestion apply_failed.

Read suggestions for your own UI

Beyond the built-in accept/reject buttons, you’ll often want to render your own indicators — a “1 pending change” badge on a row, a custom review panel, or a count in a toolbar. Query suggestions reactively with an optional SuggestionGetSuggestionsFilter, or fetch the single pending suggestion for a target.
import { useSuggestions, usePendingSuggestion } from '@veltdev/react';

// All suggestions, or filter by target / status
const all = useSuggestions();
const pendingForRow = useSuggestions({ targetId: 'row.123', status: 'pending' });

// The newest pending suggestion for one target (or null)
const pending = usePendingSuggestion('row.123');

Behavior notes

  • Drift detection is best-effort: On accept, if a getter is registered, the live value is compared against oldValue. A mismatch sets driftDetected: true on the suggestion. v1 records the flag; a future release will surface a confirmation prompt.
  • Stale wins over drift: If the target DOM node can’t be resolved at accept time, the suggestion transitions to stale immediately and drift detection is skipped.

Lifecycle and events reference

A suggestion moves forward through these states (see SuggestionStatus):
StatusMeaning
pendingCreated and awaiting review.
acceptedA reviewer accepted it; your suggestionAccepted handler applies newValue.
rejectedA reviewer rejected it (optional rejectReason). Nothing is applied.
staleThe target DOM node couldn’t be resolved at accept time, so the change can’t be applied.
apply_failedYour accept handler threw while applying. Surfaced as a status only — there’s no separate event in v1.
Events come from two elements. Accept/reject outcomes are emitted on the comment element — subscribe with useCommentEventCallback (React) or commentElement.on() (other frameworks):
Event (comment element)Fires whenPayload
suggestionAcceptedA reviewer accepts a suggestion (status: 'accepted')SuggestionAcceptEvent (annotationId, commentAnnotation, metadata, actionUser)
suggestionRejectedA reviewer rejects a suggestionSuggestionRejectEvent (adds rejectReason)
The remaining lifecycle events are emitted on the SuggestionElement — subscribe with useSuggestionEventCallback (React) or suggestionElement.on() (other frameworks). They’re defined in SuggestionEventType:
Event (SuggestionElement)Fires whenPayload
suggestionCreatedA pending suggestion is createdSuggestionCreatedEvent
suggestionStaleA suggestion goes stale at accept timeSuggestionStaleEvent
targetEditStartA user focuses a target and editing beginsTargetEditStartEvent
targetEditCommitAn edit is committed (carries the commitSuggestion builder)TargetEditCommitEvent

Data model

Suggestions are stored as CommentAnnotation objects with type === 'suggestion' and a populated suggestion field. The full type hierarchy lives in the Suggestions section of the Data Models reference.