---
title: "Access & Authorization"
description: "Who can call an action: exposure flags (agentTool, mcpTool, toolCallable), the declarative access option, ctx-based scoping, accessFilter/assertAccess, and the authorize guard."
---

# Access & Authorization

Seven settings decide who can call an action: the agent, the frontend, an extension, an outside API caller, or nobody without a human's approval first. Most actions never need to touch any of these, since the defaults already fit normal UI-and-agent use.

### Exposure flags {#exposure-flags}

All seven default to the permissive value — `mcpTool` by inheriting `agentTool` — so you only set one when you need to tighten a specific surface. The table below is a quick summary. The sections after it add the one detail each flag needs.

| Flag            | Default       | Restrictive value → who can still call                                           | Typical use                                                          |
| --------------- | ------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `agentTool`     | `true`        | `false` → UI, HTTP, and CLI only (hidden from the model, MCP, and A2A)           | UI-only or programmatic actions that shouldn't take up a tool slot   |
| `mcpTool`       | `agentTool`   | `false` → your own app's agent only (hidden from MCP and A2A)                    | Actions that need an in-app screen or session on the other end       |
| `toolCallable`  | `true`        | `false` → everything **except** the sandboxed extension iframe bridge (403)      | Sensitive account or org changes (delete account, change membership) |
| `publicAgent`   | off (private) | `{ expose: true }` → adds the action to **public** MCP/A2A/OpenAPI surfaces      | Safe read or ingest tools that don't require authentication          |
| `needsApproval` | `false`       | `true` → the agent **pauses**, and a human must approve the specific call        | Consequential side effects (send email, charge a card, delete)       |
| `access`        | none          | a declared policy → only callers holding the role it names, on **every** surface | Actions that read or change one shared resource (edit a form)        |
| `authorize`     | none          | a guard function → only callers it accepts, on **every** surface                 | Operations restricted to a role (per-app RBAC, admin-only ops)       |

These flags are independent, with one exception: an undeclared `mcpTool` inherits `agentTool`. `agentTool` controls what the model sees, see [Hide from the model](#agent-tool) below. `mcpTool` narrows that to outside agents only, see [Hide from external agents](#mcp-tool) below. `toolCallable` controls only the extension iframe, see [Block extension iframes](#tool-callable) below. `publicAgent` adds an opt-in public surface, and a public web route never implies public tool exposure, see [Expose to public agents](#public-agent) below. `needsApproval` gates execution after the call is already made, see [Require human approval](#needs-approval) below. `access` declares the role the caller must hold on a resource, checked on every dispatch path before `run()` is entered, see [Require access to a resource](#access) below. `authorize` decides whether this caller may run the action at all, on every dispatch path, before `run()` is entered, see [Restrict callers by role](#authorize) below.

## Hide from the model {#agent-tool}

By default, every action is a callable agent tool. Set `agentTool: false` to remove it from every agent tool list, while keeping it behind the framework's auth and action surface. It stays callable from the UI (`useActionMutation()` or `callAction()`), the 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 }) => {
    /* ... */
  },
});
```

Use this when you add a UI-only or purely programmatic action, or when the UI stops using an action that would otherwise stay exposed to the model.

## Hide from external agents {#mcp-tool}

`agentTool` is all-or-nothing: an action is a tool for every agent, or for none. `mcpTool` splits that in two, so an action can stay a normal tool for your own app's agent while never reaching Claude, Cursor, or a sibling app over MCP and A2A:

```ts
export default defineAction({
  description: "Open the record inspector beside the current selection.",
  mcpTool: false, // needs a live screen on the other end
  schema: z.object({ recordId: z.string() }),
  run: async ({ recordId }) => {
    /* ... */
  },
});
```

Use it for actions whose result only means something with your app's UI, an open session, or the current selection in front of the caller. `mcpTool` can only narrow `agentTool`, never widen it: an action with `agentTool: false` stays hidden everywhere. It governs the external tool surface itself, not what your own agent may do while answering an `ask_app` question — that run is your agent's, and it keeps its whole tool list.

Left undeclared, `mcpTool` follows `agentTool` rather than defaulting to a flat `true`, so hiding an action from the agent hides it from outside agents too and one flag stays one decision.

The opposite value has a second job. External agents are served a small, curated catalog by default rather than your whole action registry, and `mcpTool: true` is how an action declares itself part of it:

```ts
export default defineAction({
  description: "List the databases connected to this workspace.",
  mcpTool: true, // served to external agents, not just discoverable
  http: { method: "GET" },
  run: async () => {
    /* ... */
  },
});
```

This is the same catalog as the plugin's `mcp: { connectorCatalog: [...] }` list, declared on the action instead of in a separate array that has to be kept in sync as actions are renamed. Both forms work, and an app can move over one action at a time. Everything left out stays discoverable through `tool-search`.

Pairing the two flags the other way — `agentTool: false` with `mcpTool: true` — makes an action **MCP-only**: outside agents get it, your own app's agent never sees it. Use it for the export, sync, or handoff an outside tool needs and your own agent has no reason to call.

Membership is not permission. An external caller still has to pass the OAuth scope check, the `externalAgents` policy, and — for the direct, no-model A2A path — the `publicAgent` opt-in below.

## Block extension iframes {#tool-callable}

Extensions ([Alpine.js mini-apps in sandboxed iframes](/docs/extensions)) call actions through `appAction(name, params)`, running with the viewer's own permissions, secrets, and SQL scope. For sensitive operations, that's too much trust by default. Set `toolCallable: false` to make the extension bridge return a 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 an account or org, change auth state, modify org membership, or grant share access. The framework's built-in `share-resource`, `unshare-resource`, and `set-resource-visibility` actions already opt out. The framework enforces this with a header the host sets on iframe calls, one an iframe can't fake. Regular UI, agent, CLI, MCP, and A2A calls are unaffected. See [Security](/docs/security) for details.

## Expose to public agents {#public-agent}

Every action stays off any public protocol surface by default, even one whose web route is public. Making a page crawlable never exposes a tool. Set `publicAgent: { expose: true, readOnly: true, ... }` to opt one safe action into the public MCP, A2A, and OpenAPI surfaces:

```ts
export default defineAction({
  description: "Search published docs.",
  schema: z.object({ query: z.string() }),
  http: { method: "GET" },
  readOnly: true,
  publicAgent: {
    expose: true,
    readOnly: true,
    requiresAuth: false,
  },
  run: async ({ query }) => {
    /* ... */
  },
});
```

Use it only for safe, read-only, or ingest-only actions, the kind you'd let a stranger call without signing in. `requiresAuth: false` tells the public surface this action is safe to list before authentication. Leave it `true` for an action that still needs a connected caller's identity, like a per-account ingest tool. See [External Agents](/docs/external-agents) and [MCP Protocol](/docs/mcp-protocol) for the full catalog and auth model this opts into.

## Require human approval {#needs-approval}

A handful of actions are too consequential to let the agent run on its own, like sending an email, charging a card, or 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.

<Callout tone="warning">
  Keep approvals rare. Every gated action is a hard stop in the agent loop. It
  interrupts the run and demands a human round-trip. Use `needsApproval` only
  for genuinely high-consequence, hard-to-undo, outward-facing operations. If
  you find yourself gating reads or routine writes, you're holding it wrong. The
  default is **off**, and almost every action should leave it off.
</Callout>

<AnnotatedCode
  id="doc-block-13p4g9y"
  title="Gating the one consequential action"
  filename="actions/send-email.ts"
  language="ts"
  code={
    'export default defineAction({\n  description: "Send an email via Gmail.",\n  schema: z.object({\n    to: z.string(),\n    subject: z.string(),\n    body: z.string(),\n  }),\n  // Sending is outward-facing and hard to undo, so the agent can never send\n  // without a human approving the specific call. Drafting and queueing are\n  // not affected. Only the real send is gated.\n  needsApproval: true,\n  run: async (args) => {\n    /* ...actually send... */\n  },\n});'
  }
  annotations={[
    {
      lines: "10",
      label: "The whole gate",
      note: "One flag. When it is truthy and the call has not been approved, the loop stops before `run`. The model never reaches the side effect on its own.",
    },
    {
      lines: "11-13",
      label: "run() is untouched",
      note: "The handler stays the same. Approval is enforced by the loop around it, not by anything inside `run`.",
    },
  ]}
/>

- **`needsApproval: true`** always requires approval.
- **`needsApproval: (args, ctx) => boolean | Promise<boolean>`** requires approval only when the predicate returns true. Use this to gate conditionally, for example only for external recipients or only above a dollar threshold:

  ```ts
  needsApproval: (args) => !args.to.endsWith("@your-company.com"),
  ```

  Keep the predicate pure and fast. If the predicate throws an error, the framework treats the call the same as if the predicate had returned true, so the action pauses for approval instead of running. This means a broken predicate can never silently let a high-consequence action run without a human approving it first.

When `needsApproval` is omitted, behavior is byte-for-byte unchanged. There is no extra cost on the common path.

This works the same way for legacy `parameters`-style actions and for schema-based actions. The in-app agent and sub-agents pause in the agent loop. MCP and A2A use their own approval flows.

Direct calls to the [HTTP API](/docs/http-api) cannot provide human approval. The action route runs a gated call only for the proven app UI or the app's MCP directory widget. Other direct HTTP callers receive a `409` with `errorCode: "approval_required"`, and a service identity cannot approve. The shared client sends a short-lived UI capability with signed-in writes. A call without proof receives `403` with `errorCode: "ui_capability_required"`, then the client mints the capability and retries once. A2A tasks use their own approval flow, not this HTTP response. The task pauses until its owner approves, then replays the approved tool call. The local `pnpm action ...` CLI also evaluates `needsApproval` on prepared parsed/defaulted input with caller `cli`. It requires terminal confirmation or a person-approved rerun with `--approved`. See [CLI approval](/docs/actions-other-surfaces#cli). Treat this field as an approval flow, not an authorization boundary.

### How the loop pauses {#loop}

When the agent calls a gated action and this specific call has **not** already been approved, the loop does **not** execute `run()`. Instead it:

1. Resolves the gate. The loop first prepares the call: it validates the arguments and applies the schema's defaults and coercions. An invalid call stops here with its validation error, and nobody is asked to approve it. For a predicate, the loop then calls `needsApproval(args, ctx)` with the prepared arguments, the ones `run()` would receive. If that call throws an error, the loop treats it the same as a `true` result, so the action requires approval.
2. Emits a `tool_start` event (so the UI shows the call) followed immediately by an **`approval_required`** event, then stops the turn. The action's side effect never happens.

The `approval_required` event carries everything the client needs to render an affordance:

| Field         | Type     | Notes                                                               |
| ------------- | -------- | ------------------------------------------------------------------- |
| `tool`        | `string` | The action name the agent tried to call.                            |
| `input`       | object   | The arguments the agent passed.                                     |
| `approvalKey` | `string` | **Stable key** the client echoes back to approve _this exact call_. |
| `toolCallId`  | `string` | The model-side tool-call id, when available.                        |

The `approvalKey` is derived deterministically from the tool name plus its input, so the same logical call always produces the same key. The model never sees or sets it. It exists purely as a handshake between the framework and the human's Approve affordance.

The paused tool returns a result telling the model the turn is paused and not to retry, so the model does not spin.

### How the human approves {#approve}

On `approval_required`, the chat UI renders an **Approve / Deny** affordance on the paused tool call. This is wired automatically in `AssistantChat`. You do not need to build it per template.

<WireframeBlock id="doc-block-humanapprovalwf1">
  <Screen
    surface="popover"
    html={
      "<div style='display:flex;flex-direction:column;box-sizing:border-box;padding:16px;gap:12px'><div class='wf-card' style='display:flex;flex-direction:column;gap:8px'><div style='display:flex;align-items:center;gap:8px'><span class='wf-pill warn'>Needs approval</span><strong>send-email</strong></div><div style='display:flex;flex-direction:column;gap:2px'><span class='wf-muted'>to: alex@acme.com</span><span class='wf-muted'>subject: Following up on the proposal</span></div><div style='display:flex;gap:8px'><button>Deny</button><button class='primary'>Approve</button></div></div></div>"
    }
  />
</WireframeBlock>

- **Approve** re-issues the turn as an ordinary continuation message that carries the call's key in `approvedToolCalls: [approvalKey]`. On the re-issued turn, the gate sees the key in the approved set and lets that specific call run normally.
- **Deny** dismisses the affordance locally. Nothing is re-issued, so the action never runs.

`approvedToolCalls` is a field on the chat request (`AgentChatRequest.approvedToolCalls`). Keys that are not present in it stay paused. Approving one call never approves any other call. Because the key is content-addressed, an approval only authorizes that exact call with those exact arguments. If the model later proposes a different send, that becomes a new key and needs its own fresh approval.

### End-to-end {#flow}

<Diagram id="doc-block-2nor1g" title="The approval interrupt" summary={"A gated call pauses the turn before run() fires. Approval re-issues the turn carrying the call's key. Only then does the side effect happen."}>

```html
<div class="diagram-approve">
  <div class="diagram-box" data-rough>Agent calls send-email</div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&darr;</div>
  <div class="diagram-panel warn" data-rough>
    <strong>Gate truthy, call not yet approved</strong
    ><small class="diagram-muted"
      >loop emits tool_start + approval_required { tool, input, approvalKey
      }</small
    ><span class="diagram-pill warn">run() has not executed</span>
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&darr;</div>
  <div class="diagram-box" data-rough>
    Human clicks Approve in chat<br /><small class="diagram-muted"
      >client re-issues the turn with approvedToolCalls: [approvalKey]</small
    >
  </div>
  <div class="diagram-arrow diagram-muted" aria-hidden="true">&darr;</div>
  <div class="diagram-panel ok" data-rough>
    <span class="diagram-pill ok"
      >Gate sees the key &rarr; run() executes &rarr; email sends</span
    >
  </div>
</div>
```

```css
.diagram-approve {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}
.diagram-approve .diagram-panel {
  display: flex;
  flex-direction: column;
  gap: 6px;
  align-items: center;
  padding: 12px 16px;
  text-align: center;
}
.diagram-approve .diagram-arrow {
  font-size: 22px;
  line-height: 1;
}
```

</Diagram>

The Mail template's `send-email` action is the canonical use of this gate in the framework, and it is intentionally rare. It sets `needsApproval: true` so the agent can draft and queue emails freely. It can never actually send a message without a human approving that specific send.

## Require access to a resource {#access}

`access` declares what a caller must hold before an action runs. It is data on the definition, not code inside `run()`. Its most common form names a shared resource and the minimum role on it. The Forms template's `update-form` action uses it so only an editor of a form can change that form:

```ts
// actions/update-form.ts
export default defineAction({
  description: "Update an existing form.",
  access: {
    scope: "resource",
    resource: { type: "form", idFrom: "id", level: "editor" },
  },
  schema: z.object({
    id: z.string(),
    title: z.string().optional(),
  }),
  run: async (args) => {
    /* ...update the form... */
  },
});
```

`resource` takes three fields:

- **type**: the name the resource was registered under with `registerShareableResource()`, like `"form"` or `"deck"`. See [Sharing](/docs/sharing#building).
- **idFrom**: the input field that holds the resource's id. A dotted path like `"target.formId"` reads a nested field. If the value is missing or not a non-empty string, the call is denied.
- **level**: the minimum role the caller needs, one of `"viewer"`, `"commenter"`, `"editor"`, `"admin"`, or `"owner"`. It defaults to `"viewer"`.

Like `authorize`, the check wraps `run()` itself, so the UI, HTTP callers, the in-app agent, MCP clients, A2A callers, and the CLI all pass the same check before the body runs. It resolves the caller's role the same way `assertAccess()` does, so ownership, shares, and visibility all count. A caller below `level` gets a 403 such as `Requires editor access to form <id>.` A resource that doesn't exist gets the same 403.

Use `access` when one input field names the one resource the action touches. Use `assertAccess()` inside `run()` when the id comes from somewhere else, like a row you load first, or when one call touches several resources. List queries have no single id to check, so they use `accessFilter()`. See [Access control in actions](#access-control) below. The [doctor](/docs/doctor) check `resource-action-access` accepts either a declared `access` or an `assertAccess()` call.

### Organization and app scopes {#access-scopes}

The other fields check membership instead of a single resource. Every field you set must pass.

- **`scope: "org"`**: the caller must be an active member of their current organization.
- **`scope: "app"`**: the caller must be signed in, and the workspace must have made this app available to them.
- **`permission`**: a permission key from this app's `defineAppRoles()` `permissions` map. The caller must hold an app role that grants it. It runs the `scope: "app"` check first.

`scope: "app"` and `permission` read the workspace's app registry, so they suit apps deployed as part of a workspace. In a standalone app, guard an app permission with `authorize` and `requirePermission()` instead, as shown in [App roles](/docs/organizations-teams-permissions#app-roles-per-app-rbac). That guard also rejects an undeclared permission key when the module loads. `access.permission` only denies it at request time.

## Restrict callers by role {#authorize}

`authorize` answers one specific question: **may this caller run this operation at all?** Give it a guard function, and it runs before `run()` on every caller: agent tool, HTTP, frontend, MCP, A2A, and CLI.

Declare the role vocabulary once with `defineAppRoles()`:

```ts
// server/lib/access.ts
import { defineAppRoles } from "@agent-native/core/org-team";

export const coachAccess = defineAppRoles({
  appId: "coach",
  roles: ["member", "coach-admin"] as const,
  defaultRole: "member", // display only, never satisfies a guard
});
```

Then guard the action with an explicit list of accepted roles:

```ts
// actions/archive-plan.ts
import { coachAccess } from "../server/lib/access.js";

export default defineAction({
  description: "Archive a training plan.",
  schema: z.object({ id: z.string() }),
  authorize: coachAccess.requireAny("coach-admin"),
  run: async ({ id }) => {
    /* ... */
  },
});
```

The guard wraps `run()` itself, instead of attaching to some other part of the action. That's because `run()` is the one thing all six dispatch sites go through, so there is no way for a caller to reach the body without passing the check. A guard that throws denies the call with its own error message. A guard that returns `false` denies the call with a generic 403. Anything else, including `undefined`, allows the call. Because the check needs a caller identity, an unattended CLI or cron invocation with no user email is denied rather than let through.

The wrappers compose in this order:

```text
validate input -> access -> authorize -> run -> validate output -> audit
```

Authorization sits _inside_ input validation, so a guard that inspects `args` gets the same parsed, type-checked value that `run()` receives, not whatever raw shape the caller sent. `access` reads `idFrom` from that same parsed value. Auditing is outermost, so an audited action still records a denial. Read-only actions aren't audited by default, see [Audit logging](#audit) below.

`requireAny()` fails at definition time, not at request time. Calling it with no roles, or with a role outside the declared list, throws as soon as the module loads. That way a mistake, like `authorize: coachAccess.requireAny()` with a dropped argument, breaks right where it happened, instead of showing up later as an unexplained 403.

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

`authorize` only answers whether the caller may run the operation at all. It says nothing about which rows they may touch, that's a separate, row-level check. A table built with `ownableColumns()` and registered with `registerShareableResource()` (see [Sharing](/docs/sharing#building)) gets two helpers for this: `accessFilter()` scopes a list or read query to the rows the caller may see, and `assertAccess()` throws before a write if the caller doesn't hold the required role on that row.

Use `accessFilter()` inside a list or read action's query:

```ts
// actions/list-decks.ts
import { defineAction } from "@agent-native/core/action";
import { accessFilter } from "@agent-native/core/sharing";
import { z } from "zod";
import { getDb, schema } from "../server/db/index.js";

export default defineAction({
  description: "List the decks the current user can see.",
  schema: z.object({}),
  http: { method: "GET" },
  run: async () => {
    const db = getDb();
    return await db
      .select()
      .from(schema.decks)
      .where(accessFilter(schema.decks, schema.deckShares));
  },
});
```

Use `assertAccess()` before an update or delete, since a `WHERE` clause alone can't stop a write to a row it excludes:

```ts
// actions/rename-deck.ts
import { defineAction } from "@agent-native/core/action";
import { assertAccess } from "@agent-native/core/sharing";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { getDb, schema } from "../server/db/index.js";

export default defineAction({
  description: "Rename a deck.",
  schema: z.object({ id: z.string(), title: z.string() }),
  run: async ({ id, title }) => {
    await assertAccess("deck", id, "editor");
    const db = getDb();
    await db.update(schema.decks).set({ title }).where(eq(schema.decks.id, id));
    return { ok: true };
  },
});
```

When the id comes straight from the input, as `id` does here, a declared [`access`](#access) policy runs the same check from the action definition.

A create action calls neither helper. It only needs to stamp `ownerEmail` (and `orgId`) from the request context when inserting the new row, since there's no existing row yet to check access against. See [Sharing](/docs/sharing#building) for the full registration and create pattern, and [Run Context](/docs/actions-run-context) for the full shape of the `ctx` second argument used above.

The four mechanisms are not interchangeable:

| Mechanism                         | Question it answers                                   | Applies to                                       |
| --------------------------------- | ----------------------------------------------------- | ------------------------------------------------ |
| `access`                          | Does the caller hold the declared role or membership? | All six dispatch sites                           |
| `authorize`                       | May this caller perform the operation at all?         | All six dispatch sites                           |
| `needsApproval`                   | Should a human approve this specific permitted call?  | Agent, MCP, A2A, CLI. HTTP gates unproven calls. |
| `accessFilter()`/`assertAccess()` | Which rows may this permitted caller see or write?    | Inside `run()`, per query or row                 |

A restricted write action typically needs both `authorize` and a row check. The first keeps non-coaches out of the operation. The second keeps a coach out of another org's plan. The row check is `assertAccess()`, or `access` when the row's id is an input field. `needsApproval` is a separate axis. It never restricts who may call, only whether the agent may proceed unattended.

`coachAccess` above comes from `defineAppRoles()`, the per-app role overlay on org membership. See [Organizations, Teams & Permissions → App roles](/docs/organizations-teams-permissions#app-roles-per-app-rbac) for the role model and its guarantees.

## 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 and turn, with credential-redacted inputs. Read-only (`GET`) actions are skipped. You don't write any code for this. It happens automatically inside `defineAction()`.

Add an `audit` block only to fine-tune capture. The most common reason is to declare the resource the action changed, so the change shows up in that resource 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... */
  },
});
```

A few other options. `audit: { onRead: true }` audits a sensitive read, like a secret access or 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` and `get-audit-event` actions. See [Audit Log](/docs/audit-log) for full details.

## What's next

- [**Overview**](/docs/actions-overview) — what an action is and the hello-world walkthrough
- [**Defining Actions**](/docs/actions-defining) — `defineAction()` anatomy, schema, and HTTP config
- [**Run Context**](/docs/actions-run-context) — the `ctx` argument these checks read
- [**Mail template**](/docs/template-mail) — `send-email` is the reference example for `needsApproval`
- [**Audit Log**](/docs/audit-log) — the automatic who-changed-what trail
- [**Security**](/docs/security) — `accessFilter`/`assertAccess` and the extension iframe trust boundary
- [**Sharing**](/docs/sharing) — the framework sharing system these helpers back
