How to create and run agent actions. Actions are the single source of truth for app operations — the agent calls them as tools and frontend code calls them through client hooks. Use when creating a new action, adding an API integration, or wiring up frontend data fetching.
69
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Actions in actions/ are the single source of truth for app operations. The agent calls them as tools, and the frontend calls them through useActionQuery / useActionMutation. The framework owns the HTTP transport behind those hooks. No duplicate /api/ routes needed.
Before creating any custom REST/API route for app data, inspect actions/ and the action table in AGENTS.md. If an action already exists, call it directly from the agent or with useActionQuery / useActionMutation from the UI. If the capability is missing, create or update a defineAction. Do not add /api/*, server/routes/*, or other pass-through endpoints whose main job is to call, repackage, or re-export an action.
Stop trigger: the moment you are about to create a file under server/routes/api/ — or add server middleware to guard one — stop and check it against the exception list in When You Still Need Custom /api/ Routes below. If it is not on that list, write a defineAction instead. This applies even when you already started the route; abandoning a half-written action to hand-roll routes is the exact failure this rule exists to prevent.
Actions give the agent callable tools with structured input/output, AND they give the frontend a typed client contract through hooks. One implementation serves both the agent and the UI. They keep the agent's chat context clean, they're reusable, and they can be tested independently.
Every agent-exposed action is a tool in the model's context window. There is a real cost to each one: more tools means more for the model to read, disambiguate, and choose between, which degrades tool-selection quality. Treat the action list like an API you have to maintain — add the fewest, most orthogonal actions that cover the capability, not one per UI affordance.
update over N per-field actions. A single update-<thing> that takes a patch of optional fields beats update-<thing>-name, update-<thing>-order, update-<thing>-color, … The agent (and the UI) pass only the fields that change. Same for create/delete — one orthogonal action per resource, not one per code path.get-<thing>-by-x, list-<thing>-filtered-by-y, etc. For provider data, expose the shared provider-api-catalog / provider-api-docs / provider-api-request trio (see templates/dispatch/actions/) so the agent can hit any endpoint or filter without a new action each time. For app data in dev, the db-query tool already answers arbitrary read questions.agentTool: false. An action that only the frontend or an HTTP/cron caller needs should not spend a slot in the model's tool list. agentTool: false keeps it callable from useActionMutation / callAction / /_agent-native/actions/<name> while removing it from every agent tool surface (in-app assistant, MCP, A2A).agentTool: false is NOT toolCallable: false. They are different switches:
agentTool: false → hidden from the model entirely (it is no longer a tool the agent can see or call). Still frontend/HTTP-callable.toolCallable: false → only blocks the sandboxed extension ("tools") iframe bridge (appAction(...)). The action stays fully visible to the model, the UI, the CLI, MCP, and A2A. Use it for high-blast-radius operations (account/org/auth changes), not for trimming the tool list.agentTool: false — do not leave it exposed to the model as dead tool weight. The advisory audit below helps you spot these.pnpm actions:audit [template ...] (or node scripts/audit-template-actions.mjs) statically scans a template's actions/ and prints two kinds of suggestions:
app/ (candidates to delete or mark agentTool: false).update-foo-name / update-foo-order that could collapse into one orthogonal update-foo.It is advisory only: it always exits 0, never fails CI, and uses conservative heuristics, so expect some false positives (e.g. an action the agent calls but the UI doesn't). Use it as a prompt to review, not a gate.
Use defineAction with a Zod schema (required for new actions):
// actions/list-meals.ts
import { z } from "zod";
import { defineAction } from "@agent-native/core/action";
import { getDb } from "../server/db/index.js";
import { meals } from "../server/db/schema.js";
export default defineAction({
description: "List all meals",
schema: z.object({
date: z.string().describe("Filter by date (YYYY-MM-DD)"),
}),
http: { method: "GET" },
run: async (args) => {
// args is fully typed: { date: string }
const db = getDb();
const rows = await db.select().from(meals);
return rows; // Return objects/arrays, NOT JSON.stringify()
},
});The schema field accepts a Zod schema (or any Standard Schema-compatible library). It provides runtime validation with clear error messages (400 for HTTP, error result for agent), full TypeScript type inference for run() args, and auto-generated JSON Schema for the agent's tool definition. zod is a dependency of all templates.
When an action reads or writes app data, use Drizzle's query builder and portable operators from drizzle-orm. Do not use raw SQL, getDbExec(), or dialect-specific schema imports in normal actions unless there is a documented reason Drizzle cannot express the query.
When an action calls an external service, never hardcode API keys, bearer
tokens, webhook URLs, signing secrets, OAuth refresh tokens, private
Builder/internal data, or customer data. Read user/org/workspace credentials
from readAppSecret, resolveCredential, OAuth token helpers, or the provider
API credential adapter. Use process.env only for explicitly deploy-level
configuration, and keep examples to obvious placeholders.
Tips:
.describe() for parameter descriptions.optional() for optional paramsz.coerce.number() for numeric params that arrive as strings from HTTP.
For booleans, use an explicit string parser/helper instead of
z.coerce.boolean() because JavaScript treats any non-empty string,
including "false", as truthy.z.enum(["draft", "published"]) for constrained valuesThe legacy parameters field (plain JSON Schema object) still works as a fallback but does not provide runtime validation or type inference.
When you need app data or a mutation:
defineAction when the agent and UI both need a new operation.Do not build an umbrella REST API to make actions "easier" to call. Actions are already callable by agents, CLIs, React hooks, HTTP, MCP/A2A exposure, and external hosts through the framework.
For provider integrations used in ad hoc analysis, querying, reporting, or cross-source research, do not hardcode every provider endpoint as a separate rigid action and do not encode one lookback window, filter shape, or pagination strategy as the only path the agent can take. Expose the shared provider API action trio instead:
provider-api-catalog: lists provider base URLs, auth style, credential keys,
docs/spec URLs, placeholders, and examples without exposing secrets.provider-api-docs: fetches public provider docs/spec/changelog URLs when
the exact endpoint, filter operator, payload shape, or pagination contract is
uncertain. Registered docs URLs are curated starting points. Use
responseMode: "markdown" for clean readable docs, or
responseMode: "matches" with search: { query | terms | regex } for
compact snippets instead of flooding context with raw HTML.provider-api-request: makes a constrained authenticated HTTP request to the
provider host, injects configured credentials, blocks private/internal URLs,
and redacts secrets.Use @agent-native/core/provider-api as the shared substrate, and build these
actions as thin factory imports rather than copying defineAction schemas and
handlers into each template:
import {
createProviderApiCatalogAction,
createProviderApiDocsAction,
createProviderApiRequestAction,
} from "@agent-native/core/provider-api/actions/provider-api";
import {
createDeleteStagedDatasetAction,
createListStagedDatasetsAction,
createQueryStagedDatasetAction,
} from "@agent-native/core/provider-api/actions/staged-datasets";Pass the app's existing provider runtime to the provider factories, and pass
its app id to the staged-dataset factories. Keep app-specific descriptions,
provider allow-lists, HTTP/tool-callability settings, and credential adapters
as factory options. Only add a thin credential adapter when the app has
app-specific credential lookup rules. If the app stores a built-in provider's
OAuth grant under a narrower local provider id, use the runtime's
oauthProviderOverrides instead of duplicating provider config. If credentials
are stored on shareable/resource rows rather than in the shared credential or
OAuth-token stores, build a resolver that enforces those access checks before
exposing raw provider requests. Keep provider-api-request http: false
unless a separate UI permission model authorizes arbitrary provider writes.
Specific actions such as search-records, search-emails, or sync-source
are convenience shortcuts, not capability limits; agents should fall back to
the provider API trio when a question requires an endpoint or filter that the
shortcut does not model.
This is a framework tenet. The safety boundary should be provider host
allow-listing, credential scoping, auth injection, private-network blocking,
secret redaction, and user/org access checks, not an artificially small set of
hand-authored read actions. If the upstream provider API supports a capability,
the agent should normally be able to reach it through provider-api-request
with the user's configured credentials. For large responses, expose staging
(stageAs, itemsPath, pagination, and query-staged-dataset) or sandboxed
code execution so the agent can reduce data without flooding context.
For broad provider questions, cross-source joins, corpus-wide mention/search
work, classification, or any answer where absence matters, design the action
surface for full coverage instead of convenience-only samples. The agent should
be able to fetch every relevant page or an explicitly bounded cohort, stage or
save the raw provider response outside chat, and then use
query-staged-dataset, run-code, or provider-side search to count, join,
grep, classify, and aggregate. Tool descriptions and AGENTS.md guidance should
teach agents to report source, filters, time window, row/record counts,
pagination status, truncation, failed pages, and uncovered gaps. They must not
turn default limits, sampled rows, truncated excerpts, or aborted calls into a
confident "none found", "all records", or exhaustive conclusion.
For public web pages and docs, prefer the token-efficient path: web-search
to find likely URLs, web-request or provider-api-docs with clean
responseMode output to read a page, and run-code with webRead() /
webFetch() when you need to grep, aggregate, or compare many pages before
returning a small result.
For LONG compute, run-code supports durable background executions: pass
background: true and the code is enqueued to a sandbox_executions row and
executed out-of-band with a generous budget (default 10 min), surviving the
hosted agent run's ~40s soft timeout. The call returns
{ executionId, status: "queued" } immediately with polling guidance; check
progress with run-code { executionId } (or the get-code-execution tool
where registered) — results persist after completion. Use background for big
cross-source joins, multi-page provider sweeps, and heavy analysis scripts;
keep quick scripts in the default foreground mode. Agents should continue
other work between polls, and on failed/timed_out split the computation or
persist intermediate progress with workspaceWrite and re-run.
http OptionControls how the action is exposed as an HTTP endpoint:
| Value | Behavior | Use for |
|---|---|---|
| (omitted) | Auto-exposed as POST /_agent-native/actions/:name | Write operations (default) |
{ method: "GET" } | Auto-exposed as GET /_agent-native/actions/:name | Read-only queries |
{ method: "PUT" } | Auto-exposed as PUT /_agent-native/actions/:name | Update operations |
{ method: "DELETE" } | Auto-exposed as DELETE /_agent-native/actions/:name | Delete operations |
{ method: "GET", path: "custom" } | Auto-exposed as GET /_agent-native/actions/custom | Custom route path |
false | Agent-only, never exposed as HTTP | navigate, view-screen, internal actions |
The framework auto-refreshes the UI after any successful mutating action. On completion of a non-GET action, the framework emits a change event with source: "action" that the client's useDbSync picks up and uses to invalidate ["action"] React Query keys — so list-* / get-* hooks refetch without a full page reload. In-process calls emit directly; dev-mode pnpm action ... calls also write a durable marker so the web server sees child-process action changes.
Rules:
http: { method: "GET" } → read-only, does NOT trigger a refresh (inferred automatically).POST, PUT, DELETE, or http: false) → treated as mutating, triggers a refresh on success.POST that only reads), pass readOnly: true on the action definition.parallelSafe: true. Only do this when the action is internally concurrency-safe and order-independent (for example, it uses an app-level lock or idempotent upsert semantics). Mutating actions remain serialized by default.Agents do NOT need to call refresh-screen after a normal action — it's already handled. refresh-screen is only needed when the agent mutates data via a path the framework can't see (e.g. writing to an external system the app mirrors) or when the agent wants to pass a scope hint for narrower invalidation.
Actions should return structured data (objects, arrays) — not JSON.stringify(). The framework serializes the response automatically. If you return a string, the framework tries to parse it as JSON for a clean response.
// Good — return structured data
run: async (args) => {
const events = await fetchEvents(args.from, args.to);
return events;
}
// Bad — don't stringify
run: async (args) => {
const events = await fetchEvents(args.from, args.to);
return JSON.stringify(events, null, 2);
}_agentImages)An action's return object may include the well-known optional field
_agentImages to attach vision images (screenshots, chart previews, rendered
designs) to the tool result. The agent literally sees them — enabling visual
self-review loops — while the field itself is stripped from the JSON text the
model reads.
run: async ({ dashboardId }) => {
const shot = await renderDashboardPng(dashboardId); // Buffer
return {
dashboardId,
panelCount: 6,
_agentImages: [
// Either a public https URL (preferred — the provider fetches it)…
{ url: "https://cdn.example.com/previews/dash-1.png", label: "overview" },
// …or base64 without a data: prefix (mediaType required; a full
// data:image/png;base64,… URL in `data` is also accepted and parsed).
{ data: shot.toString("base64"), mediaType: "image/png" },
],
};
},Rules and limits:
Array<{ url?: string; data?: string; mediaType?: string; label?: string }>.
Each entry needs url (https only) or data. Supported media types:
image/jpeg, image/png, image/gif, image/webp.[image: …] notes (URLs verbatim; base64 as a byte-count placeholder) —
never the payload. Images are re-attached only for the live turn; replayed
history is text-only, so prefer stable url images the model can re-request.image content parts are
converted automatically under the same caps.outputSchema)schema validates inputs; outputSchema validates what the action returns. Pass any Standard Schema-compatible schema (Zod, Valibot, ArkType) and the framework validates the result after run() resolves — input validated before run, output after.
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; "strict" | "fallback"
// outputFallback: { summary: "", messageCount: 0 }, // used only by "fallback"
run: async ({ threadId }) => {
/* ... */
},
});"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 outputFallback in place of the invalid result.On success the validated value is returned, so coercion/defaults on outputSchema apply. Omit outputSchema and behavior is byte-for-byte unchanged (no wrapping).
needsApproval)For high-consequence, outward-facing, hard-to-undo actions (sending an email, charging a card, deleting an account), set needsApproval so the agent cannot run the action without a human approving the specific call:
export default defineAction({
description: "Send an email via Gmail.",
schema: z.object({ to: z.string(), subject: z.string(), body: z.string() }),
needsApproval: true, // boolean, or (args, ctx) => boolean | Promise<boolean>
run: async (args) => {
/* ...actually send... */
},
});When the gate is truthy and the call isn't yet approved, the loop emits an approval_required event and stops the turn — run() never executes. A predicate gates conditionally (e.g. only external recipients) and fails closed: a throw is treated as "approval required". The human approves via the chat UI's Approve affordance, which re-issues the turn with the call's approvalKey, and only then does the action run.
Keep approvals rare — the default is off and almost every action should leave it off. The canonical example is Mail's send-email (needsApproval: true). See the security skill and the Human Approval doc.
The frontend calls actions using React Query hooks from @agent-native/core/client. Components should not hand-write fetch("/_agent-native/actions/..."); add or reuse a client hook/helper instead. Use callAction from the same package for imperative cases that do not fit a hook, such as debounced search, prefetching, or non-React event handlers.
useActionQuery — for GET actionsimport { useActionQuery } from "@agent-native/core/client/hooks";
function MealList() {
// Types are auto-inferred from the action's schema + return type — no manual generic needed
const { data: meals } = useActionQuery("list-meals", {
date: "2025-01-01",
});
return <ul>{meals?.map((m) => <li key={m.id}>{m.name}</li>)}</ul>;
}useActionMutation — for POST/PUT/DELETE actionsimport { useActionMutation } from "@agent-native/core/client/hooks";
function AddMealButton() {
// Types are auto-inferred — no manual generic needed
const { mutate } = useActionMutation("log-meal");
return (
<button onClick={() => mutate({ name: "Salad", calories: 350 })}>
Log Meal
</button>
);
}Do NOT use manual type generics like useActionQuery<Meal[]>(...). Types are inferred automatically from .generated/action-types.d.ts, which is auto-generated by a Vite plugin.
Mutations automatically invalidate all ["action"] query keys on success, so GET queries refetch.
callAction — for imperative client codeimport { callAction } from "@agent-native/core/client/hooks";
const people = await callAction("search-people", { query }, { method: "GET" });Prefer hooks in React data flows. Use callAction when a hook would be awkward;
do not hand-write action route fetches in components.
pnpm action my-action --input data/source.json --output data/result.jsonThe default template uses core's runScript() in actions/run.ts:
import { runScript } from "@agent-native/core";
runScript();This is the canonical approach for new apps. Action names must be lowercase with hyphens only (e.g., my-action).
/api/ RoutesThis is the complete exception list. A route in server/routes/api/ is only
justified when the caller is not your own UI or agent, or when the payload is
not JSON:
Everything else is an action: CRUD, settings, search, list/detail reads, auth state, and anything the UI fetches as JSON. If a route needs new middleware to scope it to the current user, that is a signal it should be an action — actions already run inside request context with access checks.
Existing first-party templates still contain a shrinking, explicitly
grandfathered set of older /api/* CRUD routes for app data. The repository's
guard:no-action-twin-routes CI check ratchets that baseline down across the
first-party template route trees (with the separately owned Plan template
fenced out); it is not a substitute for keeping generated apps action-first.
When the agent needs a durable image or file URL, call the core upload-image
action or use uploadFile() in server code. Do not write base64 into SQL,
markdown, deck/design JSON, or action results. _agentImages on action results
is for ephemeral vision previews only, not persistence.
If it's a standard CRUD operation, data query, or a wrapper around an action, use the action instead.
Older actions use a bare async function export with parseArgs:
import { parseArgs, loadEnv, fail } from "@agent-native/core";
export default async function myAction(args: string[]) {
loadEnv();
const parsed = parseArgs(args);
// ...
}This still works but is not auto-exposed as HTTP. Prefer defineAction for all new actions.
JSON.stringify().http: { method: "GET" } for read-only actions. Default is POST.http: false for agent-only actions (navigate, view-screen).agentTool: false for UI-only / programmatic actions that should NOT be a tool in the model's context window. It stays frontend/HTTP-callable but is hidden from the agent. Distinct from toolCallable: false, which only blocks the sandboxed extension iframe bridge.AGENTS.md with when to use it, important args, and which return fields to preserve..agents/skills/ and add app-skill visibility (internal, exported, or both) when it should ship through a marketplace.loadEnv() only for deploy-level configuration. User/org/workspace
credentials belong in the encrypted secrets/credential/OAuth stores, never as
hardcoded literals or shared env fallbacks.fail() for user-friendly error messages (exits with message, no stack trace).@agent-native/core/action and CLI helpers such as parseArgs() from @agent-native/core — do not redefine framework utilities locally./_agent-native/actions/:name endpoint is the REST surface; duplicating it under /api/* creates drift and hides the operation from agents.Read action (GET):
import { z } from "zod";
import { defineAction } from "@agent-native/core/action";
export default defineAction({
description: "List calendar events",
schema: z.object({
from: z.string().describe("Start date"),
to: z.string().describe("End date"),
}),
http: { method: "GET" },
run: async (args) => {
return await fetchEvents(args.from, args.to);
},
});Write action (POST, default):
import { z } from "zod";
import { defineAction } from "@agent-native/core/action";
export default defineAction({
description: "Log a meal",
schema: z.object({
name: z.string().describe("Meal name"),
calories: z.coerce.number().describe("Calorie count"),
}),
run: async (args) => {
// args.calories is a number — z.coerce.number() handles string-to-number conversion from HTTP
const meal = await insertMeal(args);
return meal;
},
});Agent-only action:
import { z } from "zod";
import { defineAction } from "@agent-native/core/action";
export default defineAction({
description: "Navigate the UI to a view",
schema: z.object({
view: z.string().describe("Target view"),
}),
http: false,
run: async (args) => {
await writeAppState("navigate", { command: "go", view: args.view });
return "Navigated";
},
});pnpm action foo-bar looks for actions/foo-bar.ts.--key value or --key=value format. Boolean flags use --flag (sets value to "true").http.method doesn't match the hook. Use useActionQuery for GET actions, useActionMutation for POST/PUT/DELETE.JSON.stringify().pnpm action <name>c1ee18b
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.