HTTP-triggered functions read their request through ctx.input (validated, merged input), ctx.method, ctx.headers, ctx.path, and ctx.request (raw body access). They respond by returning a value, setting ctx.status(), or throwing ctx.error(). Non-HTTP function types receive their payload on ctx.trigger instead.
ctx.input
ctx.input is one object merging path params, query params, and the request body. When the file has a named export input (a Zod schema), the runtime validates the merged object before the handler runs and returns 400 on failure; the generic defineFunction<typeof input> types ctx.input from the same schema. With no schema, ctx.input holds the raw merged object, unvalidated.
Merge priority on key conflicts: path params > body > query params. Path params always win because the URL structure defines them.
| Source | Example |
|---|---|
| Path params | { userId: "abc-123" } |
| Query params | { page: "2", limit: "10" } |
| Request body | { name: "Alice" } |
| Merged, then validated | { userId: "abc-123", page: 2, limit: 10, name: "Alice" } |
Query params arrive as strings; use z.coerce.number() for numeric fields. A named output export validates the return value after the handler completes (500 on failure); it is skipped for streaming responses.
ctx.method, ctx.headers, ctx.path
| Field | Type | Description |
|---|---|---|
method | string | The HTTP method. Branch on it in catch-all files (no method suffix). |
headers | Headers | Standard web Headers; read with ctx.headers.get('x-api-key'). |
path | string | The request path. |
ctx.request
Raw request access for advanced handling:
request: {
isMultipart: boolean; // Content-Type is multipart/form-data
isRaw: boolean; // body is not JSON or form data
files: () => Promise<UploadedFile[]>; // parsed multipart file parts
buffer: () => Promise<ArrayBuffer>; // whole body as bytes
text: () => Promise<string>; // whole body as text
signal: AbortSignal; // fires when the client disconnects
}
Each uploaded file exposes its form fieldname, buffer, mimetype, size, and extension, so a handler can read an upload in memory and act on it. Helix ships no file storage to persist it to: ctx.files is platform design. signal is the cleanup hook for long-lived streams; see the streaming guide.
ctx.status() and ctx.error()
status(code: number): void
error(status: number, message: string): HttpError
ctx.status(code) sets the response status for the returned value (for example 201 after a create, or 204 with no return value). ctx.error(status, message) builds an HttpError for you to throw; the runtime turns it into the corresponding HTTP response.
ctx.state
ctx.state is a Record<string, any> for middleware-to-handler handoff. Middleware in functions/_middleware.ts (or a nested _middleware.ts) writes to it before calling next(); the handler reads it. See the functions and routing guide for middleware ordering.
// functions/api/v1/_middleware.ts sets ctx.state.apiKey; the handler reads it:
const apiKey = ctx.state.apiKey;
ctx.trigger by function type
defineFunction is the only function type in the shipped product, because HTTP is the only trigger. The other three rows describe platform design.
| Function type | ctx.trigger contents |
|---|---|
defineFunction | Request data lives on ctx.input, ctx.method, ctx.headers, and ctx.request; trigger.data and trigger.scheduledAt are unavailable. |
defineSchedule | { type: 'schedule', scheduledAt, firedAt, isRetry, isManual }. scheduledAt is when the cron said to fire, firedAt when it actually ran, isRetry marks a retried execution, isManual marks a helix trigger run. |
defineAppTrigger | { type: 'app', data } where data is the webhook payload, typed from the connector catalog via helix generate-types. |
defineQueueConsumer | No trigger data; the payload is ctx.input and delivery metadata is ctx.message. |
Returning objects vs Response
Return a plain object and the runtime wraps it as JSON with status 200 (or whatever ctx.status() set). Return a Response for full control over status, headers, and body, including streaming bodies (ReadableStream), which work locally and in production via Lambda response streaming.
// functions/api/v1/users/[userId].put.ts
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
export const input = z.object({
userId: z.string().uuid(), // from path param [userId]
name: z.string().min(1).optional(), // from request body
email: z.string().email().optional(), // from request body
});
export default defineFunction<typeof input>(async (ctx) => {
const { userId, ...updates } = ctx.input;
const kv = ctx.kv();
const user = await kv.getItem<Record<string, unknown>>(`user:${userId}`);
if (!user) throw ctx.error(404, 'User not found');
const updated = { ...user, ...updates };
await kv.setItem(`user:${userId}`, updated);
return updated; // 200 OK, JSON
});
And with a Response for a non-JSON body:
return new Response(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="report.csv"',
},
});
SSE patterns, transform streams, and platform limits for streamed responses are covered in the streaming guide.