Skip to content

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. CLI support comes for free. MCP tool exposure comes for free. Same validation, same error handling, same response shape — everywhere.

A Simple Example

ts
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

PropertyTypeWhat it does
namestringUnique identifier (e.g., "user:create")
descriptionstringHuman-readable description, shows up in CLI --help and Swagger
inputsz.ZodTypeZod 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
middlewareActionMiddleware[]Runs before/after the action (auth, logging, etc.)
mcpMcpActionConfigControls MCP exposure: tool, resource, and/or prompt (tool enabled by default)
timeoutnumberPer-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.

ts
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:

ts
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 schema
  • ActionResponse<A> infers the return type of an action's run() method
ts
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:

ts
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 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.

ts
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).

ts
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 with Content-Type: text/event-stream, Cache-Control: no-cache. Use send(data, { event?, id? }) to emit events and close() 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 — tells Swagger to document the endpoint as text/event-stream instead of JSON.
  • Compression is skipped for SSE responses automatically.
  • Connection cleanup is deferred until the stream closes, so sessions and middleware state remain valid during streaming.

Transport Behavior

TransportBehavior
HTTPNative SSE / chunked streaming
WebSocketIncremental messages with { streaming: true, chunk }, then { streaming: false }
MCPChunks 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:

bash
./keryx.ts "user:create" --name evan --email "evan@example.com" --password secret -q | jq

The -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, every action is automatically exposed as an MCP tool. AI agents can discover and call your actions through the Model Context Protocol — no extra configuration needed.

To exclude an action from MCP tools, set mcp = { tool: false }. 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:

ts
task = { queue: "default", frequency: 1000 * 60 * 60 }; // every hour
  • queue — which Resque queue to use
  • frequency — 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:

ts
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:

ts
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:

ts
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.

Released under the MIT License.