Actions
If there's one idea that defines Keryx, 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. You add mcp = { tool: true } to make it an MCP tool. CLI support comes for free. Same validation, same error handling, same response shape — everywhere.
A Simple Example
import { z } from "zod";
import { Action, api, HTTP_METHOD } from "keryx";
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 ./keryx.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.) |
mcp | McpActionConfig | Controls MCP exposure: tool, resource, and/or prompt (tools are opt-in) |
timeout | number | Per-action timeout in ms (overrides config.actions.timeout; 0 disables) |
Input Validation
Inputs use Zod schemas. If validation fails, the client gets a 406 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 "keryx";
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.
Raw Request Bodies
For HTTP connections, connection.rawRequest is the underlying Request — read headers, the URL, or the method straight off it:
async run(params: ActionParams<Webhook>, connection: Connection) {
const signature = connection.rawRequest?.headers.get("x-signature");
}The body is a different story. Keryx reads it to build params, so by the time run() is called it's already consumed. When you need the bytes exactly as they arrived — proxying a request upstream, verifying a webhook signature over the payload, accepting a binary upload — set web.rawBody and Keryx won't touch the body at all:
export class ProxyUpstream implements Action {
name = "proxy";
web = {
route: "/proxy/:target",
method: HTTP_METHOD.POST,
rawBody: true,
};
inputs = z.object({ target: z.string() });
async run(params: ActionParams<ProxyUpstream>, connection: Connection) {
// The body is untouched — stream it upstream without buffering it here
return fetch(`https://${params.target}/v1/messages`, {
method: "POST",
headers: { authorization: `Bearer ${process.env.UPSTREAM_KEY}` },
body: connection.rawRequest!.body,
// @ts-expect-error — Bun supports duplex streaming request bodies
duplex: "half",
});
}
}Three things follow from rawBody: true:
paramscome from path and query params only. Nothing is merged in from the body, so a body key can't shadow a path param — which matters when that path segment is a routing target or a credential.- You own the body. Read it once, with whatever's appropriate:
.text(),.json(),.arrayBuffer(),.formData(), or.bodyfor aReadableStreamyou never buffer. - You own the size limit past the headers.
config.server.web.maxBodySizeis still enforced againstContent-Length, but Keryx isn't reading the stream, so it can't stop a chunked upload that lies about its size. Enforce that yourself if you accept chunked bodies.
Content types are matched on the base media type, so application/json and application/json; charset=utf-8 behave identically — the charset doesn't change whether a body gets parsed into params.
rawRequest only exists for HTTP — it's undefined over WebSocket, CLI, background tasks, and MCP, where there's no request to speak of. An action built around raw bytes is an HTTP endpoint first; guard on connection.rawRequest if the same action can be reached another way.
Swagger documents a rawBody endpoint's request body as opaque bytes (*/*), and any Zod inputs that aren't path params as query params — because that's where they have to come from.
Raw Response Passthrough
Sometimes you need full control over the HTTP response — file downloads, image serving, streaming, redirects. For those cases, return a Response object directly from run() and the framework passes it through unchanged, skipping JSON serialization entirely.
export class FileDownload implements Action {
name = "file:download";
middleware = [SessionMiddleware];
web = { route: "/file/:id/download", method: HTTP_METHOD.GET };
inputs = z.object({ id: z.string() });
async run(params: ActionParams<FileDownload>) {
const file = await getFileContent(params.id);
return new Response(file.buffer, {
headers: {
"Content-Type": file.mimeType,
"Content-Disposition": `attachment; filename="${file.name}"`,
},
});
}
}Your action still benefits from Keryx's routing, middleware, session handling, and observability — all of that runs before run() is called. But the response itself is yours. Keryx's standard headers (CORS, security headers, session cookie) are not added to raw responses — you set your own headers on the Response you return.
This only applies to HTTP. WebSocket, CLI, and background task transports still expect JSON-serializable return values from run().
Streaming Responses
For Server-Sent Events (SSE), LLM streaming, or chunked binary transfers, return a StreamingResponse from run(). Unlike raw Response passthrough, streaming responses still get Keryx's standard headers (CORS, security, session cookie).
import { Action, HTTP_METHOD, StreamingResponse } from "keryx";
export class ChatStream implements Action {
name = "chat:stream";
description = "Stream an LLM response via SSE";
web = { route: "/chat/stream", method: HTTP_METHOD.POST, streaming: true };
timeout = 0; // disable timeout for long-running streams
async run(params: { prompt: string }) {
const sse = StreamingResponse.sse();
(async () => {
try {
for await (const token of callLLM(params.prompt)) {
sse.send(token, { event: "token" });
}
sse.send({ done: true }, { event: "done" });
} catch (e) {
sse.sendError(String(e));
} finally {
sse.close();
}
})();
return sse;
}
}Key points:
StreamingResponse.sse()— SSE withContent-Type: text/event-stream,Cache-Control: no-cache. Usesend(data, { event?, id? })to emit events andclose()to end the stream.StreamingResponse.stream(readableStream, { contentType? })— raw binary/chunked streaming for file downloads or proxied responses.timeout = 0— streaming actions should disable the action timeout.web.streaming = true— documents the endpoint astext/event-streamin Swagger, and tells the web server to treat the response as a stream: no compression, no idle timeout. Only needed when you return a rawResponsewrapping a stream; aStreamingResponsegets that treatment on its own.- Compression and the idle timeout are skipped for streaming responses automatically — SSE and chunked binary alike.
- Connection cleanup is deferred until the stream closes, so sessions and middleware state remain valid during streaming.
Transport Behavior
| Transport | Behavior |
|---|---|
| HTTP | Native SSE / chunked streaming |
| WebSocket | Incremental messages with { streaming: true, chunk }, then { streaming: false } |
| MCP | Chunks sent as logging messages; accumulated text returned as tool result |
See the dedicated Streaming guide for detailed examples and patterns.
CLI Commands
Every action is automatically available as a CLI command. No extra configuration needed:
./keryx.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. See the CLI guide for full details on flags, quiet mode, and error output.
MCP Tools
When the MCP server is enabled, actions can be exposed as MCP tools that AI agents discover and call through the Model Context Protocol. Tools are opt-in: set mcp = { tool: true } to publish an action. Actions with no mcp config are never exposed, so internal or destructive actions stay private by default.
Actions can also be registered as MCP resources or prompts via mcp.resource and mcp.prompt. See the MCP guide for full details.
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.
Timeouts
Every action execution is wrapped with a timeout (default: 5 minutes). If an action exceeds its timeout, the framework aborts it and returns an HTTP 408 error with type CONNECTION_ACTION_TIMEOUT.
The global default is set in config.actions.timeout (env: ACTION_TIMEOUT). You can override it per-action:
export class SlowReport extends Action {
name = "report:generate";
timeout = 600_000; // 10 minutes for this action
// ...
}Set timeout = 0 to disable the timeout for a specific action.
AbortSignal
When timeouts are enabled, run() receives an AbortSignal as its third argument. Long-running actions should check the signal or pass it to cancellable APIs:
async run(params: ActionParams<SlowReport>, connection?: Connection, abortSignal?: AbortSignal) {
const res = await fetch("https://slow-api.example.com/data", {
signal: abortSignal,
});
// ...
}If the action doesn't check the signal, the timeout still works — Promise.race() ensures the caller gets the timeout error immediately.
Error Handling
Actions should throw TypedError for errors — not generic Error. Each error type maps to an HTTP status code:
import { ErrorType, TypedError } from "keryx";
throw new TypedError({
message: "User not found",
type: ErrorType.CONNECTION_ACTION_RUN, // → 500
});Some common mappings: CONNECTION_ACTION_PARAM_VALIDATION → 406, CONNECTION_SESSION_NOT_FOUND → 401, CONNECTION_ACTION_NOT_FOUND → 404, CONNECTION_RATE_LIMITED → 429.
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.
Reference
Actionclass reference — every property, type helper, and option in one place- Servers — how an action is routed over HTTP, WebSocket, CLI, and MCP