---
title: "Defining Actions"
description: "The defineAction() anatomy: schema, run, and HTTP config, plus how to keep the action surface small."
---

# Defining Actions

Every action starts with a call to `defineAction()`. It bundles a description for the agent, a schema that validates input, and a `run` function that does the work, so the UI and the agent call the exact same code.

<AnnotatedCode
  id="doc-block-actions-defining-1"
  title="Anatomy of an action"
  filename="actions/reply-to-email.ts"
  language="ts"
  code={
    'import { defineAction } from "@agent-native/core/action";\nimport { z } from "zod";\n\nimport { getDb, schema } from "../server/db/index.js";\n\nexport default defineAction({\n  description: "Reply to an email thread in the user\'s voice.",\n  schema: z.object({\n    emailId: z.string().describe("The id of the email to reply to."),\n    body: z.string().describe("The reply body, in markdown."),\n  }),\n  run: async ({ emailId, body }) => {\n    const db = getDb();\n    await db.insert(schema.replies).values({ emailId, body });\n    return { ok: true, emailId };\n  },\n});'
  }
  annotations={[
    {
      lines: "7",
      label: "Tool surface",
      note: "`description` is what the agent reads to decide when to call this. The per-field `.describe()` calls flow into the JSON Schema too.",
    },
    {
      lines: "8-11",
      label: "Typed contract",
      note: "One schema validates input from **every** surface and is converted to JSON Schema for the model. Invalid inputs never reach `run`.",
    },
    {
      lines: "12-16",
      label: "One implementation",
      note: "The `run` body is the single source of truth. The UI button and the agent tool both execute exactly this. `getDb()` and `schema` come from your app's `server/db/index.ts`. See [Database](/docs/server-database).",
    },
  ]}
/>

The framework auto-discovers every file in `actions/` and mounts it on startup. There's nothing else to register by hand.

<Signature
  id="doc-block-actions-defining-2"
  name="defineAction"
  params={[
    {
      name: "config",
      type: "DefineActionConfig<TSchema, TReturn, TOutputSchema>",
      description:
        "Everything below is one config object. Only description, schema, and run are required.",
      fields: [
        {
          name: "description",
          type: "string",
          description:
            "What the agent reads to decide when to call this action.",
        },
        {
          name: "schema",
          type: "StandardSchemaV1",
          description:
            "Validates input on every surface. See Schema options below.",
        },
        {
          name: "run",
          type: "(args, ctx?) => Promise<TReturn> | TReturn",
          description:
            "The single implementation every surface calls. See Run Context for the ctx argument.",
        },
        {
          name: "http",
          type: "ActionHttpConfig | false",
          optional: true,
          description:
            "Overrides the mounted method/path, or disables the HTTP endpoint entirely. See HTTP config below.",
        },
        {
          name: "readOnly",
          type: "boolean",
          optional: true,
          default: "false",
          description:
            "Auto-true when http.method is GET. Skips the UI poll-refresh and write-auditing. Not access control.",
        },
        {
          name: "outputSchema",
          type: "StandardSchemaV1",
          optional: true,
          description:
            "Validates what run() returns. See Validating the return value below.",
        },
        {
          name: "outputErrorStrategy",
          type: '"strict" | "warn" | "fallback"',
          optional: true,
          default: '"strict"',
          description: "What happens on an output mismatch.",
        },
        {
          name: "outputFallback",
          type: "TReturn",
          optional: true,
          description:
            'Deprecated. Returned in place of an invalid result when outputErrorStrategy is "fallback".',
        },
        {
          name: "errors",
          type: "ActionErrors",
          optional: true,
          description:
            "The action's closed set of error codes, from defineActionErrors. See Declaring error codes below.",
        },
        {
          name: "mcpOutputSchema",
          type: "boolean",
          optional: true,
          default: "false",
          description:
            'Advertises outputSchema to MCP clients and validates each result against it. Requires outputErrorStrategy "strict".',
        },
        {
          name: "parallelSafe",
          type: "boolean",
          optional: true,
          description:
            "Allows a mutating action to run concurrently with other same-turn tool calls.",
        },
        {
          name: "agentTool",
          type: "boolean",
          optional: true,
          default: "true",
          description:
            "Set false to hide from every agent tool list. See Access & Authorization.",
        },
        {
          name: "mcpTool",
          type: "boolean",
          optional: true,
          default: "agentTool",
          description:
            "Defaults to agentTool. Set false to hide from external agents over MCP and A2A, or true to declare curated catalog membership (with agentTool: false, MCP-only). See Access & Authorization.",
        },
        {
          name: "endsTurn",
          type: "boolean",
          optional: true,
          default: "false",
          description:
            "Set true when a successful call hands control to the user (question form, intake dialog); make it the first or only call in the turn. Hidden from MCP, WebMCP, and A2A unless mcpTool: true — an external caller cannot receive an in-app answer. See External Agents.",
        },
        {
          name: "link",
          type: "(result) => { url, label }",
          optional: true,
          description:
            "Pure, synchronous deep-link builder read by MCP hosts and the desktop app. See Other Surfaces.",
        },
        {
          name: "mcpApp",
          type: "object",
          optional: true,
          description:
            "Embed the real app route inline in MCP-App hosts. See Other Surfaces.",
        },
        {
          name: "deferLoading",
          type: "boolean",
          optional: true,
          description:
            "Set false to keep the action in the agent's first tool list, true to load it on demand through tool-search. See Keep the action surface small below.",
        },
        {
          name: "toolCallable",
          type: "boolean",
          optional: true,
          default: "true",
          description:
            "Set false to block the sandboxed extension iframe bridge. See Access & Authorization.",
        },
        {
          name: "publicAgent",
          type: "PublicAgentActionConfig",
          optional: true,
          description:
            "{ expose: true } adds the action to public MCP/A2A/OpenAPI surfaces. See Access & Authorization.",
        },
        {
          name: "needsApproval",
          type: "boolean | ((args, ctx?) => boolean | Promise<boolean>)",
          optional: true,
          default: "false",
          description:
            "Pauses the agent loop for human approval before run(). See Human-in-the-Loop Approvals.",
        },
        {
          name: "authorize",
          type: "ActionAuthorize<TInput>",
          optional: true,
          description:
            "Guard function deciding whether this caller may run the action at all. See Access & Authorization.",
        },
        {
          name: "audit",
          type: "ActionAuditConfig",
          optional: true,
          description:
            "Tunes the automatic audit capture every mutating action already gets. See Audit Log.",
        },
        {
          name: "chatUI",
          type: "ActionChatUIConfig",
          optional: true,
          description:
            "Declares a native chat UI renderer for the action's result. See Other Surfaces.",
        },
      ],
    },
  ]}
  returns={{
    type: "ActionDefinition<TInput, TReturn>",
    description:
      "The typed action definition. Export it as the file's default export.",
  }}
/>

## Schema options {#schemas}

`schema` accepts any [Standard Schema](https://standardschema.dev)-compatible library:

- **Zod** (v4): the most common choice, with the best type inference and automatic conversion to JSON Schema.
- **Valibot**: a good pick when bundle size matters.
- **ArkType**: a good pick if you prefer its syntax.

The schema does two jobs. It converts to JSON Schema for the Claude API tool definition, and it validates input at runtime before `run()` fires. Invalid input never reaches your handler.

Validated arguments must be plain data: objects, arrays, strings, numbers, booleans, `null`, `undefined`, BigInts, and valid Dates. The framework copies them once and hashes that copy, which approvals and replays bind to. Each check that reads the arguments, and `run()` itself, gets its own copy, so a handler can change its `args` safely. If the schema's output holds anything else (a getter or setter, a `Map` or `Set`, a class instance such as a `URL` or `Buffer`, or a function), every call fails as invalid before approval or `run()`, with an error that names the argument. Transforms in your schema, or in a third-party schema you use, must return plain data; build richer values inside `run()`. These rules cover calls dispatched by the agent loop, plan mode, HTTP action routes, and MCP; a direct `action.run()`, such as one action calling another or the dev action bridge, gets schema validation only.

State enum values and defaults in `.describe()`, keep the primary id name identical across a tool family (`id`, not a mix of `id`/`deckId`), and make the one-line description enough to call the tool from a compact catalog — the `actions` skill shipped in every app carries the checklist.

## Validating the return value {#output-schema}

`schema` validates _inputs_. To also validate what an action **returns**, pass an `outputSchema` (any Standard Schema-compatible schema: Zod, Valibot, ArkType, the same surface as `schema`). The framework validates input before `run()` and validates the result after `run()` resolves.

```ts
export default defineAction({
  description: "Summarize a thread.",
  schema: z.object({ threadId: z.string() }),
  outputSchema: z.object({
    summary: z.string(),
    messageCount: z.number(),
  }),
  run: async ({ threadId }) => {
    /* ...returns { summary, messageCount } ... */
  },
});
```

The action's return type comes from `outputSchema`, and `run` must return a value the schema accepts, so a result that drifts from the schema is a compile error before it is a runtime one.

`outputErrorStrategy` controls what happens on a mismatch:

| Strategy     | Behavior on mismatch                                                                                               |
| ------------ | ------------------------------------------------------------------------------------------------------------------ |
| `"strict"`   | **Default.** Throw `ActionOutputContractError`, so a bug in the action fails loudly.                               |
| `"warn"`     | `console.warn` the issues and return the **original** result unchanged. Use it while you fix a schema.             |
| `"fallback"` | **Deprecated.** Return `outputFallback` in place of the invalid result, which callers cannot tell from a real one. |

When `run()` succeeds, the framework returns the validated value, so any defaults or coercions on `outputSchema` take effect, the same way they do for input. When you don't pass an `outputSchema`, the return value is unchanged.

The strict check runs after `run()`, so the error (`errorCode: "output_contract_violation"`) reports what already happened: `effect` is `"committed"` once a write entered its handler, in the call or in an action it called (even from a read-only action), and callers must not retry it; otherwise it is `"none"`. The message lists only declared field paths and issue codes, never the returned values.

### Declaring error codes {#error-codes}

`defineActionErrors` declares the closed set of `errorCode`s an action fails with. Its `fail` and `noWrite` accept only those codes, so a misspelled code does not compile, and passing it as `errors` publishes the codes as `errorCodes` on the definition.

```ts
const errors = defineActionErrors("thread_not_found", "thread_locked");

export default defineAction({
  description: "Archive a thread.",
  schema: z.object({ threadId: z.string() }),
  outputSchema: z.object({ archived: z.literal(true) }),
  errors,
  run: async ({ threadId }) => {
    const thread = await findThread(threadId);
    if (!thread) {
      errors.noWrite("Thread not found.", "thread_not_found", {
        statusCode: 404,
      });
    }
    /* ... */
    return { archived: true };
  },
});
```

### Advertising the contract over MCP {#mcp-output-schema}

MCP clients see no `outputSchema` by default, because a `"warn"` schema is not enforced. Once you have checked that every real result matches, set `mcpOutputSchema: true` together with `outputErrorStrategy: "strict"`. The tool then advertises the schema in the shape MCP clients receive — an array root is wrapped as `{ items }`, and actions with a `link` gain `openLink` and `url` — and validates each `structuredContent` against it before returning. The root must be an object, an object-only union, or an array; schemas with transforms cannot opt in. The advertised schema also allows for what MCP redacts: embed ticket fields are dropped, and a string holding an embed start URL arrives as `"[hidden embed URL]"`, so required fields stay required. A schema cannot opt in if it uses a keyword or `format` the check cannot enforce, such as `not`, `$async`, or an unknown format; if redaction could break one of its `const`, `enum`, or `uniqueItems` constraints; or if the action has a `link` and the schema's root limits its property names or count.

## Result contracts {#result-contracts}

A create or update result is read by more than its caller. Return `id` (so `link` can build a deep link into the editing view), a one-line `message`, and — when the operation is one step of a flow the agent should keep driving — `nextRequiredAction` naming the next tool. Never phrase it as waiting for the user: MCP/WebMCP/A2A callers cannot receive an in-app answer. The agent loop keeps `nextRequiredAction` in continuation prompts even when a large result is truncated, and MCP callers see it as `Next: …`.

## HTTP config {#http}

By default every action is exposed as `POST /_agent-native/actions/<name>`. Override with the `http` option:

```ts
export default defineAction({
  description: "Get details for a lead.",
  schema: z.object({ leadId: z.string() }),
  http: { method: "GET" },
  run: async ({ leadId }) => {
    return await db.select().from(leads).where(eq(leads.id, leadId));
  },
});
```

For a `GET` action, `leadId` is passed as a query param: `/_agent-native/actions/get-lead?leadId=abc`.

<Endpoint id="doc-block-actions-defining-3" title="The auto-mounted action endpoint" method="GET" path="/_agent-native/actions/get-lead" summary={"Every action is mounted here automatically. The filename is the action name."} auth={"Session cookie (frontend calls carry `X-Agent-Native-Frontend: 1`), or `Authorization: Bearer <token>` for external callers"} params={[
  {
    "name": "leadId",
    "in": "query",
    "type": "string",
    "required": true,
    "description": "GET args arrive as query params. POST args arrive in the JSON body."
  }
]} responses={[
  {
    "status": "200",
    "description": "The action's return value as JSON."
  },
  {
    "status": "400",
    "description": "Input failed schema validation before run() fired."
  }
]}>

POST is the default, and `http: { method: "GET" }` makes it a GET. The React hooks and `callAction()` always call this path by name, regardless of any `http.path` override. External systems can call the same route with a long-lived bearer token. See [HTTP API](/docs/http-api).

</Endpoint>

- **`http: { method: "GET" | "POST" | "PUT" | "DELETE" }`**: defaults to `POST`. `GET` actions are auto-marked `readOnly`, so a successful call doesn't trigger a UI poll-refresh.
- **`http: { path: "..." }`**: overrides the mounted URL under `/_agent-native/actions/`. It defaults to the filename. This override only changes the URL for direct HTTP callers. `useActionQuery()`, `useActionMutation()`, and `callAction()` always call `/_agent-native/actions/<name>`, regardless of this override, so overriding the path makes those hooks 404. Use a path override only for external HTTP callers. Also note that `:param` route segments in the override path are not parsed into `run()` args. Only query-string params and JSON body fields are.
- **`http: false`**: disables the HTTP endpoint entirely. Only the agent and CLI can call the action.
- **`readOnly: true`**: explicitly skips the poll-refresh, even for POST actions that don't mutate anything. This is not access control. It doesn't restrict who may call the action, and it doesn't affect `useActionQuery()`, `useActionMutation()`, or any client hook. Its only effects are suppressing the post-call refresh event and opting the action out of write-auditing. A write action it calls still counts as a write: once that write runs, the call refreshes, and a failed call reports `effect: "committed"`. To hide an action from the model, use `agentTool: false`. To gate who can call it, use the auth and access options on [Access & Authorization](/docs/actions-access-control).
- **`parallelSafe: true`**: allows a mutating action to run at the same time as other tool calls in the same turn. Only set this when the action is safe to run concurrently and doesn't depend on order. Mutating actions serialize by default.

## Failing an action {#failing}

An action reports failure by throwing. Returning `{ error: "..." }` instead looks like success to every part of the framework, so the agent's retry breakers never count it and the call is recorded as a completed side effect.

What you throw decides how much of the message survives. `fail()` raises an `ActionContractError`, and that type is the only reason the text reaches the caller. A bare `throw new Error("No such email")` is indistinguishable from a Postgres or upstream blowup, so the action route replaces it with a generic 500 `"Internal server error"`, keeps the real message in the server log, and reports it to error tracking. Keep the bare throw for genuine internal faults. That is what the 500 is for.

`fail()` accepts a message and an options object:

- **message**: the text the caller reads. Write it for a person, not for a log.
- **options.errorCode**: a stable machine-readable code. Defaults to `"action_failed"`.
- **options.statusCode**: the HTTP status for the action route. Defaults to `400`.
- **options.details**: safe structured context for the caller, such as the id a retry needs. The browser and agents both receive it. Never put secrets or raw driver output here.

The simplest call needs only a message:

```ts
import { defineAction, fail } from "@agent-native/core/action";
import { eq } from "drizzle-orm";
import { z } from "zod";

import { getDb, schema } from "../server/db/index.js";

export default defineAction({
  description: "Reply to an email thread in the user's voice.",
  schema: z.object({
    emailId: z.string().describe("The id of the email to reply to."),
    body: z.string().describe("The reply body, in markdown."),
  }),
  run: async ({ emailId, body }) => {
    const db = getDb();
    const [email] = await db
      .select()
      .from(schema.emails)
      .where(eq(schema.emails.id, emailId));
    if (!email) fail("That email is no longer in the mailbox.");
    await db.insert(schema.replies).values({ emailId, body });
    return { ok: true, emailId };
  },
});
```

Naming the cause is what lets the UI branch on a failure instead of only printing it. The code and the status both survive the transport:

```ts
if (!email) {
  fail("That email is no longer in the mailbox.", {
    errorCode: "email_not_found",
    statusCode: 404,
  });
}
```

The browser receives `{ error, errorCode, details }`. `useActionQuery()`, `useActionMutation()`, and `callAction()` all surface it as an `Error` carrying `status`, `errorCode`, `details`, and `actionMessage`.

`error.message` keeps an `Action <name> failed:` prefix, which belongs in a console and reads wrong in a toast. `actionErrorMessage()` returns just the text the action wrote, or `undefined` when nothing authored one:

```tsx
import {
  actionErrorMessage,
  useActionMutation,
} from "@agent-native/core/client/hooks";

const reply = useActionMutation("reply-to-email", {
  onError: (error) =>
    toast.error(actionErrorMessage(error) ?? t("email.replyFailed")),
});
```

A network drop, a proxy's HTML error page, and a bare status line author no message, so they return `undefined` rather than a string a UI would show by mistake. Always pair it with your own copy.

### What each throw produces {#failure-shapes}

| Thrown from `run()`                                                | HTTP status       | Response body                                  | Reported to error tracking |
| ------------------------------------------------------------------ | ----------------- | ---------------------------------------------- | -------------------------- |
| `fail(msg)`                                                        | 400               | `{ error: msg, errorCode: "action_failed" }`   | No                         |
| `fail(msg, { errorCode, statusCode, details })`                    | your `statusCode` | `{ error, errorCode, details }`                | No                         |
| `new ActionContractError(msg, { errorCode })`                      | 409               | `{ error, errorCode, details }`                | No                         |
| `new AgentActionStopError(msg)`                                    | 500               | `{ error: msg }`                               | No                         |
| A PostgreSQL deadlock (`40P01`) or serialization failure (`40001`) | 409               | `{ error, errorCode: "transaction_conflict" }` | No                         |
| `new Error(msg)`                                                   | 500               | `{ error: "Internal server error" }`           | Yes                        |
| Input that fails `schema`                                          | 400               | `{ error: "Invalid action parameters..." }`    | No                         |

`AgentActionStopError` is the one to throw when the agent should end its turn rather than read the failure and try again. `ActionContractError` is what `fail()` raises for you, so reach for it directly only when 409 is the status you want.

### Pick a status the retry policy can act on {#failure-status}

`useActionQuery()` retries `429`, `502`, `503`, and `504`, up to three times, backing off 500ms then 1s then 2s. Every other status is treated as deterministic and surfaces on the first response. Resending a request that was refused for being wrong only buys the same refusal again. `useActionMutation()` does not retry at all.

So the status is not cosmetic. A refusal sent as `404` costs one round trip. The same refusal sent as a `500`, or thrown bare, costs four round trips and four duplicate error reports.

Agent callers never see the status. The agent runs `run()` in process, so it receives the message directly whatever the type, as `Error running <action>: <message>`. An `errorCode` other than the default `action_failed` is appended as `(errorCode: not_found)` so the model can branch on it without parsing prose. `details` follow on their own line as compact JSON, such as `Error details: {"importId":"imp_1"}`, so an id the model needs for its next call doesn't have to be written into the message. External agents over MCP get the same text, and the same details in `structuredContent.error`. A tool that advertises an output schema (`mcpOutputSchema: true`) returns the text only, since a client may check an error result against that schema too. Details are capped at 2,000 characters: past that, small top-level entries are kept and the rest are listed as omitted. Secrets in the message and details are redacted first, and the agent's own repeated-error breakers bound how many times it tries again.

## Keep the action surface small {#small-surface}

Every action the agent can see takes up a slot in the model's tool list, and a long, overlapping list makes it harder for the model to pick the right tool. Design the action surface like an API you maintain, not one action per button in the UI:

- Prefer **one CRUD-style `update` action** that takes a patch of optional fields, over many per-field actions like `update-name`, `update-order`, and `update-color`. The caller only sends the fields that changed.
- Before adding a new read action for every query or filter, consider a more general option first: the [provider API trio](/docs/template-dispatch-developers#calling-a-provider-directly) (`provider-api-catalog`, `provider-api-docs`, and `provider-api-request`) for provider data, or the dev `db-query` tool for app data.
- Mark UI-only or programmatic actions [`agentTool: false`](/docs/actions-access-control#agent-tool). They stay callable from the frontend or over HTTP, without taking a slot in the model's tool list.
- Mark the handful of actions the agent reaches for constantly `deferLoading: false`. Those are the schemas sent on the first model request; everything else loads on demand through `tool-search` when the agent needs it. As soon as one action opts out this way, every unmarked action is deferred, so mark the whole starter set at once rather than one action at a time. This is about what the first turn costs, not about access: a deferred action is callable the moment `tool-search` returns it. Deferred actions still appear by name in the prompt's tool index, grouped by the noun after the leading verb, so `verb-noun` names such as `list-dashboards` and `create-dashboard` are what let the agent find them. It is the per-action form of the plugin's `initialToolNames` array, and an app can use either.
- Delete or hide actions the UI no longer uses, instead of leaving them exposed to the model.

The repo includes an advisory helper, `node scripts/audit-template-actions.mjs [template ...]` (alias `pnpm actions:audit`). It scans a template's `actions/` folder and flags actions the UI may no longer use, along with groups of per-field actions that could be combined into one. It always exits with code 0 and never fails CI, and its heuristics are conservative, so treat its output as a suggestion, not an error.

## What's next

- [**Overview**](/docs/actions-overview) — what an action is and the hello-world walkthrough
- [**Access & Authorization**](/docs/actions-access-control) — `agentTool`, `toolCallable`, `authorize`, and `ctx` scoping
- [**Run Context**](/docs/actions-run-context) — the full `ctx` field table
- [**Other Surfaces**](/docs/actions-other-surfaces) — native chat UI, CLI, A2A, MCP, and standard actions
- [**Database**](/docs/server-database) — `getDb()`/`schema` used inside `run()`
- [**Data & Sync**](/docs/client-data) — calling this action from the UI
