App tools
Tools the app registers itself — domain actions and queries agents call like built-ins.
The JSON examples below show selected fields from the full response.
Apps can register their own tools with useGenieTool. They appear under the app group, named
app_<name>, and are called like any built-in. Use them for what only the app can do: log in as a
role, seed fixtures, inject API failures, jump a wizard step.
The tools on this page come from the demo apps (apps/vite-demo, apps/router-demo). Your app
defines its own. The contract command is always the source of truth:
npx @genie-react/cli tools appRegister a tool
One declaration in a component. The zod schema drives validation, TypeScript types, and the advertised JSON Schema:
The examples import zod; add it to the app directly (pnpm add zod) — genie-react depends on
it internally, but package managers do not expose transitive dependencies to your imports.
import { useGenieTool } from 'genie-react'
import { z } from 'zod'
useGenieTool({
name: 'login_as',
kind: 'action', // or 'query' — tells the agent read vs mutate
description: 'Switches the session role and re-gates the UI.',
input: z.object({ role: z.enum(['guest', 'member', 'admin']) }),
handler: ({ role }) => switchRole(role),
})The handler always sees the latest render's state. There is no dependency array. The name, schema,
and description are fixed at mount — to change them at runtime, key the hook off enabled or the
name.
Register several tools at once
One useGenieTools call registers a whole panel; inline handlers keep the same latest-render
guarantee:
import { defineGenieTool, useGenieTools } from 'genie-react'
import { z } from 'zod'
useGenieTools([
defineGenieTool({
name: 'session',
kind: 'query',
description: 'Active role and what the task UI permits.',
handler: () => ({ role }),
}),
defineGenieTool({
name: 'login_as',
kind: 'action',
description: 'Switches the session role and re-gates the UI.',
input: z.object({ role: z.enum(['guest', 'member', 'admin']) }),
handler: ({ role: next }) => setRole(next),
}),
])apps/vite-demo/src/TaskPanel.tsx registers its five tools this way.
Register from a store or any module
Outside React, registerGenieTools works from a store, an API client, or module scope. It waits
for the genie client if it has not started yet, and returns an unregister function:
import { defineGenieTool, registerGenieTools } from 'genie-react/client'
import { useCartStore } from './cart-store'
const unregister = registerGenieTools(
defineGenieTool({
name: 'cart_state',
kind: 'query',
description: 'Current cart line items and totals from the zustand store.',
handler: () => useCartStore.getState().summary(),
}),
defineGenieTool({
name: 'cart_clear',
kind: 'action',
destructive: true,
description: 'Empties the cart store. No undo.',
handler: () => useCartStore.getState().clear(),
}),
)Store handlers read state at call time (getState()), so nothing goes stale. Calling
unregister() tombstones the tools instead of deleting them. For tools that should live as long
as the app, pass them to the provider instead: <Genie tools={[...]} />.
Options:
kind: 'query'marks the tool read-only and safe to retry.kind: 'action'marks a mutation; actions also acceptdestructive: trueandidempotent: true.group: 'checkout'lists the tool underapp.checkoutfor progressive discovery on large surfaces.tools appstill covers every subgroup;tools app.checkoutnarrows to one. Omit it when the app exposes a handful of tools — flatappreads fine.outputis an optional zod schema, advertised and drift-checked in dev builds.maxResultBytesraises the 128KB result cap when a large result is intentional.enabled: falsegates registration, e.g. behind a feature flag.
Throw GenieToolError for failures the agent should act on:
throw new GenieToolError('cart is empty', { code: 'CART_EMPTY', hint: 'seed with app_seed_tasks' })The agent sees [CART_EMPTY] cart is empty — hint: seed with app_seed_tasks. Plain throws are
wrapped with the tool name so an app bug is not blamed on Genie.
Discover and call
npx @genie-react/cli tools app
npx @genie-react/cli call app_login_as '{"role":"admin"}' --jsonOutput (selected fields):
{ "role": "admin", "canAdd": true, "canDelete": true }Arguments are validated in the app by the tool's own schema: per-field errors, defaults applied, unknown keys rejected with the valid keys listed.
Badges in listings state intent before a call: (read-only) is free, (action) mutates,
(destructive) has no undo.
Unavailable tools
A tool registered by an unmounted component stays listed instead of vanishing:
npx @genie-react/cli call app_checkout_state '{}'
# Call to app_checkout_state failed [tool-unavailable]: Tool "app_checkout_state" is currently
# unavailable — the code that registers it is not mounted (it was registered at URL path /checkout).
# Drive the app back to that UI, then retry.The failure carries errorCode: "tool-unavailable" (distinct from a handler throwing), so scripts
can branch on it. Recovery is usually one call — router_navigate to the named route, then retry.
The listing marks these tools with ✗ unavailable; the detail view carries the reason.
Patterns
| Pattern | Demo tool | Replaces |
|---|---|---|
| Impersonation | app_login_as | Driving a login form for every role |
| Fixtures | app_seed_tasks, app_reset_tasks | Hand-building test data per run |
| Fault injection | app_chaos | Hoping an error state happens naturally |
| Step-jumping | app_checkout_goto | Filling three wizard forms to test the last |
apps/vite-demo/src/TaskPanel.tsx and apps/router-demo/src/routes/checkout.tsx show the full
implementations.
Limits
- Results are capped at 128KB per tool (override with
maxResultBytes). Over the cap, the call fails with the size and the top-level keys. - Keep synchronous handler work well under a second; a blocked main thread reads as busy.
- Tools exist only while the registering code runs. In production builds without a Genie client, registration is inert.