React
@toolmark/react binds a registry to React ≥ 18.3 (spec §9). Every hook registers in an effect, is StrictMode-safe and SSR-inert. Codes: reference/codes.md.
pnpm add @toolmark/core @toolmark/reactToolmarkProvider
Create the registry once, outside render (or in useState), and provide it:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { createConfirmQueue, createToolmark } from '@toolmark/core'
import { ToolmarkProvider } from '@toolmark/react'
import { App } from './app'
export const confirmQueue = createConfirmQueue()
const tm = createToolmark({ dev: import.meta.env.DEV, confirm: confirmQueue.handler })
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ToolmarkProvider toolmark={tm}>
<App />
</ToolmarkProvider>
</StrictMode>,
)useToolmark() returns the registry (it throws outside a provider). Attach consumers with tm.use(…): the bridge, WebMCP, OpenTelemetry, MCP pairing.
ToolScope
Tools registered by the subtree get the scope's path as a name prefix; when={false} hides them.
import { ToolScope } from '@toolmark/react'
export function ChallengeDialog({ open }: { open: boolean }) {
return (
<ToolScope name="challenges" when={open}>
<ToolScope name="create">
<CreateChallengeForm /> {/* registers challenges.create.fill / .submit */}
</ToolScope>
</ToolScope>
)
}useCurrentScope() returns the enclosing Scope (or undefined at the root). See Scopes.
useTool
Registers one tool while the component is mounted.
import { ok } from '@toolmark/core'
import { useTool } from '@toolmark/react'
import { z } from 'zod'
const input = z.object({ tab: z.enum(['details', 'members']).describe('Tab to open') })
export function ChallengeTabs() {
const [tab, setTab] = useState<'details' | 'members'>('details')
useTool({
name: 'openTab',
description: 'Switch the challenge page to another tab.',
input, // keep schemas at module level: a new identity re-registers the tool
run: ({ tab }) => {
setTab(tab)
return ok({ tab })
},
})
return <Tabs value={tab} onChange={setTab} />
}run, summary and state always see the latest render's closure, so a fresh definition object every render is fine. The tool re-registers only when name, description, title, hints (shallow) or the input/output/jsonSchema identities change; in development a tool that re-registers on every render triggers a churn warning.
useFormTool + rhfAdapter
Registers <name>.fill and <name>.submit (consequential) for a form library adapter. The react-hook-form adapter lives in @toolmark/react/rhf.
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { useFormTool } from '@toolmark/react'
import { rhfAdapter } from '@toolmark/react/rhf'
const challengeSchema = z.object({
title: z.string().min(1).describe('Challenge title'),
startsAt: z.iso.date().describe('Start date, YYYY-MM-DD'),
})
type Challenge = z.infer<typeof challengeSchema>
export function CreateChallengeForm({ save }: { save: (v: Challenge) => Promise<{ id: number }> }) {
const form = useForm<Challenge>({ defaultValues: { title: '', startsAt: '' } })
const root = useRef<HTMLFormElement>(null)
useFormTool(rhfAdapter(form, { onSubmit: save, root: () => root.current }), {
name: 'create',
title: 'Create challenge',
description: 'The new-challenge form. Fill it, then submit to create the challenge.',
input: challengeSchema,
submitSummary: (v) => `Create challenge "${v.title}"`,
})
return (
<form ref={root} onSubmit={form.handleSubmit(save)}>
<input {...form.register('title')} />
<input type="date" {...form.register('startsAt')} />
</form>
)
}root (or elementFor(path)) gives the adapter the field elements, which enables tour anchors and element-based redaction (password, cc-*); without elements, declare sensitive fields in sensitive. Fill rules, arrays and options: Forms. Files: Files.
useWizardTool
Multi-step forms: in parent-state mode (data + setData) it registers <name>.fill, <name>.goTo and <name>.submit over all steps.
import { ok } from '@toolmark/core'
import { useWizardTool } from '@toolmark/react'
import { rhfAdapter } from '@toolmark/react/rhf'
export function EventWizard() {
const [data, setData] = useState({ basics: {}, venue: {} })
const [current, setCurrent] = useState('basics')
const form = useForm()
useWizardTool({
name: 'event',
description: 'The new-event wizard.',
steps, // [{ name: 'basics', input: basicsSchema }, { name: 'venue', input: venueSchema }]
data,
setData,
current,
goTo: setCurrent,
currentAdapter: rhfAdapter(form, { onSubmit: () => undefined }),
submit: async (merged) => {
await createEvent(merged) // use the argument: it includes the visible step's latest edits
return ok({})
},
})
return <StepForm step={current} form={form} />
}Stepwise mode, validation and undo: Wizards.
Confirmations
Inline (WebMCP, MCP, tours; optional for inapp): useConfirmQueue(queue) subscribes to the queue passed as confirm: queue.handler.
import { useConfirmQueue } from '@toolmark/react'
import { confirmQueue } from './main'
export function InlineConfirm() {
const { pending, approve, reject } = useConfirmQueue(confirmQueue)
if (!pending) return null
return (
<dialog open aria-label="Confirm action">
<p>{pending.summary}</p>
<button onClick={() => approve()}>Approve</button>
<button onClick={() => reject('declined')}>Reject</button>
</dialog>
)
}Deferred (inapp default): usePendingConfirmations() lists needs_confirmation calls and answers them; the tool then runs as caller human.
import { usePendingConfirmations } from '@toolmark/react'
export function PendingCards() {
const { items, approve, reject } = usePendingConfirmations()
return items.map((p) => (
<article key={p.confirmId}>
<p>{p.summary}</p>
<button onClick={() => void approve(p.confirmId)}>Approve</button>
<button onClick={() => void reject(p.confirmId, 'declined')}>Reject</button>
</article>
))
}See Confirmation modes.
useAgentActivity
A live list of in-flight calls, for a "the agent is working" indicator.
import { useAgentActivity } from '@toolmark/react'
export function AgentBusy() {
const { active } = useAgentActivity()
const agentCalls = active.filter((c) => c.caller !== 'human')
return agentCalls.length > 0 ? <p role="status">Agent is using {agentCalls[0]!.tool}…</p> : null
}useToolAnchor
Points a tool (or one of its params) at a custom widget for tours: the returned ref callback sets tm.setAnchor(tool, param, el) and clears it on detach.
import { useToolAnchor } from '@toolmark/react'
export function ZoomableChart() {
return <canvas ref={useToolAnchor('chart.zoom', 'level')} />
}Anchor each (tool, param) from one component only. Form adapters supply anchors for their fields automatically. See Anchors and state.
useTour
@toolmark/tour/react renders a tour headlessly: useTour(tour) returns the current TourState (or null).
import { startTour, type Tour } from '@toolmark/tour'
import { useTour } from '@toolmark/tour/react'
import { useToolmark } from '@toolmark/react'
export function TourCallout() {
const tm = useToolmark()
const [tour, setTour] = useState<Tour | null>(null)
const state = useTour(tour)
const start = async () =>
setTour(
await startTour(tm, {
mode: 'show',
steps: [{ tool: 'challenges.create.fill', param: 'title', text: 'Name your challenge' }],
}),
)
if (!state || state.status === 'done' || state.status === 'stopped') {
return <button onClick={() => void start()}>Show me how</button>
}
return (
<aside role="dialog" aria-label="Tour">
<p>{state.steps[state.index]?.text}</p>
<button onClick={() => void tour?.next()}>Next</button>
<button onClick={() => tour?.stop()}>Close</button>
</aside>
)
}The styled overlay, planners and modes: Tours.
StrictMode and SSR
- StrictMode. Registration happens in effects; the dev-only double mount/unmount leaves exactly one live registration per tool.
ToolScopere-creates its scope after a simulated unmount and re-renders its subtree, anduseToolAnchorkeeps its callback identity on React 18 and 19. - Stable identities. Hooks read callbacks through refs, so inline closures never force re-registration; schemas (
input) should be module-level constants. - SSR. On the server there is no
document: the registry is inert, hooks register nothing and the confirmation/activity hooks return frozen empty snapshots. See Next.js. - Development flag. Hooks follow the registry's
devflag, not the bundler's environment.
API: @toolmark/react, @toolmark/react/rhf, @toolmark/tour/react.