Wizards
A multi-step form usually keeps one form per step and the collected data in the parent. Toolmark turns the whole wizard into a few tools, so an agent can fill every step in one call (spec §8.2, D25). Two modes exist:
- Parent-state mode (preferred): the parent owns the data (
useStateslices). Tools:<name>.fill,<name>.goTo,<name>.submit(consequential) and<name>.optionswhen a step declares options. Core:createWizardTools; React:useWizardToolwithdata+setData. - Stepwise fallback: the app cannot expose parent state. Tools:
<name>.step.fill,<name>.next,<name>.previous,<name>.submit, each withmode: 'stepwise'in the manifest. Core:createStepwiseWizardTools; React:useWizardToolwithoutdata/setData.
Fill semantics are the form-fill semantics of forms applied per step: fail-closed undeclared paths, forbidden keys, duplicate paths, the node budget (10000 nodes per step), array operations, files, null clears, redaction and skipping of user-edited fields. <name>.fill and <name>.step.fill are untrustedContent: their results carry values the user typed or the page loaded.
React: parent-state mode
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { ok, type OptionsProvider, type WizardStep } from '@toolmark/core'
import { useWizardTool } from '@toolmark/react'
import { rhfAdapter } from '@toolmark/react/rhf'
const basics = z.object({ title: z.string().min(1), category: z.enum(['idea', 'problem']) })
const schedule = z.object({ startsAt: z.iso.date(), endsAt: z.iso.date() })
const people = z.object({ ownerId: z.string(), reviewers: z.array(z.object({ name: z.string() })) })
declare const findPeople: OptionsProvider // your lookup
declare function createEvent(data: Record<string, Record<string, unknown>>): Promise<void>
const steps: WizardStep[] = [
{ name: 'basics', title: 'Basics', input: basics },
{ name: 'schedule', title: 'Schedule', input: schedule },
{ name: 'people', title: 'People', input: people, options: { ownerId: findPeople } },
]
export function CreateEventWizard() {
const [data, setData] = useState<Record<string, Record<string, unknown>>>({})
const [current, setCurrent] = useState('basics')
const stepSchema = { basics, schedule, people }[current as 'basics' | 'schedule' | 'people']
const form = useForm({ resolver: zodResolver(stepSchema), values: data[current] ?? {} })
useWizardTool({
name: 'create',
description: 'The new-event wizard.',
steps,
data,
setData,
current,
goTo: setCurrent,
currentAdapter: rhfAdapter(form, { onSubmit: () => undefined }),
// Use the argument: `data` captured by this closure may miss the current step's latest edits.
submit: async (merged) => {
await createEvent(merged)
return ok({})
},
})
return null // render the current step's form here
}useWizardTool takes UseWizardToolOptions (the fields shown above plus title, resetCurrent, next, previous and submitSummary). The hook re-registers only when name, description, title, the step names or count, a step's input/jsonSchema identity, or the mode changes. goTo, submit, setData, currentAdapter, resetCurrent and every step's options providers are read through refs, so fresh closures each render are fine.
The tools
<name>.fill
Input: { steps: { <step>: partial values }, overwrite? }. Any subset of steps may be filled at once. Returns ok({ changes, skipped }) with every path prefixed <step>., or invalid with the issues of every invalid step (<step>.<path>). An unknown step → invalid at <step> ("Unknown step"); a non-object step value → invalid at <step>. A refused/cancelled/error from a step (for example file_rejected) is returned as is.
- All-or-nothing commit. Every step is validated and staged first; nothing is written unless all steps are
ok. The commit then does onesetDatacall for the data-backed steps and writes the current step through its mounted form. If writing the mounted form (orresetCurrent) throws, the previous parent data is restored withsetData(prev)and the call endserror("Tool failed") with nothing registered for undo. - The current step is read from and written through
currentAdapter()when it returns a form, so the visible form updates and keeps the user's edits (fields the user typed are skipped unlessoverwrite). Other steps go into parent data. - No mounted form for the current step: the step is written into parent data and
resetCurrent(values)is called with the step's merged values so the app can reset the visible form. With neithercurrentAdapternorresetCurrent, theerroreventwizard_current_step_unsyncedfires once per wizard (in development and production; never thrown). - Undo.
tm.undo(callId)restores every touched step, through the form of the step that is current at undo time, otherwise throughsetData.
<name>.goTo
Input { step } → calls goTo(step) → ok({ step }). With useWizardTool in parent-state mode, the mounted current step's values are first written into data (via setData), so undo and submit still see them after navigating away (parent-data sync).
<name>.submit
Takes no input (only {} or nothing). Before any confirmation is created, every step's full schema is validated (the current step from its mounted form, the others from parent data); any issue → invalid with <step>.<path> paths and no confirmation. The tool is consequential; a confirmation approved after the wizard's data or the mounted step values changed is refused stale. The default confirmation summary is "Submit <title ?? name>"; pass submitSummary(data) to change it. data is a copy of the parent data with the current step replaced by its live values (the mounted step form's values when currentAdapter returns one), so the confirmation shows what the user sees, including edits on the current step not yet synced into parent data.
With useWizardTool, the app's submit is called with the merged parent data as its argument (submit(data)), after the current step's values were synced into it. setData usually schedules a React update that is not visible in the same tick, so a data value closed over by submit is the pre-merge snapshot: read the argument. A zero-argument submit still type-checks and runs.
<name>.options
Registered when any step declares options; field is <step>.<path> (people.ownerId, people.reviewers[].id). Behaviour as in forms.
Stepwise fallback
Use it when the wizard cannot expose its data (for example each step posts to the server). Omit data/setData; next, previous and currentAdapter become required (missing ones → wizard_misconfigured: a ToolmarkError in development, an error event in production, nothing registered).
<name>.step.fillis the form fill of the current step's mounted form with the current step's schema; its description ends with" (current step: <step>)". No mounted form →refusednot_allowed("No step form is mounted").<name>.nextreturns yournext()result;<name>.previous→ok({ step }).<name>.submitis consequential and refusedstalewhen the step form changed after the confirmation was requested. In this modesubmitandsubmitSummaryreceive{}.- Stepwise refresh. When the step changes,
refresh()re-registers<name>.step.fillwith the new schema (one revision bump). It is a no-op while the current step name is unchanged, and it retries on the next call if a registration failed.useWizardToolcalls it whenevercurrentchanges.
Core API
import { createStepwiseWizardTools, createWizardTools, type Toolmark } from '@toolmark/core'
declare const tm: Toolmark
declare const opts: Parameters<typeof createWizardTools>[1]
const wizard = createWizardTools(tm, opts) // WizardToolOptions & { scope?: Scope }
wizard.dispose()WizardToolOptions fields: name, description, title?, steps: WizardStep[], getData(), setData(next) (called at most once per fill or undo), getCurrent(), goTo(step), currentAdapter?: () => FormAdapter | undefined, resetCurrent?(values), submit(), submitSummary?(data). getData, getCurrent and currentAdapter are read at call time: pass getters backed by the latest state. The step list is read at creation: re-create the wizard tools (createWizardTools) when steps change.
WizardStep: name (A–Z a–z 0–9 _ -, 1–64 characters, unique, not __proto__/prototype/ constructor), title?, input, jsonSchema?, files?, options?, sensitive? (dot paths always redacted).
createStepwiseWizardTools(tm, opts) takes StepwiseWizardOptions (name, description, title?, currentAdapter(), currentStep() → { name, input, jsonSchema? }, next(), previous(), submit(), submitSummary?()) plus scope?, and returns { dispose(), refresh() }.
Manifest schema
The fill schema has one property per step: that step's fill schema (required stripped, array operation branches, file schemas, option hints). Each step's $defs/definitions move to the root $defs as <step>.<name> and every local $ref is rewritten, so steps cannot collide.
Known limits
changes.afterfor the current step is the staged value, not a read-back after the form wrote it: an adapter that coerces values (null→'') reports the uncoerced value.- After a multi-step fill that ends
invalid, the steps that passed have updated their private "agent last set" records although nothing was written. Effect: at most one extra "not user edited" decision later. - The node budget is per step (10000 each).
Every error, refusal and event code, with the milestone that added it, is listed in reference/codes.md.