A Helix function is a TypeScript file that default-exports one of five define* helpers from @trayai/helix-sdk. The file’s location and suffix determine its trigger and route, and named exports (input, output, access, tool, rateLimit) are read by the runtime for validation, access control, and MCP metadata. This page gives exact signatures, every option with its default, and a summary of the routing rules.
| Helper | Trigger | Location |
|---|---|---|
defineFunction | Incoming HTTP request | Anywhere routable under functions/ |
defineSchedule | Cron schedule | functions/_scheduled/ |
defineQueueConsumer | Queue message | functions/_queues/<queue-name>.ts |
defineAppTrigger | Webhook from a third-party app via Helix | Routable, for example functions/webhooks/ |
defineMiddleware | Runs before route handlers | _middleware.ts at any directory level |
defineFunction
Handles one HTTP method (or all methods, for files without a method suffix). Generics give compile-time types; the named exports give runtime validation. You write each schema once and use it for both.
// @trayai/helix-sdk
// Sentinel type: use instead of `undefined` when skipping input
type NoInput = typeof NoInput;
// No validation, bare handler
function defineFunction(
handler: (ctx: FunctionContext) => any
): FunctionDef;
// Input only
function defineFunction<TInput extends z.ZodType>(
handler: (ctx: FunctionContext & { input: z.infer<TInput> }) => any
): FunctionDef;
// Input + output (use NoInput to skip input validation)
function defineFunction<
TInput extends z.ZodType | NoInput,
TOutput extends z.ZodType,
>(
handler: (ctx: FunctionContext & {
input: TInput extends z.ZodType ? z.infer<TInput> : any;
}) => MaybePromise<z.infer<TOutput>>
): FunctionDef;
The first generic types ctx.input from the input export; the second types the return value from the output export. NoInput fills the first slot when a function validates output only:
// functions/api/v1/stats.get.ts
import { z } from 'zod';
import { defineFunction, NoInput } from '@trayai/helix-sdk';
export const output = z.object({
totalUsers: z.number(),
activeToday: z.number(),
});
export default defineFunction<NoInput, typeof output>(async (ctx) => {
return { totalUsers: 1234, activeToday: 56 };
});
A typical function with input and output validation:
// functions/api/v1/users.post.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
import { users } from '@project/db/schema';
export const input = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']).default('member'),
});
export default defineFunction<typeof input>(async (ctx) => {
const [user] = await ctx.db().insert(users).values(ctx.input).returning();
ctx.status(201);
return user;
});
Return an object and the runtime sends it as JSON with status 200. Use ctx.status() to change the status, throw ctx.error(status, message) for errors, or return a Response object for full control (custom headers, non-JSON bodies, streaming).
defineSchedule
Runs on a cron schedule. Files live in functions/_scheduled/ and are never routable. A schedule is active whenever its file exists; there is no enabled flag, so remove or rename the file to disable it.
// functions/_scheduled/daily-report.ts
import { defineSchedule } from '@trayai/helix-sdk';
export default defineSchedule({
cron: '0 9 * * MON-FRI',
timezone: 'Europe/London',
concurrent: false,
handler: async (ctx) => {
ctx.log.info('Report run', { scheduledAt: ctx.trigger.scheduledAt });
},
});
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
cron | string | Yes | none | Five-field cron expression: minute, hour, day, month, weekday. |
timezone | string | No | UTC | IANA timezone, for example 'Europe/London'. |
concurrent | boolean | No | false | When false, a trigger is skipped (with a logged warning) if the previous execution is still running. Set true when overlapping runs are safe. |
handler | function | Yes | none | Receives ctx with all platform services but no request data: ctx.input and ctx.method are not available. |
ctx.trigger carries scheduledAt (when the cron said to fire), firedAt (when it actually ran), isRetry, and isManual (set by helix trigger _scheduled/<name>).
defineQueueConsumer
Consumes messages from a queue declared in helix.config.ts. The filename selects the queue: functions/_queues/order-processing.ts consumes from the order-processing queue. Returning from the handler acknowledges the message; throwing returns it to the queue for retry, and messages that exhaust all retries move to the dead letter queue.
// functions/_queues/order-processing.ts
import { z } from 'zod';
import { defineQueueConsumer } from '@trayai/helix-sdk';
export const input = z.object({
orderId: z.string(),
action: z.enum(['fulfill', 'cancel', 'refund']),
});
export default defineQueueConsumer<typeof input>({
retries: 3,
backoff: 'exponential',
backoffDelay: 1000,
timeout: 30_000,
concurrency: 5,
handler: async (ctx) => {
ctx.log.info('Processing', {
orderId: ctx.input.orderId,
attempt: ctx.message.attempt,
});
},
});
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
retries | number | No | 3 | Maximum retry attempts after a failed execution. |
backoff | 'fixed' | 'exponential' | No | 'exponential' | Retry delay strategy. |
backoffDelay | number (ms) | No | 1000 | Base delay between retries. Doubles each retry with exponential. |
timeout | number (ms) | No | 30000 | Maximum execution time per message. |
concurrency | number | No | 1 | Maximum parallel invocations. With fifo: true this is concurrency across groups; each group stays serial. |
rateLimit | { maxPerSecond?: number } | No | off | Throttles how many new messages start per second, independent of concurrency. |
fifo | boolean | No | false | Processes messages with the same groupId in publish order, one in flight per group. |
handler | (ctx) => Promise<void> | Yes | none | Receives the standard context plus ctx.input (the validated payload) and ctx.message. |
The optional input export validates the message payload with Zod before the handler runs. ctx.message carries id, queue, attempt (1-based), maxAttempts, publishedAt, and metadata from the publisher. The platform enforces concurrency, rate limits, and FIFO ordering; handler code never implements them.
defineAppTrigger
Reacts to events in third-party apps. Helix registers and manages the webhook in the external system through its trigger API; your function runs when the event fires.
// functions/webhooks/shopify.ts
import { defineAppTrigger } from '@trayai/helix-sdk';
export default defineAppTrigger({
connector: 'shopify',
event: 'order.created',
authentication: 'shopify_prod',
handler: async (ctx) => {
const order = ctx.trigger.data;
ctx.log.info('New order', { id: order.id });
},
});
| Option | Type | Required | Description |
|---|---|---|---|
connector | string | Yes | Connector name from the Helix connector catalog, for example 'shopify'. |
event | string | Yes | Event to subscribe to, for example 'order.created'. |
authentication | string | Yes | Auth alias from helix.config.ts used to register the webhook. |
handler | function | Yes | Receives the event payload on ctx.trigger.data. |
You don’t define an input schema for app triggers: the payload shape comes from the Helix connector catalog. Run helix generate-types to pull the catalog schemas and generate TypeScript types for ctx.trigger.data.
defineMiddleware
Files named _middleware.ts apply to all routes at and below their directory. They follow the standard (ctx, next) pattern: run code, call await next() to pass control down, then run code after the response.
// functions/api/v1/_middleware.ts
import { defineMiddleware } from '@trayai/helix-sdk';
export default defineMiddleware(async (ctx, next) => {
const apiKey = ctx.headers.get('x-api-key');
if (!apiKey) throw ctx.error(401, 'API key required');
ctx.state.apiKey = apiKey; // visible to downstream handlers
await next();
});
Middleware nests by directory: functions/_middleware.ts runs first, then functions/api/v1/_middleware.ts, then the handler. Throwing before next() short-circuits the request.
Named exports read by the runtime
| Export | When it runs | On failure |
|---|---|---|
input | Before the handler; merged request data is validated | 400 |
output | After the handler; the return value is validated | 500 |
access | Before the handler; role check via Helix Identity | 403 |
tool | Read at build and discovery time; MCP tool metadata | n/a |
rateLimit | Enforced by the MCP service per tool call | Call rejected |
If a file declares none of these, no validation occurs and the handler receives raw, unvalidated data.
input
A Zod schema validating everything coming into the function. The runtime merges three sources into one object, validates it, and puts the result on ctx.input:
Path params { userId: "abc-123" }
Query params { page: "2" }
Request body { name: "Alice" }
Merged { userId: "abc-123", page: "2", name: "Alice" }
Merge priority on key conflicts: path params beat body, body beats query params. Query params arrive as strings, so use z.coerce.number() where numbers are expected. Validation failure returns 400 before the handler runs.
output
A Zod schema validating the handler’s return value after it completes. Failure returns 500, since an invalid response indicates a bug in the function, not the caller. The output schema is not applied to streaming responses: a Response with a ReadableStream body can’t be validated before it has been produced, so validation only runs on plain objects and JSON.
access
Restricts an HTTP function by role. Requires Helix Identity; the runtime enforces the rule before the handler runs and rejects unauthorized requests with 403 and a JSON body of the form { "error": "forbidden", "message": "This endpoint requires one of the following roles: admin" }.
// functions/api/v1/settings.put.ts
export const access = {
allowRoles: ['admin'],
};
| Field | Type | Behavior |
|---|---|---|
allowRoles | string[] | Allowed only if the user holds at least one listed role. |
denyRoles | string[] | Rejected if the user holds any listed role. |
Evaluation rules:
- Deny wins.
denyRolesis checked first; a user holding a denied role is rejected even if they also hold an allowed role. - No
accessexport means no role restriction: any authenticated user can call the function. allowRoles: []is a build-time error (it would reject everyone); omit the field instead.- Role IDs are validated against the
rolesmap inhelix.config.tsat build time. An undefined role failshelix deployand surfaces as an error inhelix dev. accessapplies to HTTP functions only. Schedules, app triggers, and queue consumers run without a user, so anaccessexport in those files is a build-time error. So isaccesson a route excluded from authentication, wherectx.identityisnull.
access rules are enforced in helix dev exactly as in production: same 403, same body.
tool
MCP metadata for a function exposed as a tool. The function must also be listed in the mcp.tools array in helix.config.ts; see the helix.config.ts reference. Tool files don’t need method suffixes, since tools are invoked over MCP, not HTTP.
// functions/tools/lookup-customer.ts
import { z } from 'zod';
export const input = z.object({
email: z.string().email(),
});
export const tool = {
name: 'lookup_customer',
description: 'Look up customer information by email address',
inputSchema: input,
};
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Tool name shown to agents; snake_case by convention. |
description | string | Yes | What the tool does and when to use it. Agents choose tools by this text, so be specific. |
inputSchema | Zod schema | Yes | Converted to JSON Schema for MCP discovery. Usually the same schema as the input export. |
examples | { input, output }[] | No | Sample calls that show agents expected inputs and outputs. |
streaming | boolean | No | When true, the handler can push progress updates with ctx.stream() before returning its final result. |
rateLimit
Per-function override of the MCP server’s rate limit for this tool:
// functions/tools/expensive-operation.ts
export const rateLimit = {
requestsPerMinute: 5,
requestsPerHour: 50,
};
| Field | Type | Description |
|---|---|---|
requestsPerMinute | number | Maximum invocations of this tool per minute. |
requestsPerHour | number | Maximum invocations of this tool per hour. |
Routing rules
The file system is the router: file path becomes URL path, file suffix becomes HTTP method, and directory nesting creates path segments.
| Rule | Behavior |
|---|---|
Method suffix (.get.ts, .post.ts, .put.ts, .patch.ts, .delete.ts) | The file handles that HTTP method only. functions/api/v1/users.post.ts handles POST /api/v1/users. |
No method suffix (.ts) | Catch-all: handles every method on the path. Branch on ctx.method. Prefer method-specific files except for webhooks. |
[param] in a file or folder name | Dynamic segment. functions/api/v1/users/[userId].get.ts handles GET /api/v1/users/:userId; the value arrives merged into ctx.input. |
Underscore prefix (_) | Excluded from routing: _shared/ (importable utilities), _scheduled/ (cron functions), _queues/ (queue consumers), _middleware.ts (middleware). |
| Defined path, undefined method | Automatic 405 Method Not Allowed. If only users.get.ts and users.post.ts exist, DELETE /api/v1/users returns 405. |
users.get.ts and users/index.get.ts both exist | index.get.ts takes priority. |
| Route priority | Function routes match first, then static files from app/public/ or built SPA assets, then the SPA fallback (app/index.html). Functions always win over SPA routes. |