app-commands
An AI agent that works on a mobile app has a slow way to check its work. It taps through screens, takes screenshots, and reads values out of pixels. Twenty taps to put a todo list into one state, and a screenshot that cannot tell a cached value from a saved one.
This project is about a different way to build the app. Every capability the app has can be reached by name, with typed arguments, from outside the user interface:
$ pnpm app-commands todos.newTodoSubmitted --title "Buy milk"
$ pnpm app-commands apollo.inspect --prefix "Todo:"
The command runs inside the running app. It calls the same function the Add button calls, on the same store, the same GraphQL cache, and the same navigation reference. So the agent sets up a state, exercises a behavior, and reads the result in one call, and it verifies the code path a user takes.
We call an app built this way agent-addressable. docs/principles.md describes how to build one in eleven principles, each with a mechanical check. That document is the source of truth. This README introduces the idea and the workspace.
One function, two clients
The central idea is old. A screen is one client of the application. A command is another. Neither owns the behavior.
A feature declares each entry point once: its argument schema, its description, and its implementation, side by side:
// features/todos/index.ts
import { command } from "@janodetzel/app-commands";
export const createTodos = (deps: TodosDeps) => ({
add: command()
.input({ title: z.string().min(1) })
.description(
"Adds a todo through the API, updates the cache the way the screen does, and returns the todos from the cache. A repeated title adds a second todo.",
)
.run(async ({ title }) => {
await addTodo(deps.apollo, title);
return getTodos(deps.apollo, "cache");
}),
});
The handler's argument type comes from the schema, so the two cannot drift. .input takes any Standard Schema (zod, Valibot, ArkType), an object of them, or a validator function. Features compose like tRPC routers: nest one inside another, or spread two into one namespace.
The screen calls todosFeature.newTodoSubmitted({ title }). The command todos.newTodoSubmitted is that same function, with the same validation. There is no second implementation for the agent, so the agent cannot report success for behavior that users never see. Commands are named after the control a reader touches, so one that no screen can reach has nowhere to hide, and reading state is the adapters' job rather than a list command a feature writes for the agent alone.
The rest of the principles protect that one property. An entry point returns a promise that settles when the work is done, or the command reports success before the save runs. State lives outside the component tree, or a command has nothing to read when no screen is mounted. One file creates every instance, or a command eventually talks to a different client than the UI.
The eleven principles
Each principle in docs/principles.md says what to do, why it matters to an agent, and which check fails the build when code breaks it.
- The user interface is a client, not the application
- One definition per capability
- Every entry point is awaitable and honest
- State is owned outside the component tree
- Instances are created in one place
- The world arrives through dependencies
- Features own a slice and never import a sibling
- Descriptions are API, not documentation
- Commands assert data; pixels are verified elsewhere
- The transport is replaceable
- Every principle has a mechanical check
The principles are constraints, not a framework. They name no state library, navigation library, or validation library. An app can follow all of them without the packages in this repo.
What the packages add
The package makes the principles cheap to follow and expensive to break.
@janodetzel/app-commands holds the command contract, command() and featureCommands(), handleRequest, the wire protocol, the app-commands CLI, an MCP server, a web console, adapters for Apollo, React Navigation, and Zustand, and the ESLint and dependency-cruiser rules that enforce the principles. The Expo transport is behind /expo.
The core of app-commands knows four things about a command: a description, a JSON Schema, a parse function, and a run function. It imports nothing, so it works with an app built on Redux, XState, MobX, or plain service classes. command() is one way to produce those four things, from any Standard Schema; an adapter is another.
The mechanical checks from principle 11 live in the package too. For example, no-floating-promises catches a fire-and-forget call, no-ambient-io catches Date.now in a logic file, no-cross-feature-import catches one feature reaching into another, and a conformance test fails on an empty description.
The workspace
This is a pnpm workspace with the package and an example app that uses it:
packages/
app-commands/ @janodetzel/app-commands
apps/
example-app/ an Expo todo list built on it
docs/
principles.md the eleven principles and their checks
The example app is small on purpose. It exists to show the principles in running code:
apps/example-app/src/
app/
instances.ts the only file that creates stores, the Apollo client, and the navigation ref
commands.ts the registry, where features and adapters meet
App.tsx calls useAppCommands(commandRegistry)
features/ logic only, each imported through its index.ts barrel
todos/ feature.ts, api.ts, gql.ts
news/ feature.ts, api.ts, gql.ts, store.ts
profile/ feature.ts, nesting settings/: feature.ts, store.ts
screens/ the UI, a client of the features like the command registry
navigation/ the navigator and the route names
server/ a stand-in backend, so the example runs offline
Run the example
To install and build the packages, run:
pnpm install
pnpm build
To start Metro and the app, run pnpm --filter example-app start and open the app in the iOS simulator. On the Android emulator, run adb reverse tcp:8081 tcp:8081 once first.
With the app open, list its commands and call one:
$ pnpm app-commands list
$ pnpm app-commands todos.newTodoSubmitted --title "Buy milk"
$ pnpm app-commands apollo.inspect --prefix "Todo:"
$ pnpm app-commands nav.navigate --screen Settings
The CLI asks the app for its commands on every call, so a new command works after a Metro reload. Exit code 2 means the CLI could not reach the app.
An MCP client reaches the same commands as typed tools. .mcp.json registers the app-commands server, which names each tool with _ in place of the dot: todos.newTodoSubmitted becomes todos_newTodoSubmitted.
Working on the package
The example app depends on the package through the workspace, and the root
package.json depends on it too, so pnpm app-commands works from the repo root: pnpm
links a binary only into the node_modules/.bin of a package that depends on it.
// apps/example-app/package.json
"dependencies": { "@janodetzel/app-commands": "workspace:*" }
pnpm --filter @janodetzel/app-commands web:dev serves the web console from source, for
work on the console itself. The Metro Shift+M menu opens it against a running app.
cli/wire/ is copied from internal Expo code. After every Expo SDK upgrade, re-read the
files listed in cli/wire/SOURCE.md, recopy them if they changed, and rerun the
simulator smoke test. Pin the expo peer range to the tested SDK.
Checks
Run all four before you call a change done:
pnpm typecheck && pnpm lint && pnpm test && pnpm depcruise
Releasing
@janodetzel/app-commands is published to npmjs.org. Earlier releases went to GitHub
Packages, and @janodetzel/feature-kit was published there until it merged into
app-commands; those versions stay installable from GitHub Packages. Never edit a
version field or push a tag by hand; Changesets
does both.
- In a pull request that changes a published package, run
pnpm changeset. Pick the packages and the bump (patch, minor, major) and write one line for the changelog. Commit the file it adds to.changeset/. A change that ships nothing, like the example app or the docs, needs none. - When that pull request is merged, the publish workflow
opens or updates a "Version Packages" pull request. It bumps the versions and
writes each package's
CHANGELOG.md, and it collects every changeset merged since the last release. - Merge "Version Packages" when you want to release. The workflow publishes every
package whose new version is not on npmjs.org yet, then pushes a tag such as
@janodetzel/[email protected]and creates a GitHub release for it.
Run pnpm changeset status to see what the next release would bump.
Further reading
docs/principles.mdexplains each principle and what it does not claim.packages/app-commands/README.mdcovers the command contract, the CLI and its exit codes, the MCP server, the adapters, the lint rules, and release builds.CLAUDE.mdtells an agent how to work in this repo. The skills inpackages/app-commands/skillshold the longer instructions.