App tools
Register app actions and queries that agents discover and call through Genie.
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 appSettled native navigation
createNavigationTools from genie-react/navigation provides an opt-in adapter for Expo Router
and React Navigation native stacks. Register its three tools and forward navigator events to it:
import { registerGenieTools } from 'genie-react/client'
import { createNavigationTools } from 'genie-react/navigation'
// Create once for this navigator's lifetime, for example inside the layout component's effect.
const integration = createNavigationTools({
getState: () => navigation.getRootState(),
isCurrentHref: (href) => matchesCurrentHrefIncludingParams(href),
router: {
push: (href) => router.push(href),
replace: (href) => router.replace(href),
navigate: (href) => router.navigate(href),
dismissTo: (href) => router.dismissTo(href),
back: () => router.back(),
canGoBack: () => router.canGoBack(),
},
})
const unregister = registerGenieTools(...integration.tools)
// Forward state, transitionStart and transitionEnd through each relevant native stack's
// screenListeners. Retain the controller in a ref when creating it inside an effect.
// <Stack screenListeners={integration.screenListeners} />
// On layout cleanup:
// unregister()
// integration.dispose()The app supplies matchesCurrentHrefIncludingParams: compare the complete destination using your
routing rules, including parameters. Comparing only a route name can incorrectly skip a requested
parameter change. With Expo Router's typed routes, validate or cast hrefs to your app's Href
type at this adapter boundary. The repository's
complete Expo Router layout
shows effect cleanup, refs, registration and event forwarding for its two known routes.
These tools appear only after registration. Replace your existing handlers with the integration; installing Genie alone does not change app-owned navigation tools.
| Tool | Arguments | Result |
|---|---|---|
app_navigate | href: nonempty string; mode: navigate (default), push, replace, dismiss_to; timeoutMs: integer 1–15000, default 5000 | Resulting route, active route path, stack depth and settlement |
app_navigate_back | timeoutMs: integer 1–15000, default 5000 | Resulting route, stack depth and settlement; no-op when back is unavailable |
app_navigation_state | None | Current route and stack snapshot, transitioning and unsettledNavigation; does not wait |
npx @genie-react/cli call app_navigate \
'{"href":"/details","mode":"push","timeoutMs":5000}' --json{
"currentRoute": { "key": "details-instance-2", "name": "details" },
"stackDepth": 3,
"stackScope": "deepest-active-stack",
"settled": true,
"reason": "transition-end"
}Read the destination from this response; no sleep or follow-up state call is needed when
settled:true. Explicit push can add another instance of an existing screen. stackDepth and
stackRoutes expose that duplication immediately. In nested navigators, depth describes the
deepest stack on the active route path, not the sum of every navigator's routes.
Success follows a matching native transitionEnd and the resulting navigation state. An already
current navigate or dismiss_to, or a back action with no history, returns reason:"no-op".
React render quiescence is independent of native transition completion.
Concurrent calls serialize, with at most 32 waiting calls. The timeout includes time waiting in
the queue; an expired queued action is never dispatched. Read settled on every response:
timeout, transition-in-progress, previous-navigation-unsettled, queue-full, unavailable,
dispatch-error and disposed all report settled:false with the state observed at return time.
A timeout does not undo navigation. Do not blindly retry a push.
After a dispatched action times out, further actions remain blocked until its matching transition finishes. If the navigator never emits that event, inspect the actual app state and correct the listener wiring before disposing and recreating the integration. Forward events from every stack that can perform the action. State changes alone, parameter-only updates without a transition, and non-native navigators do not establish native transition completion.
Register 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 current store state at call time with getState(). 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'advertises read-only and idempotent annotations. Its handler must honor them.kind: 'action'marks a mutation; actions also acceptdestructive: trueandidempotent: true.group: 'checkout'lists the tool underapp.checkoutfor progressive discovery on large apps.tools appstill covers every subgroup;tools app.checkoutnarrows to one. Omit it when the app exposes only a few tools.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 runtime retains the error code and hint in its handler error message. The executable CLI
returns a sanitized JSON error with reason: "tool-error"; it does not expose that raw message.
If an agent must branch on an expected domain outcome such as CART_EMPTY, return a typed result
with that code in your tool's output schema.
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.
Inspect a tool's JSON descriptor with tools app_login_as before calling it. annotations reports
readOnlyHint, idempotentHint, and destructiveHint when declared. These are handler contracts,
not enforcement or a promise that a read has no runtime cost.
Unavailable tools
A tool registered by an unmounted component stays listed instead of vanishing:
npx @genie-react/cli tools app_checkout_state
npx @genie-react/cli call app_checkout_state '{}'The descriptor exposes available: false and unavailableReason. The call returns a JSON error
with reason: "tool-unavailable" and a nonzero exit code. This differs from reason: "tool-error"
when a mounted handler throws. Read the descriptor's recovery reason, navigate to the UI that
registers the tool, and discover it again before retrying.
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
- App tool results have a 128 KiB runtime cap, configurable with
maxResultBytes. Oversize results fail the tool call. The CLI output budget is separate; raising one cap does not raise the other. - 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.