JSON schema builder
A visual tree for building tool parameter schemas, with a JSON preview.
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_statuswithinvoice_number,supplier_idandinclude_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
Names are snake_case
The Tint Well Rule
rounded-[10px] bg-muted/70) with one outline button, not a dashed box.Anatomy#
- Field count. 13px muted text, tabular: No fields, 1 field, 3 fields.
- View switch. A segmented control named Schema view with Fields and JSON.
- Drag handle. From Sortable list, at the start of each row. Hidden while disabled.
- Name. A mono input with the placeholder
field_name. Turns red with a message when invalid or repeated. - Type. A 120px select with an icon per type: String, Number, Integer, Boolean, Enum, Array, Object.
- Required. A small switch with a visible Required label.
- More actions. Move up, Move down, Duplicate and a destructive Delete.
- Description. An input under the name, placeholder What should the agent capture here?, with an optional action beside it.
- Nested guide. A 1px hairline on the left that indents an object's fields and an array's item type.
- Add field. A small ghost button after each list.
- 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
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#
| State | Treatment |
|---|---|
| Empty | A well that says No fields yet. Add the first thing the agent should capture. with an outline Add field. |
| Rest | Top-level rows separated by hairlines. |
| Invalid name | The 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. |
| Dragging | The row lifts onto the card surface with shadow-popover while it moves. |
| Enum | A mono tag input for the options appears under the description. |
| Array | Each item is with its own type select, indented. Arrays of arrays aren't offered. |
| Object | A nested field list behind the guide. Empty objects say No fields inside yet. |
| Too deep | At maxDepth, Object is disabled in the type select with Too deep beside it. |
| JSON | The generated schema in a code block, computed only while shown. |
| Disabled | Every input, switch, select and button is disabled and the drag handles disappear. |
Behavior#
valueandonChangeare required; the builder holds no field state of its own. The view is internal unless you passviewandonViewChange.- Adding a field focuses its name. Duplicate inserts a copy named
name_copybelow 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 withenum, addsrequiredonly when something is required, trims descriptions and setsadditionalProperties: falseon 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. maxDepthcounts the top level, so the default of 3 allows two levels of nesting and 1 turns objects off.allowedTypeslimits the type select; a field keeps its current type even if it isn't allowed.renderDescriptionActionputs a control beside each description, such as an AI button that writes one from the field name.
Do and don't#
1 field
1 field
supplier_id means and when to send it.1 field
invoice_number.1 field
Use lowercase letters, numbers and underscores, starting with a letter.
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-hiddenbecause the switch already carries the full name. - Name errors are linked with
aria-describedbyand mark the inputaria-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.
| Keys | Action |
|---|---|
| Tab | Moves through handle, name, type, required, more actions and description in each row. |
| Space | Toggles Required. On a handle, picks the row up or drops it. |
| ↑↓ | Move a picked-up row, or move through an open select or menu. |
| Enter | Opens the type select or the row menu, and chooses the highlighted item. In an enum, adds the typed option. |
| Esc | Closes a select or menu, or cancels a drag. |
Design tokens#
| Token | Used for |
|---|---|
--border | Hairlines between top-level rows and the nested guide |
--muted | Empty-state well at 70% |
--card | Lifted row while dragging, with shadow-popover |
--destructive | Name errors and the Delete item |
--muted-foreground | Count, type icons, Required label, Add field |
font-mono | Field names and enum options |
--radius-lg | Row 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">.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | SchemaProperty[] | No default | Top-level fields. |
onChangeRequired | (value: SchemaProperty[]) => void | No default | Called with the whole tree on every edit. |
allowedTypes | SchemaType[] | all seven types | Types offered in every type select. |
maxDepth | number | 3 | Levels of nesting, counting the top level. 1 disables objects. |
renderDescriptionAction | (prop: SchemaProperty, update: (next: Partial<SchemaProperty>) => void) => ReactNode | No default | A control beside each description, like an AI Describe button. |
view | "fields" | "json" | No default | Controls the view. Uncontrolled when omitted, starting on Fields. |
onViewChange | (view: "fields" | "json") => void | No default | Called when the view switch changes. |
disabled | boolean | No default | Disables every control and hides the drag handles. |
className | string | No default | Merged onto the root. |
SchemaProperty
One field in the tree.
| Prop | Type | Default | Description |
|---|---|---|---|
idRequired | string | No default | Stable key. createSchemaProperty makes one. |
nameRequired | string | No default | The JSON key. Empty names are skipped in the schema. |
typeRequired | SchemaType | No default | One of the seven types. |
description | string | No default | What the model should pass here. |
required | boolean | No default | Adds the name to the parent's required. |
enumValues | string[] | No default | Options when type is enum. |
items | SchemaProperty | No default | Item shape when type is array. Its name is ignored. |
properties | SchemaProperty[] | No default | Child fields when type is object. |
createSchemaProperty
(partial?: Partial<SchemaProperty>) => SchemaProperty. A blank string field with a fresh id, merged with partial.
| Prop | Type | Default | Description |
|---|---|---|---|
partial | Partial<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.
| Prop | Type | Default | Description |
|---|---|---|---|
propsRequired | SchemaProperty[] | No default | The builder's value. |
validateSchemaProperties
(props: SchemaProperty[], path?: string) => { id: string; path: string; message: string }[]. Every problem that should block saving.
| Prop | Type | Default | Description |
|---|---|---|---|
propsRequired | SchemaProperty[] | No default | The builder's value. |
path | string | "" | Prefix for nested paths. |
SchemaType
Type export.
| Prop | Type | Default | Description |
|---|---|---|---|
SchemaType | "string" | "number" | "integer" | "boolean" | "enum" | "array" | "object" | No default | Enum 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.