Skip to content

JSON schema builder

A visual tree for building tool parameter schemas, with a JSON preview.

Status
Beta
Category
Editors
Adoption
Not used yet
import { JsonSchemaBuilder } from "@oration/canon/components/json-schema-builder";
packages/canon/src/components/json-schema-builder.tsx

get_payment_status

Looks up when an invoice will be paid and how.

3 fields

import { Button } from "@oration/canon/components/button";import {  createSchemaProperty,  JsonSchemaBuilder,  type SchemaProperty,  validateSchemaProperties,} from "@oration/canon/components/json-schema-builder";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const [fields, setFields] = React.useState<SchemaProperty[]>([        createSchemaProperty({            id: "tool-invoice_number",            name: "invoice_number",            type: "string",            required: true,            description:                "The invoice number the supplier reads out, like INV-20931.",        }),        createSchemaProperty({            id: "tool-supplier_id",            name: "supplier_id",            type: "string",            description:                "Cedarline's ID for the supplier, like sup_4410, when the caller is already identified.",        }),        createSchemaProperty({            id: "tool-include_remittance",            name: "include_remittance",            type: "boolean",            description:                "Whether to return remittance details with the status. True when the supplier asks how they were paid.",        }),    ]);    return (        <div className="flex w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-card shadow-border">            <div className="flex flex-col gap-0.5 border-b border-border px-4 py-3">                <p className="font-mono text-13 font-medium text-foreground">                    get_payment_status                </p>                <p className="text-13 text-muted-foreground">                    Looks up when an invoice will be paid and how.                </p>            </div>            <div className="px-4 py-3">                <JsonSchemaBuilder value={fields} onChange={setFields} />            </div>            <div className="flex justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    type="button"                    onClick={() => {                        const issues = validateSchemaProperties(fields);                        const first = issues[0];                        if (first)                            toast.add({                                type: "error",                                title: `Fix ${issues.length === 1 ? "1 field" : `${issues.length} fields`} before saving`,                                description: `${first.path}: ${first.message}`,                            });                        else                            toast.add({                                type: "success",                                title: "Parameters saved",                                description:                                    "Nora will use them on the next call.",                            });                    }}                >                    Save parameters                </Button>            </div>        </div>    );}

Usage#

JSON schema builder edits the parameters a tool accepts, or the data an agent pulls out of a call, as a tree of named and typed fields, and shows the JSON Schema it produces. In Oration it defines tools like get_payment_status and post-call extraction. The part people skip is the description: the model reads it to decide what to pass, so a field without one is a guess.

When to use

  • To define the input of a tool an agent calls, such as get_payment_status with invoice_number, supplier_id and include_remittance.
  • To define what an agent extracts from a call: a dispute reason, a promised payment date, the invoices discussed.
  • When people who don't write JSON need to shape a schema, including enums, lists and nested objects.
  • When the generated schema should be readable and copyable in the same place.

When not to use

  • For flat string pairs such as HTTP headers or metadata. Use Key-value editor
  • For a form people fill in. Build the fields themselves. Use Field
  • For showing a schema or payload people read but don't edit. Use Code block
  • For a single list of allowed values on its own. Use Tag input

Describe every field

The description is the instruction the model reads. Say what the value is, where it comes from and give an example: The invoice number the supplier reads out, like INV-20931.

Names are snake_case

The name input converts spaces, hyphens and camelCase humps to underscores as people type, and anything outside lowercase letters, digits and underscores is flagged inline.

The Tint Well Rule

The empty state is a Well Gray well (rounded-[10px] bg-muted/70) with one outline button, not a dashed box.

Anatomy#

  1. Field count. 13px muted text, tabular: No fields, 1 field, 3 fields.
  2. View switch. A segmented control named Schema view with Fields and JSON.
  3. Drag handle. From Sortable list, at the start of each row. Hidden while disabled.
  4. Name. A mono input with the placeholder field_name. Turns red with a message when invalid or repeated.
  5. Type. A 120px select with an icon per type: String, Number, Integer, Boolean, Enum, Array, Object.
  6. Required. A small switch with a visible Required label.
  7. More actions. Move up, Move down, Duplicate and a destructive Delete.
  8. Description. An input under the name, placeholder What should the agent capture here?, with an optional action beside it.
  9. Nested guide. A 1px hairline on the left that indents an object's fields and an array's item type.
  10. Add field. A small ghost button after each list.
  11. JSON view. A code block titled JSON Schema that scrolls past 28rem.

Examples#

JSON view

The generated schema for get_payment_status. Every object is strict with additionalProperties: false, and required lists only the fields marked required. Pass view and onViewChange to control it.

3 fields

JSON Schema4 keys
"type": "object",
"properties": {
"invoice_number": {
"type": "string",
"description": "The invoice number the supplier reads out, like INV-20931."
"supplier_id": {
"type": "string",
"description": "Cedarline's ID for the supplier, like sup_4410."
"include_remittance": {
"type": "boolean",
"description": "Whether to return remittance details with the status."
"required": [
"invoice_number"
"additionalProperties": false

Arrow keys move between rows and open or close them. C copies a value, P copies its path. Slash searches.

import { createSchemaProperty, JsonSchemaBuilder, type SchemaProperty } from "@oration/canon/components/json-schema-builder";import * as React from "react";export function JsonView() {    const [view, setView] = React.useState<"fields" | "json">("json");    const [fields, setFields] = React.useState<SchemaProperty[]>([        createSchemaProperty({            id: "json-invoice_number",            name: "invoice_number",            type: "string",            required: true,            description:                "The invoice number the supplier reads out, like INV-20931.",        }),        createSchemaProperty({            id: "json-supplier_id",            name: "supplier_id",            type: "string",            description: "Cedarline's ID for the supplier, like sup_4410.",        }),        createSchemaProperty({            id: "json-include_remittance",            name: "include_remittance",            type: "boolean",            description:                "Whether to return remittance details with the status.",        }),    ]);    return (        <JsonSchemaBuilder            value={fields}            onChange={setFields}            view={view}            onViewChange={setView}            className="w-full max-w-2xl"        />    );}

Enums, arrays and objects

A post-call extraction schema. Enum options are a mono tag input, arrays pick an item type, and objects nest their fields behind a hairline guide.

3 fields

    • wrong_amountduplicate_invoicemissing_poother
  • Each item is
import { createSchemaProperty, JsonSchemaBuilder, type SchemaProperty } from "@oration/canon/components/json-schema-builder";import * as React from "react";export function Nested() {    const [fields, setFields] = React.useState<SchemaProperty[]>([        createSchemaProperty({            id: "dispute",            name: "dispute",            type: "object",            description: "Filled in when the supplier disputes an amount.",            properties: [                createSchemaProperty({                    id: "dispute.reason",                    name: "reason",                    type: "enum",                    required: true,                    description: "Why the supplier thinks the amount is wrong.",                    enumValues: [                        "wrong_amount",                        "duplicate_invoice",                        "missing_po",                        "other",                    ],                }),                createSchemaProperty({                    id: "dispute.amount",                    name: "amount_disputed",                    type: "number",                    description:                        "The amount in dispute, in US dollars, like 180.00.",                }),            ],        }),        createSchemaProperty({            id: "invoice_numbers",            name: "invoice_numbers",            type: "array",            description: "Every invoice the supplier mentioned on the call.",            items: createSchemaProperty({                id: "invoice_numbers.item",                name: "item",                type: "string",            }),        }),        createSchemaProperty({            id: "promised_payment_date",            name: "promised_payment_date",            type: "string",            description: "A date the agent committed to, as YYYY-MM-DD.",        }),    ]);    return (        <JsonSchemaBuilder            value={fields}            onChange={setFields}            className="w-full max-w-2xl"        />    );}

Validation

Repeated names show inline. validateSchemaProperties also catches empty enums and unnamed fields, with a path for each, so you can block saving.

3 fields

  • Another field at this level is already named invoice_number.

  • Another field at this level is already named invoice_number.

validateSchemaProperties found 3 problems

  • invoice_number Another field at this level is already named invoice_number.
  • invoice_number Another field at this level is already named invoice_number.
  • payment_method Add at least one option.
import {  createSchemaProperty,  JsonSchemaBuilder,  type SchemaProperty,  validateSchemaProperties,} from "@oration/canon/components/json-schema-builder";import * as React from "react";export function Validation() {    const [fields, setFields] = React.useState<SchemaProperty[]>([        createSchemaProperty({            id: "check-first",            name: "invoice_number",            type: "string",            required: true,            description: "The invoice number the supplier reads out.",        }),        createSchemaProperty({            id: "check-second",            name: "invoice_number",            type: "string",            description: "A second invoice, if they ask about two.",        }),        createSchemaProperty({            id: "check-third",            name: "payment_method",            type: "enum",            description: "How the supplier wants to be paid.",            enumValues: [],        }),    ]);    const issues = validateSchemaProperties(fields);    return (        <div className="flex w-full max-w-2xl flex-col gap-3">            <JsonSchemaBuilder value={fields} onChange={setFields} />            <div className="rounded-[10px] bg-muted/70 px-3 py-2.5">                <p className="text-13 font-medium text-foreground">                    {issues.length                        ? `validateSchemaProperties found ${issues.length} ${issues.length === 1 ? "problem" : "problems"}`                        : "validateSchemaProperties found no problems"}                </p>                {issues.length ? (                    <ul className="mt-1 flex flex-col gap-0.5">                        {issues.map((issue) => (                            <li                                key={`${issue.id}-${issue.message}`}                                className="text-13 text-muted-foreground"                            >                                <span className="font-mono text-foreground">                                    {issue.path}                                </span>{" "}                                {issue.message}                            </li>                        ))}                    </ul>                ) : null}            </div>        </div>    );}

Limited types

allowedTypes trims the type select and maxDepth={1} turns objects off, for a webhook that only accepts flat JSON.

3 fields

  • receivedmatchedapprovedpaid
import { createSchemaProperty, JsonSchemaBuilder, type SchemaProperty } from "@oration/canon/components/json-schema-builder";import * as React from "react";export function FlatTypes() {    const [fields, setFields] = React.useState<SchemaProperty[]>([        createSchemaProperty({            id: "flat-supplier_id",            name: "supplier_id",            type: "string",            required: true,            description: "Cedarline's ID for the supplier, like sup_4410.",        }),        createSchemaProperty({            id: "flat-amount",            name: "amount",            type: "number",            required: true,            description: "The invoice total in US dollars.",        }),        createSchemaProperty({            id: "flat-status",            name: "status",            type: "enum",            description: "Where the invoice is in the approval flow.",            enumValues: ["received", "matched", "approved", "paid"],        }),    ]);    return (        <JsonSchemaBuilder            value={fields}            onChange={setFields}            allowedTypes={["string", "number", "boolean", "enum"]}            maxDepth={1}            className="w-full max-w-2xl"        />    );}

Description action

renderDescriptionAction adds a control beside each description. Here an AI button drafts one from the field name, as post-call analysis does.

2 fields

import { AIGenerateButton } from "@oration/canon/components/ai/ai-button";import { createSchemaProperty, JsonSchemaBuilder, type SchemaProperty } from "@oration/canon/components/json-schema-builder";import * as React from "react";export function DescriptionAction() {    const [fields, setFields] = React.useState<SchemaProperty[]>([        createSchemaProperty({            id: "remittance_email",            name: "remittance_email",            type: "string",        }),        createSchemaProperty({            id: "callback_requested",            name: "callback_requested",            type: "boolean",        }),    ]);    return (        <JsonSchemaBuilder            value={fields}            onChange={setFields}            className="w-full max-w-2xl"            renderDescriptionAction={(prop, update) => (                <AIGenerateButton                    label="Describe"                    tooltip="Write a description from the field name"                    toastTitle="Description added"                    onGenerate={() =>                        `The ${prop.name.replace(/_/g, " ") || "value"} the supplier gave on the call.`                    }                    onResult={(description) => update({ description })}                />            )}        />    );}

Empty and disabled

An empty builder offers one Add field in a well. Disabled hides the drag handles; say who can make changes.

No fields

No fields yet. Add the first thing the agent should capture.

1 field

Only admins can change tool parameters.

import { createSchemaProperty, JsonSchemaBuilder, type SchemaProperty } from "@oration/canon/components/json-schema-builder";import * as React from "react";export function EmptyAndDisabled() {    const [fields, setFields] = React.useState<SchemaProperty[]>([]);    const [locked, setLocked] = React.useState<SchemaProperty[]>([        createSchemaProperty({            id: "locked_invoice_number",            name: "invoice_number",            required: true,            description: "The invoice number the supplier reads out.",        }),    ]);    return (        <div className="grid w-full gap-6 md:grid-cols-2">            <JsonSchemaBuilder value={fields} onChange={setFields} />            <div className="flex flex-col gap-2">                <JsonSchemaBuilder                    value={locked}                    onChange={setLocked}                    disabled                />                <p className="text-13 text-muted-foreground">                    Only admins can change tool parameters.                </p>            </div>        </div>    );}

States#

States
StateTreatment
EmptyA well that says No fields yet. Add the first thing the agent should capture. with an outline Add field.
RestTop-level rows separated by hairlines.
Invalid nameThe name input gets aria-invalid (red border and ring) and a red message below it, for a bad pattern or a name used twice at the same level.
DraggingThe row lifts onto the card surface with shadow-popover while it moves.
EnumA mono tag input for the options appears under the description.
ArrayEach item is with its own type select, indented. Arrays of arrays aren't offered.
ObjectA nested field list behind the guide. Empty objects say No fields inside yet.
Too deepAt maxDepth, Object is disabled in the type select with Too deep beside it.
JSONThe generated schema in a code block, computed only while shown.
DisabledEvery input, switch, select and button is disabled and the drag handles disappear.

Behavior#

  • value and onChange are required; the builder holds no field state of its own. The view is internal unless you pass view and onViewChange.
  • Adding a field focuses its name. Duplicate inserts a copy named name_copy below and focuses it. Delete removes the field and focuses its neighbor.
  • Changing the type fills in what the new type needs: an empty option list for Enum, an empty field list for Object, a string item for Array. Nested data from the old type stays in the value but only the current type reaches the schema.
  • Rows reorder by drag, by keyboard on the handle, or with Move up and Move down.
  • toJsonSchema(value) skips unnamed fields, writes enums as strings with enum, adds required only when something is required, trims descriptions and sets additionalProperties: false on every object.
  • Inline errors don't block anything. Call validateSchemaProperties(value) before saving; it returns unnamed fields, bad or repeated names and enums with no options, each with a dotted path.
  • maxDepth counts the top level, so the default of 3 allows two levels of nesting and 1 turns objects off. allowedTypes limits the type select; a field keeps its current type even if it isn't allowed.
  • renderDescriptionAction puts a control beside each description, such as an AI button that writes one from the field name.

Do and don't#

1 field

Do. Describe each field with what it is, where it comes from and an example.

1 field

Don't. Leave descriptions blank. The model guesses what supplier_id means and when to send it.

1 field

Do. Name fields in snake_case nouns the model can match to what it hears: invoice_number.

1 field

  • Use lowercase letters, numbers and underscores, starting with a letter.

Don't. Use labels with spaces, capitals or symbols. They fail validation and read as prose, not keys.

Content#

  • Field names are lowercase snake_case nouns: invoice_number, supplier_id, include_remittance.
  • Booleans read as a yes or no question in the name: include_remittance, is_disputed.
  • Descriptions are one sentence ending in a period, with an example in the supplier's words: like INV-20931.
  • Enum options are snake_case values the code switches on: wrong_amount, duplicate_invoice, missing_po.
  • Mark a field required only when the tool can't run without it.

Accessibility#

  • Every control is named from its row: Field name 1, Type for invoice_number, invoice_number is required, Description for invoice_number, More actions for invoice_number.
  • The visible Required text is aria-hidden because the switch already carries the full name.
  • Name errors are linked with aria-describedby and mark the input aria-invalid.
  • Nested Add field buttons name their parent: Add field to dispute.
  • Focus moves to the new name after add or duplicate, and to a neighbor after delete, so keyboard users never land on nothing.
  • Every drag handle is named Drag to reorder without the field's name; Move up and Move down in the row menu are the clearer keyboard path.
Keyboard interactions
KeysAction
TabMoves through handle, name, type, required, more actions and description in each row.
SpaceToggles Required. On a handle, picks the row up or drops it.
↑↓Move a picked-up row, or move through an open select or menu.
EnterOpens the type select or the row menu, and chooses the highlighted item. In an enum, adds the typed option.
EscCloses a select or menu, or cancels a drag.

Design tokens#

Design tokens
TokenUsed for
--borderHairlines between top-level rows and the nested guide
--mutedEmpty-state well at 70%
--cardLifted row while dragging, with shadow-popover
--destructiveName errors and the Delete item
--muted-foregroundCount, type icons, Required label, Add field
font-monoField names and enum options
--radius-lgRow corners; the empty well uses 10px

API reference#

JsonSchemaBuilder

The field tree with a Fields and JSON view.

Other props spread onto Doesn't spread props; renders <div data-slot="json-schema-builder">.

Props of JsonSchemaBuilder
PropTypeDefaultDescription
valueRequiredSchemaProperty[]No defaultTop-level fields.
onChangeRequired(value: SchemaProperty[]) => voidNo defaultCalled with the whole tree on every edit.
allowedTypesSchemaType[]all seven typesTypes offered in every type select.
maxDepthnumber3Levels of nesting, counting the top level. 1 disables objects.
renderDescriptionAction(prop: SchemaProperty, update: (next: Partial<SchemaProperty>) => void) => ReactNodeNo defaultA control beside each description, like an AI Describe button.
view"fields" | "json"No defaultControls the view. Uncontrolled when omitted, starting on Fields.
onViewChange(view: "fields" | "json") => voidNo defaultCalled when the view switch changes.
disabledbooleanNo defaultDisables every control and hides the drag handles.
classNamestringNo defaultMerged onto the root.

SchemaProperty

One field in the tree.

Props of SchemaProperty
PropTypeDefaultDescription
idRequiredstringNo defaultStable key. createSchemaProperty makes one.
nameRequiredstringNo defaultThe JSON key. Empty names are skipped in the schema.
typeRequiredSchemaTypeNo defaultOne of the seven types.
descriptionstringNo defaultWhat the model should pass here.
requiredbooleanNo defaultAdds the name to the parent's required.
enumValuesstring[]No defaultOptions when type is enum.
itemsSchemaPropertyNo defaultItem shape when type is array. Its name is ignored.
propertiesSchemaProperty[]No defaultChild fields when type is object.

createSchemaProperty

(partial?: Partial<SchemaProperty>) => SchemaProperty. A blank string field with a fresh id, merged with partial.

Props of createSchemaProperty
PropTypeDefaultDescription
partialPartial<SchemaProperty>{}Fields to set. Pass id for stable ids in server-rendered pages.

toJsonSchema

(props: SchemaProperty[]) => JsonSchema. The object schema for the top-level fields.

Props of toJsonSchema
PropTypeDefaultDescription
propsRequiredSchemaProperty[]No defaultThe builder's value.

validateSchemaProperties

(props: SchemaProperty[], path?: string) => { id: string; path: string; message: string }[]. Every problem that should block saving.

Props of validateSchemaProperties
PropTypeDefaultDescription
propsRequiredSchemaProperty[]No defaultThe builder's value.
pathstring""Prefix for nested paths.

SchemaType

Type export.

Props of SchemaType
PropTypeDefaultDescription
SchemaType"string" | "number" | "integer" | "boolean" | "enum" | "array" | "object"No defaultEnum is written to the schema as a string with enum.

Known gaps#

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

There is no format, default or nullable option, so a payment date is a plain string and the description has to say the format.

Missing descriptions aren't flagged anywhere, inline or by validateSchemaProperties, even though they matter most to the model.

Delete in the row menu is immediate, with no undo, even for an object with fields inside.

Every drag handle has the same name, Drag to reorder.

createSchemaProperty makes ids with crypto.randomUUID, so building initial state with it on a server-rendered page gives different ids on server and client. Pass id yourself.

The registry lists three exports; validateSchemaProperties and the SchemaProperty, SchemaType and JsonSchema types are exported too.