---
title: "Actions"
description: "defineAction — the single definition that becomes an agent tool, typed frontend hooks, framework transport, an MCP tool, and a CLI command."
---

# Actions

Actions are the single source of truth for anything your app does. Define an action once with `defineAction()`, drop it in `actions/`, and it's immediately available as:

- **An agent tool** — the agent sees it with a zod-derived JSON Schema and can call it in chat.
- **Typesafe React hooks** — `useActionQuery("name")` and `useActionMutation("name")` on the frontend, types inferred from the schema.
- **Imperative client calls** — `callAction("name", params)` when a hook does not fit.
- **Framework transport** — auto-mounted by the framework behind those hooks and available to external HTTP clients.
- **An MCP tool** — exposed to Claude, ChatGPT custom MCP apps, Claude Desktop/Code, Cursor, Codex, and any other MCP host.
- **An A2A tool** — called by other agent-native apps over A2A.
- **A CLI command** — `pnpm action <name>` for scripting and dev loops.

One definition, seven consumers. This is rung 3 of the [ladder](/docs/what-is-agent-native#the-ladder).
In the default getting-started flow, the action first appears as an agent tool in
chat, then as a native inline result, then as the operation behind a durable app
page. For the full surface map, see [Agent Surfaces](/docs/agent-surfaces).

<Diagram id="doc-block-xcp5x6" title="One definition, seven consumers" summary={"A single defineAction() fans out to every surface — agent, UI, HTTP, MCP, A2A, and CLI — with one validated schema and one run() body."}>

```html
<div class="diagram-fanout">
  <div class="diagram-panel center" data-rough>
    <span class="diagram-pill accent">defineAction()</span
    ><small class="diagram-muted">schema + run(), defined once</small>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&rarr;</div>
  <div class="diagram-grid">
    <div class="diagram-node">
      Agent tool<br /><small class="diagram-muted"
        >JSON Schema in context</small
      >
    </div>
    <div class="diagram-node">
      React hooks<br /><small class="diagram-muted"
        >useActionQuery/Mutation</small
      >
    </div>
    <div class="diagram-node">
      callAction()<br /><small class="diagram-muted">imperative client</small>
    </div>
    <div class="diagram-node">
      HTTP<br /><small class="diagram-muted"
        >/_agent-native/actions/:name</small
      >
    </div>
    <div class="diagram-node">
      MCP tool<br /><small class="diagram-muted">external hosts</small>
    </div>
    <div class="diagram-node">
      A2A tool<br /><small class="diagram-muted">other agent-native apps</small>
    </div>
    <div class="diagram-node">
      CLI<br /><small class="diagram-muted">pnpm action &lt;name&gt;</small>
    </div>
  </div>
</div>
```

```css
.diagram-fanout {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
}
.diagram-fanout .center {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 14px 16px;
}
.diagram-fanout .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
.diagram-fanout .diagram-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
}
```

</Diagram>

If the UI and agent both need to do something, reach for an action — not a custom
route. For when a route-shaped protocol _is_ the right call, see [Prefer Actions
For App Operations](/docs/server#actions-first).

On this page: [Start with one action](#hello-action), [Defining an action](#defining),
[Run context](#run-context), [Access control](#access-control),
[Human-in-the-loop approval](#needs-approval), [Audit logging](#audit),
[Calling from the UI](#ui), [Native chat UI](#native-chat-ui),
[Calling from the CLI](#cli), [A2A](#a2a), [MCP](#mcp),
[Standard actions](#standard-actions), and [Feature flags](#feature-flags).

## Start with one action {#hello-action}

The first operation in a chat-first app can be tiny. In the Chat template,
replace `actions/hello.ts` or add a new file beside it:

```ts
// actions/hello.ts
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";

export default defineAction({
  description: "Say hello from the local agent.",
  schema: z.object({
    name: z.string().default("world"),
  }),
  http: { method: "GET" },
  readOnly: true,
  run: async ({ name }) => {
    return { message: `Hello, ${name}!` };
  },
});
```

Run it from the same folder:

```bash
pnpm action hello '{"name":"Steve"}'
```

The CLI accepts a JSON object as the action input, which matches the structured
tool calls agents already make. Simple flags still work for quick manual runs:

```bash
pnpm action hello --name Steve
```

Then run the app-agent loop against the folder:

```bash
npx agent-native agent "Call hello for Steve and explain the result"
```

That is the same app-agent loop your scheduled jobs, chat UI, external MCP
tools, and future screens will use. Chat and domain templates are for adding UI
around actions, not a required prerequisite for the action itself.

## Defining an action {#defining}

<AnnotatedCode
  id="doc-block-15q9y0r"
  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/database).",
    },
  ]}
/>

That's it. The framework auto-discovers every file in `actions/` and mounts them on startup.

### Schema options {#schemas}

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

- **Zod** (v4) — most common, best type inference, auto-converts to JSON Schema.
- **Valibot** — minimal bundle size if that matters.
- **ArkType** — if you like the syntax.

The schema is converted to JSON Schema for the Claude API tool definition, _and_ used at runtime to validate inputs before `run()` fires. Invalid inputs never reach your handler.

### 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, same surface as `schema`). The framework validates the result _after_ `run()` resolves, composing with input validation: input validated before `run`, output validated after.

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

`outputErrorStrategy` controls what happens on a mismatch:

| Strategy     | Behavior on mismatch                                                                               |
| ------------ | -------------------------------------------------------------------------------------------------- |
| `"warn"`     | **Default.** `console.warn` the issues and return the **original** result unchanged. Non-breaking. |
| `"strict"`   | Throw a clear error so a buggy action surfaces loudly.                                             |
| `"fallback"` | Return the provided `outputFallback` value in place of the invalid result.                         |

On success, the **validated** value is returned, so any coercion or defaults defined on the `outputSchema` take effect (mirroring the input path). When no `outputSchema` is supplied, behavior is byte-for-byte unchanged — there is no wrapping. This is borrowed from Mastra/Flue structured-output and kept dependency-free on the action layer.

### 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-uhuudd" 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 by default; `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" }`** — default `POST`. `GET` actions are auto-marked `readOnly` so successful calls don't trigger a UI poll-refresh.
- **`http: { path: "..." }`** — override the mounted URL under `/_agent-native/actions/`. Defaults to the filename. **Path overrides change the URL only 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 path overrides only for external HTTP callers. Note also 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`** — disable the HTTP endpoint entirely. Agent + CLI only.
- **`readOnly: true`** — explicitly skip the poll-refresh even for POST actions that don't mutate.
- **`parallelSafe: true`** — allow a mutating action to run concurrently with other same-turn tool calls. Only set this when the action is internally concurrency-safe and order-independent; mutating actions serialize by default.

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

Every action the agent can see is a tool in the model's context window, and a long, overlapping tool list degrades the model's tool-selection quality. Design the action surface like an API you maintain, not one action per UI affordance:

- Prefer **one CRUD-style `update`** that takes a patch of optional fields over N per-field actions (`update-name`, `update-order`, `update-color`, …). The caller sends only what changed.
- Before adding a new read action per query/filter, reach for a generic escape hatch: the [provider API trio](/docs/template-dispatch-vault-integrations#provider-api) (`provider-api-catalog` / `provider-api-docs` / `provider-api-request`) for provider data, or the dev `db-query` tool for app data.
- Mark UI-only or programmatic actions [`agentTool: false`](#agent-tool) so they stay frontend/HTTP-callable without spending a slot in the model's tool list.
- Delete or hide actions the UI no longer uses instead of leaving them exposed to the model.

A repo-level advisory helper, `node scripts/audit-template-actions.mjs [template ...]` (alias `pnpm actions:audit`), statically scans a template's `actions/` and flags likely UI-dead actions and redundant per-field clusters. It is advisory only (always exits 0, never fails CI) and uses conservative heuristics, so review its suggestions rather than treating them as errors.

### Exposure flags {#exposure-flags}

Four flags control _who_ can invoke an action. All default to the permissive value, so you only set one to tighten a specific surface. This table is the glanceable summary; the subsections add the one detail each needs.

| Flag            | Default       | Restrictive value → who can still call                                      | Typical use                                                     |
| --------------- | ------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------- |
| `agentTool`     | `true`        | `false` → UI, HTTP, CLI only — **hidden from the model**, MCP, and A2A      | UI-only / programmatic actions that shouldn't spend a tool slot |
| `toolCallable`  | `true`        | `false` → everything **except** the sandboxed extension iframe bridge (403) | Auth-adjacent ops (delete account, change org membership/roles) |
| `publicAgent`   | off (private) | `{ expose: true }` → adds the action to **public** MCP/A2A/OpenAPI surfaces | Safe read/ingest tools reachable without authentication         |
| `needsApproval` | `false`       | `true` → the agent **pauses**; a human must approve the specific call       | Consequential side effects (send email, charge a card, delete)  |

These are independent: `agentTool` controls the model's view, `toolCallable` controls only the extension iframe, `publicAgent` adds an opt-in public surface (public web routes never imply public tool exposure), and `needsApproval` gates execution after the call is made — see [Human-in-the-loop approval](#needs-approval) below.

#### `agentTool` — hide from the model {#agent-tool}

By default every action is a callable agent tool. Set `agentTool: false` to keep it behind the framework's auth + action surface while removing it from every agent tool list — it stays callable from the UI (`useActionMutation` / `callAction`), CLI, and `/_agent-native/actions/<name>`:

```ts
export default defineAction({
  description: "Persist the user's sidebar width.",
  agentTool: false, // UI-only — not a tool in the model's context window
  schema: z.object({ widthPx: z.number() }),
  http: { method: "PUT" },
  run: async ({ widthPx }) => {
    /* ... */
  },
});
```

Reach for it when you add a UI-only or purely programmatic action, or when the UI stops using an action you'd otherwise leave exposed to the model.

#### `toolCallable` — block the extension iframe {#tool-callable}

Extensions ([Alpine.js mini-apps in sandboxed iframes](/docs/extensions)) call actions via `appAction(name, params)`, running with the _viewer's_ permissions, secrets, and SQL scope. For high-blast-radius operations that is too much trust by default. Set `toolCallable: false` to make the extension bridge return 403 while keeping the action callable from the UI, agent, CLI, MCP, and A2A:

```ts
export default defineAction({
  description: "Delete the current user's account.",
  toolCallable: false, // never callable from an extension iframe
  schema: z.object({ confirm: z.literal("yes") }),
  run: async () => {
    /* ... */
  },
});
```

Use it for actions that delete or transfer accounts/orgs, change auth state, modify org membership, or grant share access. The framework's built-in `share-resource`, `unshare-resource`, and `set-resource-visibility` are already opted out. Enforcement is by an unspoofable host-set header on iframe calls; regular UI/agent/CLI/MCP/A2A calls are unaffected — see [Security](/docs/security) for details.

### Run context (second argument) {#run-context}

`run` receives an optional second argument, `ctx`, carrying the resolved request identity and the surface that invoked the action. Read it instead of calling `getRequestUserEmail()` / `getRequestOrgId()` by hand, and pass the whole `ctx` to tracking:

```ts
export default defineAction({
  description: "Log an audit entry for the current request.",
  schema: z.object({ event: z.string() }),
  run: async (args, ctx) => {
    // ctx is undefined-safe: a 1-arg `run(args)` is still valid.
    const actor = ctx?.userEmail ?? "system";
    if (ctx?.caller === "frontend") {
      // tighter rules for browser-initiated calls, looser for "tool"/"cli"
    }
    await db.insert(audit).values({
      actor,
      orgId: ctx?.orgId ?? null,
      source: ctx?.caller ?? "unknown",
      event: args.event,
    });
    return { ok: true };
  },
});
```

`ActionRunContext` fields:

| Field                                           | Type                                                   | Notes                                                                                                                                                                                                                                               |
| ----------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userEmail`                                     | `string \| undefined`                                  | Resolved request user. **Never defaulted to a dev identity** — `undefined` when the request has no authenticated user. Apply your own fallback if you need one.                                                                                     |
| `orgId`                                         | `string \| null`                                       | Resolved org id, or `null` when the request has no org.                                                                                                                                                                                             |
| `caller`                                        | `ActionCaller`                                         | How the action was invoked (see below).                                                                                                                                                                                                             |
| `send`                                          | `(event) => void`                                      | Optional. Emit an SSE event to the client. Only present inside the agent tool loop (`caller: "tool"`); `undefined` elsewhere.                                                                                                                       |
| `attachments`                                   | `AgentChatAttachment[]`                                | Files, images, and pasted text blocks submitted with the current agent turn. Populated only when `caller: "tool"`; `undefined` on all other surfaces.                                                                                               |
| `signal`                                        | `AbortSignal`                                          | Aborts when the run is soft-timed-out, cancelled, or the server shuts down. Populated only when `caller: "tool"`. Long-running actions can observe it to cancel early instead of waiting for the per-tool 60s hard timeout.                         |
| `actionName`                                    | `string \| undefined`                                  | The registry key of the action being called (e.g. `delete-recording`), set at each dispatch site so cross-cutting concerns like the audit log can attribute the call. `undefined` for direct programmatic `run()` calls that bypass the dispatcher. |
| `threadId` / `runId` / `turnId`                 | `string \| undefined`                                  | The agent conversation thread, run attempt, and turn that triggered this call. Populated only when `caller: "tool"`, so the audit log can link a mutation back to the exact agent run.                                                              |
| `automation`                                    | `ActionAutomationContext \| undefined`                 | Present only when `caller: "automation"` — `{ triggerId, triggerName, policyId? }` for the event-triggered automation that dispatched this call.                                                                                                    |
| `networkProtocol` / `networkId` / `networkPeer` | `"a2a" \| "mcp" \| "provider-api"`, `string`, `string` | Verified lineage for a direct delegated call — the protocol, a correlation id (e.g. the A2A delegation JWT id), and the calling peer. Set only for those direct-dispatch paths.                                                                     |

`caller` is the union `"tool" | "http" | "frontend" | "cli" | "mcp" | "a2a" | "automation"`:

| `caller`       | Set when…                                                                                                                                                                                        |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `"tool"`       | The in-app agent loop, a sub-agent / agent team, or natural-language cross-app A2A delegation (which drives the same agent loop, so its tool calls are `"tool"`).                                |
| `"frontend"`   | A browser call via `useActionMutation` / `useActionQuery` / `callAction` (tagged with the `X-Agent-Native-Frontend: 1` header).                                                                  |
| `"http"`       | A bare programmatic `POST` / `GET` to `/_agent-native/actions/<name>` without the frontend marker.                                                                                               |
| `"cli"`        | `pnpm action <name>` (the CLI runner).                                                                                                                                                           |
| `"mcp"`        | An external agent over the MCP `tools/call` endpoint.                                                                                                                                            |
| `"a2a"`        | A direct HTTP call to `/_agent-native/actions/<name>` carrying a signed A2A delegation token — an explicitly exposed, cross-app dispatch distinct from the natural-language `"tool"` path above. |
| `"automation"` | An event-triggered automation dispatched from a stored workspace trigger. `ctx.automation` carries the trigger lineage. See [Automations](/docs/automations).                                    |

`run` stays backward compatible: existing 1-argument handlers and handlers that only destructure `{ send }` continue to work unchanged.

### Access control in actions {#access-control}

User-owned tables must scope reads through `accessFilter` and writes through `assertAccess` — the same helpers the framework's sharing system uses. Here is a complete, paste-ready example:

```ts
// actions/create-lead.ts
import { defineAction } from "@agent-native/core/action";
import { z } from "zod";
import { getDb } from "../server/db/index.js";
import * as schema from "../server/db/schema.js";

export default defineAction({
  description: "Create a lead in the CRM.",
  schema: z.object({ name: z.string(), company: z.string() }),
  run: async ({ name, company }, ctx) => {
    const db = getDb();
    await db.insert(schema.leads).values({
      id: crypto.randomUUID(),
      name,
      company,
      ownerEmail: ctx?.userEmail ?? "system",
    });
    return { ok: true };
  },
});
```

For list and read actions, use `accessFilter` to scope the query to the current user and org. For actions that update or delete a specific row, use `assertAccess` to confirm the caller is allowed before writing. See [Security](/docs/security#access-guards) and [Sharing](/docs/sharing) for the full helper API.

### Human-in-the-loop approval {#needs-approval}

A handful of actions are too consequential to let the agent run autonomously — sending an email, charging a card, deleting an account. Set `needsApproval: true` (or a predicate `(args, ctx) => boolean | Promise<boolean>`) on `defineAction` and the loop pauses before `run()`, requiring a human to approve that specific call. The default is **off**, and almost every action should stay that way.

See [Human-in-the-Loop Approvals](/docs/human-approval) for the predicate API, the `approval_required` event, and the full end-to-end flow.

### Audit logging {#audit}

Every mutating action is **audited automatically** — the framework records who ran it, when, from which surface, and (when it was the agent) which thread/turn, with credential-redacted inputs. Read-only (`GET`) actions are skipped. You don't write any code for this; it happens at the `defineAction` seam.

Add an `audit` block only to _tune_ capture — most usefully to declare the resource the action changed so the change shows up in that resource's owner's trail:

```ts
export default defineAction({
  description: "Delete a recording.",
  schema: z.object({ id: z.string() }),
  audit: {
    target: (args, result) => ({ type: "recording", id: args.id }),
    summary: (args) => `Deleted recording ${args.id}`,
  },
  run: async (args, ctx) => {
    /* ...delete... */
  },
});
```

Other knobs: `audit: { onRead: true }` audits a sensitive read (secret access, bulk export); `audit: { enabled: false }` opts a noisy write out; `audit: { recordInputs: false }` skips capturing arguments. Read the trail back with the built-in `list-audit-events` / `get-audit-event` actions. Full details in [Audit Log](/docs/audit-log).

## Calling it from the UI {#ui}

Two hooks, both in `@agent-native/core/client`. Types are inferred from your `defineAction` schemas — no manual type declarations.

### `useActionMutation` {#use-action-mutation}

For actions that change state:

```tsx
import { useActionMutation } from "@agent-native/core/client/hooks";
const { mutate, isPending } = useActionMutation("reply-to-email");

<Button
  disabled={isPending}
  onClick={() => mutate({ emailId, body: "Thanks!" })}
>
  Send Reply
</Button>;
```

On success, the framework emits a change event with `source: "action"` so `useActionQuery` consumers and active query observers refetch automatically. See [Live Sync](/docs/key-concepts#polling-sync).

### `useActionQuery` {#use-action-query}

For read-only GET actions:

```ts
import { useActionQuery } from "@agent-native/core/client/hooks";
const { data, isLoading } = useActionQuery("get-lead", { leadId });
```

The query is cached under `["action", "get-lead", { leadId }]` and auto-invalidated on any mutating action that completes.

## Rendering native chat UI {#native-chat-ui}

Actions can return structured widget data that the in-app chat renders
natively. This is the first-party chat path for reusable tables, charts, setup
summaries, and insight cards. Use [Generative UI](/docs/generative-ui) when the
agent needs to generate arbitrary interactive controls inside the in-app chat,
and use [MCP Apps](/docs/mcp-apps) for inline UI in external MCP hosts.

```ts
import { defineAction } from "@agent-native/core/action";
import { ACTION_CHAT_UI_DATA_CHART_RENDERER } from "@agent-native/core/action-ui";
import {
  createDataChartWidgetResult,
  dataChartWidgetResultSchema,
} from "@agent-native/core/data-widgets";

export default defineAction({
  description: "Summarize response trends.",
  readOnly: true,
  outputSchema: dataChartWidgetResultSchema,
  chatUI: { renderer: ACTION_CHAT_UI_DATA_CHART_RENDERER },
  run: async () =>
    createDataChartWidgetResult({
      title: "Response trends",
      chartSeries: {
        type: "line",
        xKey: "day",
        series: [{ key: "responses", label: "Responses" }],
        data: [
          { day: "Mon", responses: 12 },
          { day: "Tue", responses: 18 },
        ],
      },
    }),
});
```

The built-in discriminants are `"data-table"`, `"data-chart"`, and
`"data-insights"`, with server-safe builders and schemas in
`@agent-native/core/data-widgets`. Use `data-chart` for chart-only answers,
`data-table` for SQL/list results, and `data-insights` only when a combined
summary/chart/table card is the right output. See
[Native Chat UI](/docs/native-chat-ui) for the full result contract and BYO
runtime guidance, or
[Agent Surfaces](/docs/agent-surfaces) for how the same action can stay
automation-friendly, render in chat, or grow into a full screen.

## Calling it from the CLI {#cli}

Every action is runnable via `pnpm action`:

```bash
pnpm action reply-to-email '{"emailId":"thread-123","body":"Thanks!"}'
```

JSON input is the preferred shape for agents and complex objects. Flags are
still parsed into the same schema shape for simple manual runs and existing
scripts. Useful for agent-dev loops, scripts, and cron.

## Calling it from another agent (A2A) {#a2a}

If your app is an [A2A](/docs/a2a-protocol) peer, other agent-native apps discover your actions automatically and can call them by name. Same-origin deploys skip JWT signing; cross-origin uses a shared `A2A_SECRET`.

## Exposing it over MCP {#mcp}

With MCP enabled, your actions show up in the framework's MCP server at `/mcp`. Every caller gets a compact catalog by default — app-facing builtins plus the template-declared app actions — and `tool-search` is always present so any other tool stays reachable on demand. The full action surface is served only on explicit opt-in (`--full-catalog` token or `AGENT_NATIVE_MCP_FULL_CATALOG=1`), and `publicAgent.expose` opts a safe read/ingest tool onto the public surface. See [MCP Protocol](/docs/mcp-protocol) for catalog tiers, auth, and the `mcpApp` resource details.

For UI-capable MCP hosts, an action can declare an optional MCP Apps resource via the `mcpApp` field (plus a matching `link`) so capable hosts render the result inline. When `link` and `mcpApp` should point at the same route, `embedRoute()` builds both from one pure path builder:

```ts
import { embedRoute } from "@agent-native/core";

export default defineAction({
  description: "Create an email draft for review.",
  schema: z.object({ body: z.string() }),
  run: async ({ body }) => ({ body }),
  ...embedRoute({
    title: "Review draft",
    openLabel: "Open in Mail",
    path: ({ result }) => ({
      label: "Open draft in Mail",
      url: "/_agent-native/open?app=mail&view=inbox",
    }),
  }),
});
```

Keep `link` as the fallback for CLI and non-UI MCP hosts; it is also the embed's launch target. The embed bridge — the signed embed-start session, transplant vs. controlled-frame rendering, the `ui/*` host bridge, CSP, and height clamping — is owned by [External Agents](/docs/external-agents#mcp-app-bridge).

## Standard actions {#standard-actions}

Every template should include these two for [context awareness](/docs/context-awareness):

### view-screen {#view-screen}

Reads the current navigation state, fetches contextual data, and returns a snapshot of what the user sees. The agent calls this when it needs a fresh look at the screen.

```ts
// actions/view-screen.ts
import { defineAction } from "@agent-native/core/action";
import { readAppState } from "@agent-native/core/application-state";
import { z } from "zod";

export default defineAction({
  description: "Read the current screen state for context.",
  schema: z.object({}),
  http: { method: "GET" },
  run: async () => {
    const navigation = await readAppState("navigation");
    const screen: Record<string, unknown> = { navigation };

    if (navigation?.view === "inbox") {
      screen.emailList = await listEmailsForLabel(navigation.label);
    }

    return screen;
  },
});
```

### navigate {#navigate}

Writes a one-shot navigation command to application state. The UI reads it, navigates, and deletes the entry.

```ts
// actions/navigate.ts
import { defineAction } from "@agent-native/core/action";
import { writeAppState } from "@agent-native/core/application-state";
import { z } from "zod";

export default defineAction({
  description: "Navigate the user to a view.",
  schema: z.object({
    view: z.string(),
    threadId: z.string().optional(),
  }),
  run: async (args) => {
    await writeAppState("navigate", args);
    return { ok: true };
  },
});
```

## Guarding staged features {#feature-flags}

Use Core feature flags when an action is safe to deploy but should reach
production gradually. Declare the default-off flag once, register it from a
Nitro plugin, and evaluate it inside every guarded action. Hiding the matching
button is presentation; the action check is enforcement.

```ts
// shared/feature-flags.ts
import { defineFeatureFlag } from "@agent-native/core/feature-flags";

export const FULL_APP_BUILDING = defineFeatureFlag({
  key: "full-app-building",
  displayName: "Full app building",
  description: "Create and edit Fusion-backed applications.",
});
```

```ts
// server/plugins/feature-flags.ts
import { createFeatureFlagsPlugin } from "@agent-native/core/server";

import { FULL_APP_BUILDING } from "../../shared/feature-flags.js";

export default createFeatureFlagsPlugin({ flags: [FULL_APP_BUILDING] });
```

```ts
// actions/create-fusion-app.ts
import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags";

run: async (args, ctx) => {
  if (!(await isFeatureFlagEnabled(FULL_APP_BUILDING, ctx))) {
    throw new Error("Full app building is not enabled for this account.");
  }
  // Perform the guarded operation.
};
```

Use `useFeatureFlag(FULL_APP_BUILDING.key)` from
`@agent-native/core/client` to hide or reveal the client surface. Core mounts
`get-feature-flags`, `list-feature-flags`, and `set-feature-flag`. Management
is permission-checked and audited; fleet operator UIs call the same app-local
actions through narrowly scoped A2A delegation. Analytics is the sole operator
UI; apps should not add their own flag settings panels. Never route flag writes
through generic settings endpoints or raw SQL, and never put authentication,
authorization, audit, secrets, or SSR cache behavior behind a runtime flag.

## Legacy CLI-style actions {#legacy-cli-actions}

The framework still supports older `export default async function(args)` actions that aren't wrapped in `defineAction` — useful for one-off dev scripts that don't need agent/HTTP exposure. These are CLI-only; they don't appear as agent tools, don't mount HTTP endpoints, and don't get typesafe frontend hooks.

```ts
// actions/debug-dump.ts — CLI-only
import { parseArgs } from "@agent-native/core";

export default async function main(args: string[]) {
  const { table } = parseArgs(args);
  // one-off script you wouldn't want the agent to call
}
```

New code should prefer `defineAction()`. Reach for this pattern only when you deliberately don't want the action exposed to agents or the UI.

### `parseArgs(args)` {#parseargs}

Helper for legacy-style actions. Parses CLI arguments in `--key value` or `--key=value` format:

```ts
import { parseArgs } from "@agent-native/core";

const args = parseArgs(["--name", "Steve", "--verbose", "--count=3"]);
// { name: "Steve", verbose: "true", count: "3" }
```

## Utility functions {#utility-functions}

| Function                | Returns   | Description                                           |
| ----------------------- | --------- | ----------------------------------------------------- |
| `loadEnv(path?)`        | `void`    | Load `.env` from project root (or custom path).       |
| `camelCaseArgs(args)`   | `Record`  | Convert kebab-case keys to camelCase.                 |
| `isValidPath(p)`        | `boolean` | Validate a relative path (no traversal, no absolute). |
| `isValidProjectPath(p)` | `boolean` | Validate a project slug (e.g. `my-project`).          |
| `ensureDir(dir)`        | `void`    | `mkdir -p` helper.                                    |
| `fail(message)`         | `never`   | Print to stderr and `exit(1)`.                        |

## What's next

- [**Audit Log**](/docs/audit-log) — the automatic who-changed-what trail around every action
- [**Human-in-the-Loop Approvals**](/docs/human-approval) — the `needsApproval` gate in depth
- [**Drop-in Agent**](/docs/drop-in-agent) — `useActionMutation` / `useActionQuery` in React
- [**Context Awareness**](/docs/context-awareness) — the `view-screen` + `navigate` pattern in depth
- [**A2A Protocol**](/docs/a2a-protocol) — how other agents discover and call your actions
- [**MCP Protocol**](/docs/mcp-protocol) — exposing actions over MCP
- [**Generative UI**](/docs/generative-ui) — rendering transient or persisted generated UI inline in chat
