- name
- actions
- description
- How to create and run agent actions. Actions are the single source of truth for app operations — the agent calls them as tools and frontend code calls them through client hooks. Use when creating a new action, adding an API integration, or wiring up frontend data fetching.
- scope
- dev
- metadata
- {"internal":true}
# Agent Actions
## Rule
Actions in `actions/` are the **single source of truth** for app operations. The agent calls them as tools, the frontend calls them through `useActionQuery` / `useActionMutation`, and the framework owns the HTTP transport behind those hooks — no duplicate `/api/` routes.
Before creating any custom route for app data, check `actions/` and the action table in `AGENTS.md`. An action already exists? Call it directly. Missing? Create or update a `defineAction`. **Stop trigger:** about to add a file under `server/routes/api/` (or middleware to guard one)? Check it against the exception list in *Custom `/api/` Routes* below first — even if you already started the route.
## Keep Actions Deterministic
An action may call a provider API, validate data, and persist records without being an AI feature — keep it deterministic, focused, and independently useful to the agent. Don't put LLM calls or a second model runtime in app actions.
The sanctioned model-backed exception is `@agent-native/core/decisions` for capability-aware classification and scoring, and it returns a result without performing or authorizing the selected action.
When a workflow is research, analysis, generation, recommendation, or synthesis — or spans several provider calls and writes — route it to the AgentSidebar via `sendToAgentChat({ openSidebar: true })` and let the agent orchestrate focused actions instead of hiding an AI-shaped workflow behind one opaque `generate-*`/`create-*` action just because its implementation happens to be deterministic.
## How to Create an Action
**One action per file, default-exported. The filename is the action name** (kebab-case): `actions/list-meals.ts` is the `list-meals` action. Don't put several `defineAction`s as named exports in one file — the registry keys actions by filename and only the default export is registered, so extra named exports never become callable and won't match `.generated/action-types.d.ts`.
```ts
// actions/list-meals.ts
import { z } from "zod";
import { defineAction } from "@agent-native/core/action";
import { getDb } from "../server/db/index.js";
import { meals } from "../server/db/schema.js";
export default defineAction({
description: "List all meals",
schema: z.object({}),
http: { method: "GET" },
run: async () => {
const db = getDb();
const rows = await db.select().from(meals);
return rows; // Return objects/arrays, NOT JSON.stringify()
},
});
```
`schema` (Zod or Standard Schema-compatible) gives runtime validation, TS inference for `run()` args, and an auto-generated JSON Schema for the tool. Write it so an agent can call the tool correctly on the first try: `.describe()` every param and state enum values and the default in that text (`z.enum(["open","closed"]).default("open").describe('Filter status: "open" or "closed"; defaults to "open"')`); mark a field `.optional()` only when the action truly tolerates its absence — a silent default the caller cannot see is a required field in disguise; name the primary id the same way across a family (`create-/get-/update-/delete-<thing>` all take `id`, not a mix of `id` and `<thing>Id`), and if one action must differ, accept both with `.or()`; give nested objects a real schema, never a prose description; keep the one-line `description` complete enough to call the tool without a second lookup, because compact catalogs and `tool-search` show only that line. Use `z.coerce.number()` for numeric HTTP params, but write an explicit boolean parser instead of `z.coerce.boolean()`, which treats `"false"` as truthy.
Use Drizzle's PostgreSQL query builder, not raw SQL/`getDbExec()` or direct driver imports, unless Drizzle can't express the query. Never hardcode API keys/tokens/secrets - read via `readAppSecret` / `resolveCredential` / OAuth helpers; `process.env` is deploy-level config only.
**Decision order:** existing action → extend/create a `defineAction` → custom route as last resort (*Custom `/api/` Routes* below). Actions are already callable by agents, CLIs, hooks, HTTP, and MCP/A2A — don't wrap them in an umbrella REST API.
## Keep the Action Surface Small and Orthogonal
Every agent-exposed action is a tool in the model's context window; more tools degrades tool-selection quality. Add the fewest, most orthogonal actions that cover the capability.
- **One orthogonal `update` per resource**, not one per field — `update-<thing>` taking an optional-fields patch, not `update-<thing>-name` + `update-<thing>-order` + …
- **Reach for a generic escape hatch before minting a new read action** — the `provider-api-catalog`/`docs`/`request` trio for provider data (`references/provider-apis.md`), `db-query` for ad hoc app-data reads in dev.
- **`agentTool: false`** hides a UI-only/programmatic action from the model while keeping it frontend/HTTP-callable — not `toolCallable: false`, which only blocks the sandboxed extension bridge and leaves the action visible everywhere else; reserve that one for high-blast-radius operations.
- **Delete or hide stale actions** once you confirm neither the UI nor an agent workflow uses them.
## Key Actions — One Index, Every Surface
Document the app's primary actions in the `AGENTS.md` action table. For first-turn loading, add those action names to the plugin's `initialToolNames` (`INITIAL_TOOL_NAMES` in some templates), or mark the full starter set `deferLoading: false` on the actions. When one action opts into per-action eager loading, unmarked actions are deferred; the remaining actions stay discoverable through `tool-search`. When users name an action's job in words its name and description lack ("auto-archive rule", "canned reply"), add those words as `searchHints`: `tool-search` ranks them just below the name and never shows them to the model.
When MCP is enabled, its `Key tools` line is generated from `mcp.keyToolNames ?? initialToolNames` and filtered to the actions served on that surface. A configured `mcp.keyToolNames` overrides the advertised MCP/WebMCP key list; it does not change the in-app agent's first-turn list. Keep the advertised subset consistent with the primary actions in the `AGENTS.md` table and the external catalog; the table may also document other in-app actions. Do not hand-write another action list in `mcp.instructions`, a skill, or an external skill; describe when to use actions there.
## The `http` Option
Controls HTTP exposure:
| Value | Behavior | Use for |
| --- | --- | --- |
| _(omitted)_ | `POST /_agent-native/actions/:name` | Write operations (default) |
| `{ method: "GET" }` | `GET /_agent-native/actions/:name` | Read-only queries |
| `{ method: "PUT"/"DELETE" }` | matching verb | Update / delete |
| `{ method: "GET", path: "x" }` | custom route path | Non-default path |
| `false` | never exposed as HTTP | `navigate`, `view-screen`, internal |
Mutating actions (anything but `GET`) auto-refresh the UI on success — don't call `refresh-screen` after a normal action. Overrides (`readOnly`, `parallelSafe`) and exact trigger rules: `references/action-fields.md`.
## Saves That Can Race
An action that writes a value a person or another agent may also be changing checks what it read:
- **Server:** `saveIfCurrent` from `@agent-native/core/save-conflict/server` takes `read`, `write` and `expectedRevision`. It checks and writes in one statement, confirms exactly one row changed, ignores a repeat of a save it already applied, and throws one typed conflict carrying the current value. Use `saveRevisionOf(value)` as the revision, so no migration is needed and any writer moves it. Set `requireRevision` for a write that replaces a whole value: a caller that read nothing gets the conflict, with the current value, and can redo its edit.
- **Reads** return the same revision, and the schema takes it as `expectedRevision` with `EXPECTED_REVISION_DESCRIPTION`.
- **Browser:** `saveWithMerge` from `@agent-native/core/save-conflict` merges field by field on a conflict and saves again, so most apps need no merge code. `useSaveConflictPrompt` from `@agent-native/toolkit/app/save-conflict` is its `resolveOverlap`: a person sees it only when both writers changed the same field, and can get the other version back.
- **Agents** never see the prompt. They get the conflict and redo the edit on `details.current`.
Outcomes are counted (`save_outcome_counts`, and `save_conflict_prompt` in the browser), so prompts that could have been automatic show up.
## Return Values
Return **structured data** (objects, arrays), never `JSON.stringify()` — the framework serializes the response and only tries to parse a returned string as JSON, which isn't the same contract.
```ts
run: async (args) => await fetchEvents(args.from, args.to); // good
run: async (args) => JSON.stringify(await fetchEvents(...)); // bad
```
A create/update result is read by more than the caller. These keys are well-known and survive result truncation:
- `id` — the created or updated record. Pair it with a pure, synchronous `link` builder on the action (`link: (result) => ({ url: buildDeepLink({ app, view, params: { id: result.id } }), label })`) so MCP hosts and the desktop app surface an "Open …" deep link that lands in the editing view focused on the record; returning `url`/`urlPath` in the result works too.
- `nextRequiredAction` — the name of the next **tool** to call when the operation is one step of a flow the agent should keep driving (`nextRequiredAction: "update-slide"`). Never phrase it as waiting for the user: an MCP/WebMCP/A2A caller cannot receive an in-app answer. The agent loop keeps this field in continuation prompts even when the rest of a large result is dropped, and MCP callers see it as `Next: …`.
- `message`/`summary` — the one-line status external callers read; everything else stays in the structured result.
- `designSystem` — when a record can link a brand or design system, expose it as `designSystem: AgentDesignSystemContext | null` via `loadAgentDesignSystemContext(id, getDesignSystemAction)` from core's shared `design-system-agent-context`. Reads get the bounded summary (`scope: "summary"`, with a `next` line naming the full read); only the create action and `get-design-system` itself pass `{ full: true }`. `status: "unavailable"` is not `null`: keep the id and the message. Print it in text results with `formatAgentDesignSystemContext`; do not name the field anything else.
For actions that attach a `chatUI` renderer, use `chatUI.projectResult` to return
only the small structured fields the renderer needs. `chatUI.when` is evaluated
against the full successful result first; the projection is used live and saved
for interrupted-run recovery.
An action that hands control back to the user (question form, intake dialog) sets `endsTurn: true`; that hides it from MCP/WebMCP/A2A unless `mcpTool: true` is explicit — `references/action-fields.md`. For external-agent integrations, use the external-agents documentation slug listed by `agent-native-docs`.
Every new action declares `outputSchema`: it types the action's return and is enforced strictly by default (`guard:action-output-schema`). Declare its failure codes with `defineActionErrors` and fail through them. Reach for `_agentImages` (attach images the agent can see), `authorize` (gate who may call it), or `needsApproval` (require human sign-off per call) only when the action needs that guarantee — examples in `references/action-fields.md`.
To test an action, start with `checkActionProperties(action, { context })` from `@agent-native/core/action-properties`. It generates inputs from the zod `schema`, and fails on any throw that is not an action contract error, on a return its `outputSchema` rejects, and on an invalid input that reaches `run`. It needs `fast-check` as a devDependency, and it throws `UnsupportedSchemaError` for a schema shape it cannot generate unless you pass an override.
### Write receipts
A write action that can check its own effect returns a plain-object result with a reserved `_receipt`, so the final answer is reconciled with what the write did, not with the model's reading of a JSON string that may be truncated:
```ts
import type { WriteReceipt } from "@agent-native/core/action";
const _receipt: WriteReceipt = {
changed: true,
verified: false,
summary: "Saved; panel 3 returned no rows.",
checks: [{ id: "panel-3", ok: false, detail: "0 rows" }],
};
return { id, _receipt };
```
`verified` is `true` (the effect was observed), `false` (checked and did not hold), or `"unverified"` (could not be checked); `checks` and `warnings` are optional. The agent loop reads the receipt before the result is stringified and truncated (summary 200 chars, 8 checks, 5 warnings):
- `verified: false` or `changed: false` forces one honest-reconciliation retry per turn: the model must say what the receipt shows and may not call the change visible or working. If the retry is spent, the answer is prefixed with the receipt block.
- `verified: "unverified"` only prefixes that note; no retry.
- A receipt that is present but malformed counts as `unverified`, never clean. `changed: false` also records the call as `completedSideEffect: false`, unless a write nested in the action also entered its handler: a receipt, like `retrySafe`, speaks only for the action's own write.
- Set `subject` (the stable target, such as a dashboard id) so a later `changed: true, verified: true` receipt for the same subject can supersede an earlier flagged one in the same turn. When the earlier receipt had failing or unverified `checks`, the later receipts (from this action or another that writes the same subject) must carry an `ok: true` check with the same `id` for each; a receipt without checks never clears one that had them. A flagged receipt with no failing checks is superseded only by the same action. Receipts without a `subject` are never superseded.
A receipt is not an error channel. A write that did not achieve the requested state throws (`fail()`); return `changed: false` only for a benign no-op, such as the record already being in the requested state.
## Failed Writes
The framework classifies a failed action from the writes it **saw** the call make: every database write (any statement that is not a read such as `SELECT`, `SET` or `LOCK`, and not the framework's own schema setup) and every `fetch` that is not GET, HEAD or OPTIONS. A rejection raised before any of those is "nothing saved", however it is thrown (`fail()`, a plain `Error`, a library error).
- **Declare writes it cannot see.** Before a disk write, child process, `node:http`/`https` request or vendor SDK call, call `noteExternalWrite("<channel>")`, e.g. `noteExternalWrite("local-disk")`. Without it, a failed call that already wrote looks retry-safe. `guard:no-unobserved-writes` fails new code that skips the call. A temp-only or retry-idempotent write can say why with `// observed-write-ok: <reason>` instead.
- **`withoutWriteObservation(fn)`** is only for framework bookkeeping (an audit row, a change marker, analytics) or a write a retry provably cannot repeat, such as one keyed by a deterministic id or dedup key. Never wrap the write the call exists to make. `await` the work inside `fn`: a Drizzle query builder only runs when awaited, after `fn` has returned, so `withoutWriteObservation(() => db.insert(t).values(v))` exempts nothing.
- **`retrySafe`** says this action's own write is safe to run again, so a failure after that write is not "unknown". It covers only this action's write. Don't set it on an action that appends, sends or charges unless the input carries an idempotency key.
- **`NoWriteError`** is an override, not a requirement: most rejections need no label, but it wins over an observed write when the only write before the rejection is one a retry harmlessly repeats (such as uploads nobody has referenced yet), and the framework accepts it only for the call that raised it and only when no nested write action entered that call's handler.
- **A call that times out or loses its result stays "unknown"**, because it may still be running.
## Frontend Hooks
Import from focused `@agent-native/core/client/*` entry points; the broad
`@agent-native/core/client` barrel is deprecated. Use action hooks, not
After adding or changing several related actions, smoke-test **once** (one
happy-path call that proves the wire-up), then typecheck once. Do not run
`pnpm action …` for every method unless a smoke test failed and you need to
isolate which action broke.
## CRUD + Drizzle in this starter
Before hunting through `node_modules` for DB helpers, read:
1. `drizzle/START_HERE.md` — where tables live + migrate commands
2. `drizzle/crud-action-example.ts` — copy-paste list/create/update/delete pattern
DB client: `getDb` / `schema` from `server/db.ts`.
hand-written `fetch("/_agent-native/actions/...")`.
```ts
import { useActionQuery, useActionMutation, callAction } from "@agent-native/core/client/hooks";
const { data: meals } = useActionQuery("list-meals", { date: "2025-01-01" }); // GET, types auto-inferred
const { mutate } = useActionMutation("log-meal"); // POST/PUT/DELETE
mutate({ name: "Salad", calories: 350 });
const people = await callAction("search-people", { query }, { method: "GET" }); // imperative (debounce, prefetch)
```
Don't add manual generics like `useActionQuery<Meal[]>(...)` — types come from `.generated/action-types.d.ts`. Mutations auto-invalidate all `["action"]` query keys, so GET queries refetch. A revisit paints the persisted result for the signed-in user and org before that refetch; see `references/action-fields.md` for `persistInBrowser: false`.
When a query result varies by viewer or another client context that is not part of the action arguments, pass that stable context through `queryKeyScope`. It partitions only the local query cache; it is not sent to the action and never replaces server-side authorization.
## How to Run (Agent)
```bash
pnpm action log-meal --name "Salad" --calories 350
```
CLI flags become action input fields (`--key value` or `--key=value`); the runner does not read or write files for `--input` or `--output`. The default template dispatches through core's `runScript()` in `actions/run.ts`. Action names are lowercase-with-hyphens (`pnpm action my-action` → `actions/my-action.ts`).
For a `needsApproval` action, the CLI checks parsed/defaulted input with caller `cli`. An interactive terminal shows the action and arguments and asks `y/N` (only `y` or `yes` runs). Without both stdin and stdout TTYs, ask the person to approve the specific call, then rerun the exact original `pnpm action` arguments with `--approved` appended. Never add `--approved` before the person approves. This reserved flag is removed before action input parsing and is harmless on ungated actions. See `references/action-fields.md` for approval details.
## Custom `/api/` Routes
View on GitHub