Actions
If there's one idea that defines bun-actionhero, it's this: actions are the universal controller. In the original ActionHero, we had actions, tasks, and CLI commands as separate concepts. That always felt like unnecessary duplication — you'd write the same validation logic three times for three different entry points. So in this version, we've collapsed them all into one thing.
An action is a class with a name, a Zod schema for inputs, and a run() method that returns data. You add a web property to make it an HTTP endpoint. You add a task property to make it a background job. CLI support comes for free. Same validation, same error handling, same response shape — everywhere.
A Simple Example
import { z } from "zod";
import { Action, api } from "../api";
import { HTTP_METHOD } from "../classes/Action";
export class Status implements Action {
name = "status";
description = "Return the status of the server";
inputs = z.object({});
web = { route: "/status", method: HTTP_METHOD.GET };
async run() {
return {
name: api.process.name,
uptime: new Date().getTime() - api.bootTime,
};
}
}That's a fully functioning HTTP endpoint, CLI command, and WebSocket handler. Hit GET /api/status from a browser, run ./actionhero.ts status -q | jq from the terminal, or send { action: "status" } over a WebSocket — same action, same response.
Properties
| Property | Type | What it does |
|---|---|---|
name | string | Unique identifier (e.g., "user:create") |
description | string | Human-readable description, shows up in CLI --help and Swagger |
inputs | z.ZodType | Zod schema — validation happens automatically |
web | { route, method } | HTTP routing. Routes are strings with :param placeholders or RegExp patterns |
task | { queue, frequency? } | Makes this action schedulable as a background job |
middleware | ActionMiddleware[] | Runs before/after the action (auth, logging, etc.) |
Input Validation
Inputs use Zod schemas. If validation fails, the client gets a 422 with the validation errors — you don't need to write any error handling for bad inputs.
inputs = z.object({
name: z.string().min(3).max(256),
email: z
.string()
.email()
.transform((val) => val.toLowerCase()),
password: secret(z.string().min(8)),
});Secret Fields
You can mark sensitive fields with the secret() wrapper so they're redacted as [[secret]] in logs. Don't log passwords — use this:
import { secret } from "../util/zodMixins";
inputs = z.object({
password: secret(z.string().min(8)),
});Type Helpers
Two type helpers make your life easier:
ActionParams<A>infers the validated input type from an action's Zod schemaActionResponse<A>infers the return type of an action'srun()method
async run(params: ActionParams<UserCreate>) {
// params.name, params.email, params.password — all typed
}The frontend uses ActionResponse<A> to get type-safe API responses without any code generation.
Web Routes
Add a web property to expose an action as an HTTP endpoint:
web = { route: "/user/:id", method: HTTP_METHOD.GET };Routes support :param path parameters (like Express) and can also be RegExp patterns. There's no separate routes.ts file — the route lives on the action itself, right next to the handler that serves it.
Available methods: GET, POST, PUT, DELETE, PATCH, OPTIONS.
CLI Commands
Every action is automatically available as a CLI command. No extra configuration needed:
./actionhero.ts "user:create" --name evan --email "evan@example.com" --password secret -q | jqThe -q flag suppresses server logs so you can pipe the JSON output cleanly. Use --help on any action to see its parameters.
Task Scheduling
Add a task property to schedule an action as a recurring background job:
task = { queue: "default", frequency: 1000 * 60 * 60 }; // every hourqueue— which Resque queue to usefrequency— optional interval in ms for recurring execution
See Tasks for the full story on background processing and the fan-out pattern.
Error Handling
Actions should throw TypedError for errors — not generic Error. Each error type maps to an HTTP status code:
import { ErrorType, TypedError } from "../classes/TypedError";
throw new TypedError({
message: "User not found",
type: ErrorType.CONNECTION_ACTION_RUN, // → 400
});Some common mappings: ACTION_VALIDATION → 422, CONNECTION_SESSION_NOT_FOUND → 401, CONNECTION_ACTION_NOT_FOUND → 404.
Registration
New actions need to be re-exported from backend/actions/.index.ts. This is how the frontend gets type information about your API — it imports from that barrel file to power ActionResponse<A> on the client side.