Skip to content

Prompt editor

The Tiptap editor for long prompts: variables, a slash menu, AI rewrites with inline diffs and change tracking.

Status
Beta
Category
Editors
Adoption
Not used yet
import { PromptEditor } from "@oration/canon/components/prompt-editor/prompt-editor";
packages/canon/src/components/prompt-editor/prompt-editor.tsx
Loading editor
6 lines and 65 words changed133 words~212 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

import { Button } from "@oration/canon/components/button";import { PromptEditor, type PromptVariable } from "@oration/canon/components/prompt-editor/prompt-editor";import { toast } from "@oration/canon/components/toast";import { PhoneCallIcon } from "lucide-react";import * as React from "react";export function Hero() {    const [variables, setVariables] = React.useState<PromptVariable[]>([        {            name: "supplier_name",            description: "The supplier's name from the vendor record.",            example: "Northwind Freight",        },        {            name: "invoice_number",            description: "The invoice the caller is asking about.",            example: "INV-20931",        },        {            name: "payment_date",            description: "When that invoice is scheduled to be paid.",            example: "Friday, October 2",        },        {            name: "agent_name",            kind: "system",            description: "The name the agent introduces itself with.",            example: "Nora",        },        {            name: "caller_number",            kind: "system",            description: "The caller's phone number in E.164.",            example: "+13125550148",        },    ]);    const baseline = [        "# Who you are",        "You are {{agent_name}}, the supplier support agent for Cedarline's accounts payable team. Suppliers call you about invoices, payments and remittances.",        "",        "# How you sound",        "Friendly and clear. Keep answers short.",        "",        "# What you do",        "1. Greet {{supplier_name}} by name.",        "2. Ask for the invoice number and read it back: {{invoice_number}}.",        "3. Look up the payment and tell them it's scheduled for {{payment_date}}.",        "",        "# Guardrails",        "- Never read out full bank account or routing numbers.",    ].join("\n");    const [value, setValue] = React.useState(        [            "# Who you are",            "You are {{agent_name}}, the supplier support agent for Cedarline's accounts payable team. Suppliers call you about invoices, payments and remittances.",            "",            "# How you sound",            "Warm, unhurried and exact. Read amounts and dates back slowly, and never guess.",            "",            "# What you do",            "1. Greet {{supplier_name}} by name and confirm you're speaking with their accounts receivable contact.",            "2. Ask for the invoice number and read it back: {{invoice_number}}.",            "3. Look up the payment with `get_payment_status` and tell them it's scheduled for {{payment_date}}.",            "4. If they ask for remittance details, offer to email them to the address on file.",            "",            "# Guardrails",            "- Never read out full bank account or routing numbers.",            "- Don't promise a payment date that isn't in the system.",            "- If the caller disputes an amount, take a note and transfer them to Priya Raman's team.",        ].join("\n"),    );    return (        <PromptEditor            value={value}            onChange={setValue}            baseline={baseline}            baselineLabel="published version 4"            variables={variables}            onCreateVariable={(name) => {                setVariables((current) => [...current, { name }]);                toast.add({                    title: `Added {{${name}}}`,                    description:                        "Describe it so the model knows how to use the value.",                });            }}            toolbarActions={                <Button                    type="button"                    variant="ghost"                    size="sm"                    onClick={() =>                        toast.add({                            title: "Calling you as Nora",                            description:                                "Using the draft prompt, not published version 4.",                        })                    }                >                    <PhoneCallIcon                        data-icon="inline-start"                        aria-hidden="true"                    />                    Test call                </Button>            }            maxHeight="26rem"            aria-label="Nora prompt"            className="w-full"        />    );}

Usage#

Prompt editor is where people write the long instructions a voice agent follows, such as the system prompt for Nora, Cedarline's supplier support agent. It is a Tiptap editor that stores plain text with light Markdown: {{variables}} become chips, / opens blocks and AI actions, selecting text offers AI rewrites as inline diffs, and the footer counts changes against the published version. The thing people get wrong is hard-coding per-call facts like a supplier's name into the prose; anything that changes per call belongs in a variable that is defined and described.

When to use

  • For an agent's system prompt, a procedure's instructions or a skill's body: long text the model reads.
  • When the text references call-time values such as {{supplier_name}}, {{invoice_number}} or {{payment_date}}.
  • When edits are drafted against a published version and people need to see what changed before publishing.
  • When AI help (rewrite, shorten, add guardrails, translate) should arrive as a reviewable suggestion, not a silent overwrite.
  • With toolbar={false} for a shorter templated message, like the first line the agent says.

When not to use

  • For a short single-line value, such as a variable's example. Use Input
  • For a plain note or description without variables or formatting. Use Textarea
  • For a chat message someone sends to an assistant. Use Prompt bar
  • For showing code or a JSON payload that people read but don't write. Use Code block
  • For comparing two finished versions side by side. Use Diff

Suggestions, not overwrites

Every AI action runs as a proposal. The editor locks, the suggestion streams in beside the text it would replace, and nothing changes until someone accepts it. Accepting is one undoable step.

The Label-Beside-Color Rule

Undefined variables are a warning tint plus an icon and a tooltip, and the change dot in the footer always sits beside its sentence, so neither depends on color.

The Tint Well Rule

The variables panel's undefined warning sits in a 10px-corner warning well (bg-warning/10), not a bordered alert box.

Anatomy#

Loading editor
2 lines and 7 words changed11 words~26 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

  1. Frame. A card surface with rounded-xl and shadow-border. While the text has focus it gains a 3px Focus Indigo ring at 20%.
  2. Text style menu. Text or Heading 1 to 3, in a 108px ghost trigger at the start of the toolbar.
  3. Formatting tools. 28px ghost icon buttons with tooltips and shortcuts: bold, italic, inline code, lists, quote, code block, undo and redo. Pressed tools fill with --accent.
  4. Variable button. Inserts {{ at the caret, which opens the variable menu.
  5. Toolbar actions. toolbarActions, after a divider at the end of the toolbar.
  6. Writing area. 14px text on 24px lines (16px on small screens), 16px by 12px padding. Grows from minHeight and scrolls past maxHeight.
  7. Variable chip. An inline atom that serializes to {{name}}. Gray for custom, teal for system, a warning tint for undefined names.
  8. Change status. A 6px dot (indigo when changed) beside how many lines and words moved from baseline. Hidden without a baseline.
  9. View changes. Opens a line diff against the baseline. Only shown when something changed.
  10. Word and token count. Live word count and a rough token estimate, at the end of the footer.

Examples#

With the variables panel

extractVariables(value) feeds the panel's usage counts and its warning for names nobody defined. The panel inserts at the caret through the editor's ref.

Loading editor
26 words~51 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Dynamic variables

1 variable is used but not defined. It will be blank on calls.

  • remittance_email (undefined variable)
  • supplier_nameUsed once

    The supplier's name from the vendor record.

    Example: Northwind Freight

  • invoice_numberUsed once

    The invoice the caller is asking about.

    Example: INV-20931

  • payment_dateUsed once

    When that invoice is scheduled to be paid.

    Example: Friday, October 2

System

  • agent_name (system variable)Not used

    The name the agent introduces itself with.

    Example: Nora

import {  extractVariables,  PromptEditor,  type PromptEditorHandle,  type PromptVariable,  VariablesPanel,} from "@oration/canon/components/prompt-editor/prompt-editor";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function WithVariablesPanel() {    const editorRef = React.useRef<PromptEditorHandle>(null);    const [variables, setVariables] = React.useState<PromptVariable[]>([        {            name: "supplier_name",            description: "The supplier's name from the vendor record.",            example: "Northwind Freight",        },        {            name: "invoice_number",            description: "The invoice the caller is asking about.",            example: "INV-20931",        },        {            name: "payment_date",            description: "When that invoice is scheduled to be paid.",            example: "Friday, October 2",        },        {            name: "agent_name",            kind: "system",            description: "The name the agent introduces itself with.",            example: "Nora",        },    ]);    const [value, setValue] = React.useState(        [            "Greet {{supplier_name}} and confirm the invoice number, {{invoice_number}}.",            "",            "Tell them it's scheduled for {{payment_date}}. If they ask for remittance details, offer to email them to {{remittance_email}}.",        ].join("\n"),    );    return (        <div className="grid w-full gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">            <PromptEditor                ref={editorRef}                value={value}                onChange={setValue}                variables={variables}                onCreateVariable={(name) =>                    setVariables((current) => [...current, { name }])                }                minHeight={200}                aria-label="Nora prompt"            />            <div className="self-start rounded-xl bg-card p-3 shadow-border">                <VariablesPanel                    title="Dynamic variables"                    variables={variables}                    usedNames={extractVariables(value)}                    onInsert={(name) => editorRef.current?.insertVariable(name)}                    onCreate={(name) => {                        if (!name) {                            editorRef.current?.openVariableMenu();                            return;                        }                        setVariables((current) => [                            ...current,                            {                                name,                                description: "Where to send remittance advice.",                                example: "ar@northwindfreight.com",                            },                        ]);                        toast.add({                            type: "success",                            title: `Defined {{${name}}}`,                            description: "It will be filled in on every call.",                        });                    }}                    onEdit={(variable) =>                        toast.add({                            title: `Edit {{${variable.name}}}`,                            description: variable.description,                        })                    }                />            </div>        </div>    );}

AI actions

onAIAction gets the action, the selection and the whole prompt, and resolves with replacement text. The answer streams in as a suggestion people accept or reject.

Loading editor
33 words~43 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Select the sentence under How you sound and choose Rewrite, or type / on the empty last line and choose Add guardrails.

import { PromptEditor } from "@oration/canon/components/prompt-editor/prompt-editor";import * as React from "react";export function AIActions() {    const [value, setValue] = React.useState(        [            "# How you sound",            "Be nice to callers and try to help them with whatever they need about their payments.",            "",            "# What you do",            "Look up the invoice and tell them when it gets paid.",            "",        ].join("\n"),    );    return (        <div className="flex w-full flex-col gap-2">            <PromptEditor                value={value}                onChange={setValue}                variables={[]}                minHeight={200}                aria-label="Nora prompt"                onAIAction={async (request) => {                    // Call your model here. The editor streams the answer in for review.                    await new Promise((resolve) =>                        window.setTimeout(resolve, 500),                    );                    if (request.kind === "guardrails")                        return [                            "## Guardrails",                            "- Never read out full bank account or routing numbers.",                            "- If the caller disputes an amount, transfer them to Priya Raman's team.",                        ].join("\n");                    if (request.selection?.includes("Be nice"))                        return "Stay warm, unhurried and exact with every caller. Read amounts and dates back slowly, and never guess.";                    return request.selection ?? request.document;                }}            />            <p className="text-13 text-pretty text-muted-foreground">                Select the sentence under How you sound and choose Rewrite, or                type / on the empty last line and choose Add guardrails.            </p>        </div>    );}

Without the toolbar

toolbar={false} and a small minHeight for a short templated message. Name it with aria-labelledby pointing at the visible label.

First message

What Nora says when the supplier picks up.

Loading editor
15 words~28 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

import { PromptEditor } from "@oration/canon/components/prompt-editor/prompt-editor";import * as React from "react";export function FirstMessage() {    const labelId = React.useId();    const [value, setValue] = React.useState(        "Hi, this is {{agent_name}} from Cedarline supplier support. Am I speaking with someone from {{supplier_name}}?",    );    return (        <div className="flex w-full max-w-xl flex-col gap-2">            <div className="flex flex-col gap-0.5">                <p id={labelId} className="text-13 font-medium text-foreground">                    First message                </p>                <p className="text-13 text-muted-foreground">                    What Nora says when the supplier picks up.                </p>            </div>            <PromptEditor                value={value}                onChange={setValue}                variables={[                    {                        name: "supplier_name",                        description:                            "The supplier's name from the vendor record.",                        example: "Northwind Freight",                    },                    {                        name: "agent_name",                        kind: "system",                        description:                            "The name the agent introduces itself with.",                        example: "Nora",                    },                ]}                toolbar={false}                minHeight={72}                placeholder="What should the agent say first?"                aria-labelledby={labelId}            />        </div>    );}

Plain text in and out

The value is the text the model reads: light Markdown with {{name}} for each chip. Edit on the left and watch the stored value.

Loading editor
23 words~46 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

value
Confirm **{{invoice_number}}** with {{supplier_name}}, then say it's paid on {{payment_date}}.
- Never read out bank details.
- Repeat {{invoice_number}} back before you look it up.
extractVariables(value)invoice_numbersupplier_namepayment_date
import { CodeBlock } from "@oration/canon/components/code-block";import { extractVariables, PromptEditor } from "@oration/canon/components/prompt-editor/prompt-editor";import { VariableChip } from "@oration/canon/components/variable-chip";import * as React from "react";export function PlainText() {    const [value, setValue] = React.useState(        [            "Confirm **{{invoice_number}}** with {{supplier_name}}, then say it's paid on {{payment_date}}.",            "- Never read out bank details.",            "- Repeat {{invoice_number}} back before you look it up.",        ].join("\n"),    );    const names = [...new Set(extractVariables(value))];    return (        <div className="grid w-full gap-4 md:grid-cols-2">            <PromptEditor                value={value}                onChange={setValue}                variables={[                    { name: "supplier_name", example: "Northwind Freight" },                    { name: "invoice_number", example: "INV-20931" },                    { name: "payment_date", example: "Friday, October 2" },                ]}                toolbar={false}                minHeight={120}                aria-label="Payment status instructions"            />            <div className="flex min-w-0 flex-col gap-3">                <CodeBlock code={value} title="value" />                <div className="flex flex-wrap items-center gap-1.5">                    <span className="font-mono text-xs text-muted-foreground">                        extractVariables(value)                    </span>                    {names.map((name) => (                        <VariableChip key={name} name={name} size="sm" />                    ))}                </div>            </div>        </div>    );}

Loading

PromptEditorSkeleton holds the editor's frame, toolbar and footer while the prompt loads. Match its minHeight to the editor's.

Loading editor
20 words~31 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

import { Button } from "@oration/canon/components/button";import { PromptEditor, PromptEditorSkeleton } from "@oration/canon/components/prompt-editor/prompt-editor";import { SkeletonReveal } from "@oration/canon/components/skeleton-reveal";import { RotateCcwIcon } from "lucide-react";import * as React from "react";export function Loading() {    const [loading, setLoading] = React.useState(false);    const [value, setValue] = React.useState(        "# Guardrails\n- Never read out full bank account or routing numbers.\n- Don't promise a payment date that isn't in the system.",    );    React.useEffect(() => {        if (!loading) return;        const id = window.setTimeout(() => setLoading(false), 1200);        return () => window.clearTimeout(id);    }, [loading]);    return (        <div className="flex w-full flex-col items-start gap-3">            <Button                type="button"                variant="outline"                size="sm"                disabled={loading}                onClick={() => setLoading(true)}            >                <RotateCcwIcon data-icon="inline-start" aria-hidden="true" />                Reload prompt            </Button>            <SkeletonReveal                loading={loading}                label="Loading prompt"                className="w-full"                skeleton={                    <PromptEditorSkeleton minHeight={140} className="w-full" />                }            >                <PromptEditor                    value={value}                    onChange={setValue}                    variables={[]}                    minHeight={140}                    aria-label="Nora guardrails"                    className="w-full"                />            </SkeletonReveal>        </div>    );}

Disabled

For people who can read the prompt but not change it. Say who can, beside it.

Loading editor
Matches the published version 413 words~28 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Only admins can edit Nora's prompt. Ask Maya Okafor for access.

import { PromptEditor } from "@oration/canon/components/prompt-editor/prompt-editor";import * as React from "react";export function ReadOnly() {    const [value, setValue] = React.useState(        "Greet {{supplier_name}} by name, confirm {{invoice_number}} and tell them it's scheduled for {{payment_date}}.",    );    return (        <div className="flex w-full flex-col gap-2">            <PromptEditor                value={value}                onChange={setValue}                baseline={value}                baselineLabel="published version 4"                variables={[                    { name: "supplier_name" },                    { name: "invoice_number" },                    { name: "payment_date" },                ]}                minHeight={96}                disabled                aria-label="Nora prompt"            />            <p className="text-13 text-muted-foreground">                Only admins can edit Nora's prompt. Ask Maya Okafor for access.            </p>        </div>    );}

States#

States
StateTreatment
LoadingBefore Tiptap mounts, the frame shows four skeleton lines at minHeight. Use PromptEditorSkeleton while the prompt itself loads.
EmptyThe placeholder shows in muted text. Empty lines in a focused editor hint: Type / for commands or {{ for a variable.
FocusedA 3px ring at 20% around the whole frame.
Slash menu openA popover of Blocks and AI actions under the caret, filtered as you type. The highlight follows the arrow keys.
Variable menu openCustom variables, then system variables, then Create name when the typed name is new and valid.
Text selectedThe AI bubble appears above the selection after 120ms: Ask AI, Rewrite, Shorten, Expand, Translate and Change tone.
Suggestion streamingThe editor is locked. New text streams in green beside the struck original with a pulsing caret, and a Stop button.
Suggestion readyAn inline word diff with Accept (focused), Reject and Try again. Whole-document actions replace the blocks with the diff.
Suggestion failed or emptyCouldn't get a suggestion. with Try again and Dismiss, or No changes suggested. with Dismiss.
ChangedIndigo dot and 3 lines and 12 words changed, with View changes. Unchanged reads Matches the published version.
Undefined variableThe chip takes the warning tint, an icon and a tooltip saying it will be blank on calls.
DisabledThe whole frame drops to 60% opacity, the text isn't editable and every toolbar tool is disabled.

Behavior#

  • value and onChange are plain text. Chips serialize to {{name}}, and the Markdown subset (# headings, - and 1. lists with two-space nesting, > quotes, fenced code, ---, **bold**, _italic_, ` code `) round-trips without escaping, so the model sees exactly what was written.
  • It is controlled. A new value from outside replaces the document without calling onChange; values the editor just emitted are ignored.
  • Typing {{ opens the variable menu, matching names and descriptions. Picking one inserts the chip and a space. A new snake_case name offers Create, which inserts the chip and calls onCreateVariable.
  • Typing a full {{name}} or pasting text that contains one turns it into a chip.
  • / after a space or at the start of a line opens Blocks (Heading, Bulleted list, Numbered list, Quote, Divider, Variable) and AI (Write a section, Improve writing, Add guardrails, Summarize, Translate).
  • Menus keep focus in the editor: arrows move the highlight, Enter or Tab picks, Escape closes. They enter with a fast spring.
  • AI actions call onAIAction with the action, the selection and the whole document as text. The full answer is awaited, then streamed in a few words at a time. Without a handler, built-in simulated answers run, which is what these demos use unless noted.
  • While a suggestion is open the editor is read-only. Accept inserts it as one undoable transaction and returns focus to the text; Reject restores focus without changes.
  • The footer diff uses a deferred value, so typing stays smooth on long prompts. View changes folds unchanged lines, keeping two lines of context around each change.
  • The toolbar is one tab stop and sticks to the top of the frame's scroll container. The ref handle offers focus(), insertVariable(name) and openVariableMenu(), which a variables panel uses to insert at the caret.

Do and don't#

Loading editor
11 words~22 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Do. Put anything that changes per call in a variable: Greet {{supplier_name}} and confirm {{invoice_number}}.
Loading editor
13 words~20 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Don't. Hard-code one supplier's facts into the prompt. Every other call gets Northwind Freight's invoice.
Loading editor
16 words~31 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Do. Structure long prompts with headings and numbered steps the model can follow.
Loading editor
49 words~66 tokens

Type slash for blocks and AI actions, or two opening braces to insert a variable. Select text for AI rewrites, then press Control J or Command J to reach them.

Don't. Write one dense paragraph. People can't scan it, and changes are hard to review in the diff.

Variables

  • supplier_nameUsed once

    The supplier's name from the vendor record.

    Example: Northwind Freight

  • invoice_numberUsed 2 times

    The invoice the caller is asking about.

    Example: INV-20931

Do. Define and describe every variable the prompt uses, so the panel has nothing to warn about.

Variables

2 variables are used but not defined. They will be blank on calls.

  • invoice_number (undefined variable)
  • remittance_email (undefined variable)
  • supplier_nameUsed once
Don't. Leave names undefined. They are blank on every call, and the agent says nothing where the value should be.

Content#

  • Write the prompt to the agent in the second person: You are Nora, the supplier support agent for Cedarline.
  • Use # headings for sections (Who you are, How you sound, What you do, Guardrails) and numbered lists for steps in order.
  • Variable names are lowercase snake_case nouns: supplier_name, invoice_number, payment_date.
  • Describe each variable in one sentence that says where the value comes from, and give a realistic example such as INV-20931.
  • The placeholder says what to write, not how the editor works: the default is Describe who the agent is, what it should do and how it should sound…
  • Set baselineLabel to the version people know: published version 4 reads as Matches the published version 4.

Accessibility#

  • The writing area is role="textbox" with aria-multiline. Name it with aria-label (default Prompt) or point aria-labelledby at a visible heading.
  • A visually hidden hint, linked by aria-describedby, explains /, {{, AI rewrites and the ⌘J or Ctrl J shortcut. Your own aria-describedby is appended to it.
  • While a menu is open the editor carries aria-expanded, aria-controls, aria-autocomplete="list" and aria-activedescendant pointing at the highlighted option, and a live region announces the count.
  • The AI bubble is a role="toolbar" with arrow-key movement. Suggestions are a labelled group; added and removed words carry visually hidden Added and Removed prefixes, and a live region says when a suggestion is ready.
  • Undefined variables carry an icon and a tooltip, not only the warning tint. The footer dot is aria-hidden; its sentence carries the meaning.
  • The streaming caret pulses only when motion is allowed.
Keyboard interactions
KeysAction
/Opens blocks and AI actions after a space or at the start of a line.
{{Opens the variable menu.
↑↓Move the highlight in an open menu.
EnterPicks the highlighted item. Tab does the same.
EscCloses a menu, leaves the AI bubble, or rejects an open suggestion.
⌘JWith text selected, moves focus into the AI bubble. Ctrl J on Windows.
←→Move between toolbar tools or AI bubble actions. Home and End jump in the toolbar.
⌘EnterAccepts a ready suggestion.
⌘BBold. ⌘I italic, ⌘E inline code, ⌘⇧8 bulleted list, ⌘⇧7 numbered list.
⌘ZUndo. ⌘⇧Z redo.

Design tokens#

Design tokens
TokenUsed for
--cardFrame and sticky toolbar background
shadow-borderFrame edge
--ringFocus ring at 20% on the frame
--borderToolbar and footer hairlines, quote rule, dividers
--accentPressed tools and the highlighted menu item
--popoverAI bubble and suggestion menus, with shadow-popover
--primaryChanged dot in the footer
--successInserted words in suggestions and the changes diff
--destructiveRemoved words and the error message
--warningUndefined variable chips and the panel's warning well
--tag-grayCustom variable chips
--tag-tealSystem variable chips
--mutedInline code and code block fill
--subtle-foregroundList markers and the empty-line hint

API reference#

PromptEditor

The editor, toolbar, menus, AI bubble and change footer.

Other props spread onto Doesn't spread props; renders <div data-slot="prompt-editor">.

Props of PromptEditor
PropTypeDefaultDescription
valueRequiredstringNo defaultPrompt text with light Markdown and {{variables}}.
onChangeRequired(text: string) => voidNo defaultCalled with plain text on every edit.
variablesRequiredPromptVariable[]No defaultKnown variables, for the menu, chip states and tooltips.
baselinestringNo defaultThe published text. Enables the change count and View changes.
baselineLabelstring"published version"Names the baseline in the footer and the diff dialog.
onCreateVariable(name: string) => voidNo defaultCalled when someone creates a variable from the {{ menu. The chip is inserted either way.
onAIAction(request: AIActionRequest) => Promise<string>No defaultRuns an AI action and resolves with the replacement text. Without it, simulated answers run.
placeholderstring"Describe who the agent is, what it should do and how it should sound…"Shown while the document is empty.
toolbarbooleantrueShows the formatting toolbar.
toolbarActionsReactNodeNo defaultExtra controls at the end of the toolbar.
minHeightnumber | string280Height of the writing area, like 240 or "18rem".
maxHeightnumber | stringNo defaultPast this height the writing area scrolls.
idstringNo defaultSet on the writing area, for a <label htmlFor>.
aria-labelstring"Prompt"Name of the writing area.
aria-labelledbystringNo defaultId of a visible heading that names the writing area.
aria-describedbystringNo defaultAdded after the built-in keyboard hint.
disabledbooleanfalseRead-only text, disabled toolbar, 60% opacity.
refRef<PromptEditorHandle>No defaultImperative handle: focus, insertVariable, openVariableMenu.
classNamestringNo defaultMerged onto the frame.

PromptEditorHandle

What the ref exposes.

Props of PromptEditorHandle
PropTypeDefaultDescription
focus() => voidNo defaultFocuses the writing area.
insertVariable(name: string) => voidNo defaultInserts a chip at the caret.
openVariableMenu() => voidNo defaultTypes {{ at the caret, which opens the variable menu.

PromptVariable

One variable the prompt can reference.

Props of PromptVariable
PropTypeDefaultDescription
nameRequiredstringNo defaultsnake_case name, without braces.
descriptionstringNo defaultShown in the menu, the chip tooltip and the panel.
examplestringNo defaultA realistic value, shown beside the description.
kind"custom" | "system""custom"System variables are filled in at call time and drawn teal.

AIActionRequest

What onAIAction receives.

Props of AIActionRequest
PropTypeDefaultDescription
kindRequired"write" | "improve" | "guardrails" | "summarize" | "translate" | "rewrite" | "shorten" | "expand" | "tone" | "custom"No defaultWhich action ran.
instructionstringNo defaultFree text for write and custom, the language for translate, the tone for tone.
selectionstringNo defaultThe selected text, when the action runs on a selection.
documentRequiredstringNo defaultThe whole prompt as text, with variables as {{name}}.

VariablesPanel

The variables a prompt can reference, with usage counts and a warning for names used but not defined. Pair it with extractVariables(value).

Other props spread onto Doesn't spread props; renders <section aria-labelledby>.

Props of VariablesPanel
PropTypeDefaultDescription
variablesRequiredPromptVariable[]No defaultDefined variables, custom and system.
usedNamesRequiredstring[]No defaultEvery {{name}} in the prompt, repeats included.
onInsert(name: string) => voidNo defaultShows an insert button on each row.
onCreate(name?: string) => voidNo defaultShows New variable, and Define beside each undefined name (called with that name).
onEdit(variable: PromptVariable) => voidNo defaultShows an edit button on custom rows.
titlestring"Variables"The panel heading.
classNamestringNo defaultMerged onto the section.

PromptEditorSkeleton

The editor's frame with placeholder lines, for while the prompt loads.

Props of PromptEditorSkeleton
PropTypeDefaultDescription
toolbarbooleantrueDraws a toolbar row.
minHeightnumber280Match the editor's minHeight.
classNamestringNo defaultMerged onto the frame.

extractVariables

(text: string) => string[]. Every {{name}} in order, repeats included.

Props of extractVariables
PropTypeDefaultDescription
textRequiredstringNo defaultPrompt text.

docToText

(doc: JSONContent) => string. Tiptap JSON to prompt text.

Props of docToText
PropTypeDefaultDescription
docRequiredJSONContentNo defaultA Tiptap document.

textToDoc

(text: string) => JSONContent. Prompt text to Tiptap JSON.

Props of textToDoc
PropTypeDefaultDescription
textRequiredstringNo defaultPrompt text.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

The AI bubble's buttons don't set type="button". The bubble is appended inside the editor, so inside a <form> choosing Rewrite or Shorten submits the form.

The AI bubble's buttons and the toolbar's Variable button pass text-13 next to a text color through Button's cn, so the 13px size is dropped and they render at 14px.

onAIAction gets no AbortSignal. Stop ends the local stream, but the request keeps running.

The suggestion hint reads ⌘↵ to accept, Esc to reject on every platform; the toolbar detects Windows and shows Ctrl, the hint doesn't.

Try again in a ready suggestion is an icon button named with title, not a tooltip.

disabled dims the whole frame to 60%, text included. There is no read-only mode at full contrast for people who can read but not edit.

The menus' entrance spring and the AI bubble have no reduced-motion check of their own. The app-wide MotionConfig drops their transforms, but their fades still play.

The token count is characters divided by four, a rough estimate rather than the model's tokenizer.

PromptEditorSkeleton takes a number for minHeight while PromptEditor also accepts strings.