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.
  • A typesafe React mutationuseActionMutation("name") on the frontend, types inferred from the schema.
  • An HTTP endpointPOST /_agent-native/actions/<name> (auto-mounted by the framework).
  • An MCP tool — exposed to Claude Desktop, ChatGPT remote-MCP, and any other MCP client.
  • An A2A tool — called by other agent-native apps over A2A.
  • A CLI commandpnpm action <name> for scripting and dev loops.

One definition, six consumers. This is rung 3 of the ladder.

Defining an action

// actions/reply-to-email.ts
import { defineAction } from "@agent-native/core";
import { z } from "zod";

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 }) => {
    await db.insert(replies).values({ emailId, body });
    return { ok: true, emailId };
  },
});

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

Schema options

schema accepts any Standard Schema-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.

HTTP config

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

export default defineAction({
  description: "Get details for a lead.",
  schema: z.object({ leadId: z.string() }),
  http: { method: "GET", path: "leads/:leadId" }, // optional override
  run: async ({ leadId }) => {
    return await db.select().from(leads).where(eq(leads.id, leadId));
  },
});
  • 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 route path under /_agent-native/actions/. Defaults to the filename.
  • 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.

Extension callability

Extensions (Alpine.js mini-apps that run inside sandboxed iframes — see Extensions) call actions via appAction(name, params). Because a shared extension's HTML/JS executes inside the viewer's session, an action invoked from an extension runs with the viewer's permissions, secrets, and SQL scope. For high-blast-radius operations, that is too much trust to grant by default.

Use the toolCallable flag to control this (the flag name is kept for backward compatibility — it gates extension iframe callability):

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 () => {
    /* ... */
  },
});
Value Behavior
true Allow (same as undefined). Useful for documentation of intent.
false Explicit deny. The extension bridge returns 403; the action is still callable normally from the UI, agent, CLI, MCP, and A2A.
undefined Default-allow. Extensions are intra-org and typically authored by trusted teammates, so the default trusts the org-level access controls. Set false only for genuinely auth-adjacent operations (account deletion, org membership changes).

Enforcement: the parent host (ToolViewer.tsx / EmbeddedTool.tsx — physical class names retained) tags every outbound action call from an extension iframe with the header X-Agent-Native-Tool-Bridge: 1. The action route layer reads this header and applies the rule above. Regular UI/agent/CLI/A2A calls do not carry the header and are unaffected. The header is set by the React host; the iframe's user-authored content cannot spoof it because the bridge sanitizes iframe-supplied headers.

Set toolCallable: false for actions that:

  • delete or transfer ownership of any account/org,
  • change auth state (sign-out-all sessions, rotate tokens),
  • modify org membership (invite/remove members, change roles),
  • change resource visibility or grant share access (the framework's built-in share-resource, unshare-resource, and set-resource-visibility are already opted out).

Calling it from the UI

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

useActionMutation

For actions that change state:

import { useActionMutation } from "@agent-native/core/client";

const { mutate, isPending } = useActionMutation("replyToEmail");

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

On success, the framework emits a poll event so every useActionQuery/useDbSync consumer refetches automatically. See Real-Time Sync.

useActionQuery

For read-only GET actions:

import { useActionQuery } from "@agent-native/core/client";

const { data, isLoading } = useActionQuery("getLead", { leadId });

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

Calling it from the CLI

Every action is runnable via pnpm action:

pnpm action replyToEmail --emailId thread-123 --body "Thanks!"

Flags are parsed into the shape your schema expects. Useful for agent-dev loops, scripts, and cron.

Calling it from another agent (A2A)

If your app is an A2A 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

With MCP enabled, your actions show up in the framework's MCP server at /_agent-native/mcp. Any MCP client — Claude Desktop, ChatGPT remote MCP, etc. — can connect and see them as tools. See MCP Protocol.

Standard actions

Every template should include these two for context awareness:

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.

// actions/view-screen.ts
import { defineAction } from "@agent-native/core";
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") {
      const res = await fetch(
        `${process.env.APP_URL}/api/emails?label=${navigation.label}`,
      );
      screen.emailList = await res.json();
    }

    return screen;
  },
});

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

// actions/navigate.ts
import { defineAction } from "@agent-native/core";
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 };
  },
});

Legacy CLI-style 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.

// 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)

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

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

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

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