Inertia: pages, server-declared tools and navigation
@toolmark/inertia supports @inertiajs/react 2 and 3 on React ≥ 18.3. Inertia 3 requires React 19 (@inertiajs/react 2 works with React 18.3 or 19); the CI matrix never runs React 18.3 × Inertia 3, because that combination cannot exist.
@toolmark/inertia (@inertiajs/react 2 and 3) adds three things in M2 (spec §10.1, §12.4):
inertiaPages— a page scope per visit that registers server-declared tools from thetoolmarkpage prop and disposes them on navigation (D23);navigationTool— a GET-only tool that visits named routes (Wayfinder or Ziggy);inertiaFormComponentAdapter— form tools for the uncontrolled Inertia<Form>component.
inertiaAdapter (M1) still covers useForm forms. For the server side, see the Laravel props builder.
Setup
// resources/js/app.tsx
import { createInertiaApp, router } from '@inertiajs/react'
import { createRoot } from 'react-dom/client'
import { ToolmarkProvider } from '@toolmark/react'
import { inertiaPages, navigationTool } from '@toolmark/inertia'
import { toolmark } from './toolmark' // createToolmark(...) (see the Laravel reference, section 1)
import { routes } from './navigation-routes' // Record<string, RouteFn>, see "navigationTool" below
createInertiaApp({
resolve: (name) => import(`./Pages/${name}.tsx`),
setup({ el, App, props }) {
toolmark.use(inertiaPages({ router, initialPage: props.initialPage }))
// Root scope: the navigation tool must survive page swaps.
toolmark.register(navigationTool({ routes, visit: (url, opts) => router.visit(url, opts) }))
createRoot(el!).render(
<ToolmarkProvider toolmark={toolmark}>
<App {...props} />
</ToolmarkProvider>,
)
},
})Pass Inertia's router directly: it satisfies RouterLike in both majors. RouterLike.on is typed for the events both majors dispatch (InertiaCommonEventName: start, navigate, success, error, finish); InertiaEventName also names the major-specific ones (httpException, networkError in 3; invalid, exception in 2). RouterVisitOptions and VisitDataValue mirror Inertia's visit options without importing @inertiajs/* types.
inertiaPages
inertiaPages({ router, initialPage, propsKey? }) (InertiaPagesOptions, propsKey default 'toolmark') returns a consumer for tm.use:
- It holds one transparent page scope, so tools keep exactly the names the server gave them.
- On attach it registers
initialPage.props[propsKey]; on everynavigateevent it disposes the page scope and registers the new page's entries. An unchanged entry list (Inertia firesnavigateon first load and afterpreserveStatevisits) keeps the registrations: no revision bump. A changed list bumps the revision once. - A navigation does not cancel an in-flight props call (a props tool's own visit swaps the page before its success callback). Only the consumer's disposer does: it removes the page tools, unsubscribes from the router and settles every in-flight props call
cancelledsignal. - At most one
inertiaPagesper registry: a second one does nothing and emitsduplicate_name. - A props value that is
null/missing registers nothing; any other non-array → oneinvalid_props_toolevent. Nothing innavigatehandling ever throws into Inertia. - Under SSR it does nothing.
The toolmark props shape (public API)
The shape is public API (spec §17). Each entry is a PropsToolEntry: a manifest-shaped tool plus the visit that runs it.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "toolmark page prop",
"type": "array",
"maxItems": 64,
"items": {
"type": "object",
"required": ["name", "description", "inputSchema", "visit"],
"properties": {
"name": { "type": "string", "pattern": "^[A-Za-z0-9_.-]{1,128}$" },
"title": { "type": "string", "maxLength": 128 },
"description": { "type": "string", "minLength": 1, "maxLength": 2048 },
"inputSchema": {
"type": "object",
"description": "Draft 2020-12 schema in the fromJsonSchema subset; JSON.stringify length ≤ 32768. For a non-get visit the root must be { \"type\": \"object\", \"additionalProperties\": false }. No \"properties\" may declare \"_method\" or \"_token\"."
},
"hints": {
"type": "object",
"properties": {
"readOnly": { "type": "boolean" },
"consequential": { "type": "boolean" },
"destructive": { "type": "boolean" },
"untrustedContent": { "type": "boolean" }
}
},
"visit": {
"type": "object",
"required": ["url", "method"],
"properties": {
"url": {
"type": "string",
"description": "Same-origin http(s) URL without userinfo; absolute is recommended."
},
"method": { "enum": ["get", "post", "put", "patch", "delete"] }
}
}
}
}
}Example entry:
{
"name": "post.update",
"title": "Update post",
"description": "Update this post's title and body.",
"inputSchema": {
"type": "object",
"properties": { "title": { "type": "string", "minLength": 1, "maxLength": 200 } },
"required": ["title"],
"additionalProperties": false
},
"visit": { "url": "https://app.example.com/posts/7", "method": "put" }
}How entries are checked
The client treats every entry as untrusted. It reads own properties only and skips an entry with an invalid_props_tool event (in development and production, never thrown) when:
nameis not a valid tool name,descriptionis empty, or a field has the wrong type;titleis longer than 128 (MAX_PROPS_TOOL_TITLE_LENGTH) ordescriptionlonger than 2048 (MAX_PROPS_TOOL_DESCRIPTION_LENGTH) UTF-16 code units;inputSchemais larger than 32768 characters serialized (MAX_PROPS_TOOL_SCHEMA_LENGTH), is outside thefromJsonSchemasubset (including an unsafepattern), or declares_methodor_tokenin anyproperties;- a non-GET entry's schema root is not closed (
type: "object"andadditionalProperties: false), because extra keys would reach the server's request body; visit.methodis not one of the five lowercase methods (PropsToolMethod:get,post,put,patch,delete);visit.urldoes not resolve to this page's origin overhttp:/https:without userinfo (//evil.example, backslash tricks,javascript:are all refused).
Only the first 64 entries (MAX_PROPS_TOOLS_PER_PAGE) are processed; one event reports the rest. Hint values must be booleans; unknown hint keys are dropped. A name already taken by any tool (or its LLM name) is skipped with a duplicate_name event, so a server entry can never replace an app tool.
Canonical absolute URLs. The URL is resolved once, at registration, against the page it was registered on, and the tool always visits that canonical absolute href. A relative URL can therefore not be retargeted by a later in-app navigation. Send absolute same-origin URLs anyway.
How props tools run
- Tools carry
origin: 'server'; input is validated withfromJsonSchema(inputSchema)beforerun. - Non-GET tools are at least
consequential, so every non-human caller must get a confirmation: a serverdestructive(anduntrustedContent) hint is kept,readOnlyis dropped. GET tools keep the server's hints. - Reserved keys. Input carrying
_methodor_tokenat any depth →invalid("Reserved field is not allowed"), no visit:_methodwould spoof the HTTP method (turning aposttool into adelete) and_tokenis Laravel's CSRF field. Input that is not an object →invalid. - The run calls
router.visit(url, { method, data: input, preserveState: true, ...callbacks }). Inertia attaches its own XSRF header; Toolmark never reads cookies or tokens. - Visit outcome mapping (per-visit callbacks, shared with
inertiaAdapter):
| Callback | Result |
|---|---|
onSuccess | ok({}) |
onError (validation errors) | invalid, one issue per error key |
onHttpException (3) / onInvalid (2) | error "Request failed" |
onNetworkError (3) / onException (2) | error "Network error" |
| cancelled or interrupted | cancelled signal |
onFinish with none of the above | error "Visit did not complete" |
| call signal aborted | the visit is cancelled, cancelled |
The server remains the authority: it filters the entries with its own authorization before rendering and re-authorizes and re-validates every visit it receives. The client checks are agent-UX and defence in depth, not access control.
propsTools(tm, entries, scope, { router, signal? }) (PropsToolsOptions) is the building block inertiaPages uses; call it yourself only when you manage scopes by hand.
navigationTool
navigationTool({ routes, visit, name?, description? }) (NavigationToolOptions) returns a tool definition for tm.register:
- Input
{ route, params? }(NavigationInput):routeis an enum of therouteskeys. - Returns
ok({ url })—urlis the canonical absolute href that was visited — right after starting the visit, before the page swap disposes the page scope; achanged/manifest follows (D23). Register it at the root scope withtm.register(...), never inside the page scope, so it survives navigation. - GET only. A route whose method is not
get(case-insensitive) →refusednavigation_failed("Only GET routes can be navigated; declare a server tool for mutations"). A throwing route, a malformed route result, a URL outside this origin or a throwingvisit→refusednavigation_failed. The visit receives the canonical absolute URL. paramskeys__proto__/constructor/prototype(any depth) →invalid. Parameters a route does not consume may become query-string values, as Wayfinder and Ziggy do.- Default name
navigate, description"Navigate to a page in this app. Use route names from the enum.". Emptyroutesthrows aTypeError("navigationTool needs at least one route") when the tool is built.
A RouteFn is (params?) => { url, method }.
Wayfinder route functions already return { url, method }:
// resources/js/navigation-routes.ts
import type { RouteFn } from '@toolmark/inertia'
import { index as postsIndex, show as showPost } from '@/routes/posts' // generated by Wayfinder
export const routes: Record<string, RouteFn> = {
'posts.index': () => postsIndex(),
'posts.show': (params) => showPost(params as { post: string | number }),
}Ziggy's route() returns a URL string, so the wrapper adds the method (list only GET routes):
import type { RouteFn } from '@toolmark/inertia'
import { route } from 'ziggy-js'
const get =
(name: string): RouteFn =>
(params) => ({ url: route(name, params as Record<string, string>), method: 'get' })
export const routes: Record<string, RouteFn> = {
'posts.index': get('posts.index'),
'posts.show': get('posts.show'),
}Ziggy builds absolute URLs from its configured url; when that differs from the page's origin (a proxy, a different host name), the call is refused navigation_failed.
Inertia <Form>
inertiaFormComponentAdapter({ element, formRef, router }) adapts the uncontrolled <Form> component (@inertiajs/react ≥ 2.1; verified on 2.3.28 and 3.7.1). Values, filling, dirty tracking and fields() come from domFormAdapter over the <form> element (so the same exclusions apply: hidden _token, passwords, read-only fields…); submit() calls formRef.current.submit() and settles from the router's global events.
import { Form, router } from '@inertiajs/react'
import { useEffect, useRef, type ComponentRef } from 'react'
import { createFormTools, fromJsonSchema } from '@toolmark/core'
import { synthesizeFormSchema } from '@toolmark/core/dom'
import { inertiaFormComponentAdapter } from '@toolmark/inertia'
import { useToolmark } from '@toolmark/react'
export function EditPost({ id }: { id: number }) {
const tm = useToolmark()
const formRef = useRef<ComponentRef<typeof Form>>(null)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
const element = box.current?.querySelector('form')
if (!element) return
const adapter = inertiaFormComponentAdapter({ element, formRef, router })
const schema = synthesizeFormSchema(element)
const tools = createFormTools(tm, adapter, {
name: 'post',
description: 'Edit this post.',
input: fromJsonSchema(schema),
jsonSchema: schema,
})
return () => {
tools.dispose()
adapter.dispose()
}
}, [tm])
return (
<div ref={box}>
<Form ref={formRef} action={`/posts/${id}`} method="put">
<input name="title" required />
<button type="submit">Save</button>
</Form>
</div>
)
}(For file inputs, see DOM forms; scanDom also picks up a <Form> rendered with data-tool attributes, but its submit is then the native requestSubmit.)
Outcome mapping of submit():
errorevent →invalid(one issue per error key;path: ""when none can be read);httpException(3) /invalid(2) →error"Request failed";networkError(3) /exception(2) →error"Network error";finishwithcancelledorinterrupted→cancelledsignal;- a bare
finish→ok({}). This differs from the per-visit mapping above, where a bareonFinishis anerror: here success is only observable through the globalfinish; - no mounted ref →
error"Form is not mounted"; nostartwithin 1000 ms of the submit (anonBeforereturnedfalse, or the submit was swallowed) →error"Submit did not start a visit", listeners removed;dispose()settles in-flight submitscancelledsignaland removes their listeners.
Correlation. The first start after submit() records "<method> <visit.url.href>"; a finish (or failure event carrying a visit) is accepted only when it matches, so an unrelated visit (a poll, another form) that starts or finishes meanwhile is ignored.
Known limit (Inertia 3): error, httpException and networkError events carry no visit, so they cannot be correlated. An unrelated visit that fails while this form's submit is in flight may be attributed to it.
Development and production
Misconfiguration in this package is always an error event (invalid_props_tool, duplicate_name), never a throw, because a throw inside Inertia's event dispatch would break navigation. Registry errors thrown in development while registering a props tool are re-emitted as events with their own code.
Every error, refusal and event code, with the milestone that added it, is listed in reference/codes.md.