Every file in functions/ is one HTTP endpoint. The file path sets the URL, the method suffix (.get.ts, .post.ts) sets the HTTP verb, and optional Zod schemas validate input and output. There is no route table to maintain: the file system is the routing configuration.
File-based routing
| File | Method | URL |
|---|---|---|
functions/health.get.ts | GET | /health |
functions/api/v1/users.get.ts | GET | /api/v1/users |
functions/api/v1/users.post.ts | POST | /api/v1/users |
functions/api/v1/users/[userId].get.ts | GET | /api/v1/users/:userId |
functions/webhooks/stripe.post.ts | POST | /webhooks/stripe |
functions/status.ts | Any | /status |
Directory nesting creates path segments. [paramName] in a folder or file name creates a dynamic segment: [userId] becomes :userId in the route, and the value arrives in ctx.input alongside the query params and body.
If both users.get.ts and users/index.get.ts exist, index.get.ts takes priority.
Method suffixes
.get.ts,.post.ts,.put.ts,.patch.ts, and.delete.tseach handle only that verb.- A file with no method suffix (
status.ts) is a catch-all: it handles every HTTP method. Branch onctx.methodinside the handler.
Requests to a defined path with an undefined method return 405 Method Not Allowed automatically. If only users.get.ts and users.post.ts exist, DELETE /api/v1/users returns 405 without any code from you.
// functions/webhooks/stripe.ts, all methods on /webhooks/stripe
import { defineFunction } from '@trayai/helix-sdk';
export default defineFunction(async (ctx) => {
if (ctx.method !== 'POST') throw ctx.error(405, 'POST only');
// Process the webhook...
return { received: true };
});
Files that never become routes
Anything starting with an underscore is excluded from routing:
| Path | Purpose |
|---|---|
_shared/ | Utilities importable by other functions |
_scheduled/ | Cron-triggered functions in the platform design, not available today |
_middleware.ts | Runs before route handlers, see middleware |
Files inside underscore-prefixed directories are never exposed as HTTP endpoints. Only .ts files at the top level of a routable directory become routes.
Route priority
When a request arrives, the dev server and the production runtime resolve it in the same order:
- Function routes (static segments before dynamic ones)
- Static files from
app/public/or built SPA assets - SPA fallback:
app/index.htmlfor client-side routing
Functions always win over SPA routes. See the frontend guide for how the SPA side works.
Defining a function
The simplest function takes a context and returns a value:
// functions/health.get.ts, GET /health
import { defineFunction } from '@trayai/helix-sdk';
export default defineFunction(async (ctx) => {
return { status: 'ok', timestamp: Date.now() };
});
HTTP is the only trigger a function can have today, so defineFunction is the only function type you can deploy. The platform design adds two more: defineSchedule on a cron timer and defineAppTrigger for webhooks from third-party apps via Helix connectors (see the function reference). Neither is in the shipped product.
Validating input
Export a Zod schema named input and the runtime validates every request before your handler runs. Pass the same schema as a generic and ctx.input is fully typed. You write the schema once: the named export drives runtime validation, the generic drives compile-time types.
// functions/api/v1/users.post.ts, POST /api/v1/users
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
// Named export: the runtime validates the merged request against this
export const input = z.object({
name: z.string().min(1),
email: z.string().email(),
role: z.enum(['admin', 'member', 'viewer']).default('member'),
});
// Generic: types ctx.input
export default defineFunction<typeof input>(async (ctx) => {
const kv = ctx.kv();
const user = {
id: crypto.randomUUID(),
...ctx.input,
createdAt: new Date().toISOString(),
};
await kv.setItem(`user:${user.id}`, user);
// The store cannot list keys, so keep your own index
const index = (await kv.getItem<string[]>('user:index')) ?? [];
await kv.setItem('user:index', [...index, user.id]);
ctx.status(201);
return user;
});
Validation failures return 400 before the handler runs. If no input export is present, no validation occurs and ctx.input contains the raw merged data.
How input is merged
The runtime merges three sources into one object, then validates it against the input schema:
Path params → { userId: "abc-123" }
Query params → { page: "2", limit: "10" }
Request body → { name: "Alice" }
Merged input → { userId: "abc-123", page: "2", limit: "10", name: "Alice" }
After Zod → { userId: "abc-123", page: 2, limit: 10, name: "Alice" }
On key conflicts the priority is path params > body > query params. Path params always win because the URL structure defines them. Query params arrive as strings, so use z.coerce.number() for numeric ones.
One schema can cover path params and body fields together:
// functions/api/v1/users/[userId].put.ts, PUT /api/v1/users/:userId
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
export const input = z.object({
userId: z.string().uuid(), // from the [userId] path param
name: z.string().min(1).optional(), // from the request body
email: z.string().email().optional(), // from the request body
role: z.enum(['admin', 'member', 'viewer']).optional(), // from the 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;
});
Validating output
Export a schema named output and the runtime validates the return value after your handler completes. Pass it as the second generic to type the return value. A failure returns 500, since a response that breaks its own contract is a bug.
// functions/api/v1/users.get.ts, GET /api/v1/users?page=1&limit=20
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
export const input = z.object({
page: z.coerce.number().default(1),
limit: z.coerce.number().default(20),
});
export const output = z.array(z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string(),
role: z.string(),
createdAt: z.string().datetime(),
}));
type User = z.infer<typeof output>[number];
export default defineFunction<typeof input, typeof output>(async (ctx) => {
const kv = ctx.kv();
const index = (await kv.getItem<string[]>('user:index')) ?? [];
const start = (ctx.input.page - 1) * ctx.input.limit;
const ids = index.slice(start, start + ctx.input.limit);
const users = await Promise.all(ids.map((id) => kv.getItem<User>(`user:${id}`)));
return users.filter((user): user is User => user !== null);
});
To validate output without validating input, use the NoInput sentinel as the first generic:
// functions/api/v1/stats.get.ts, GET /api/v1/stats
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 };
});
Named exports the runtime reads
| Named export | Behavior |
|---|---|
export const input = z.object(...) | Path params, query params, and body merged, then validated before the handler runs. 400 on failure. |
export const output = z.object(...) | Return value validated after the handler completes. 500 on failure. |
export const access = { allowRoles, denyRoles } | Platform design, not shipped. App access is a dashboard setting today, see identity and roles. |
Sending responses
Return a plain object and the runtime sends it as JSON with a 200 status:
// functions/hello.get.ts
import { defineFunction } from '@trayai/helix-sdk';
export default defineFunction(async (ctx) => {
return { message: 'hello' };
// 200 OK, Content-Type: application/json, body: {"message":"hello"}
});
For anything beyond the default:
ctx.status(code)sets the response status while still returning a value, for examplectx.status(201)after a create, orctx.status(204)with no return value after a delete.throw ctx.error(status, message)short-circuits the handler with an error response, for examplethrow ctx.error(404, 'User not found').- Return a
Responsefor full control over status, headers, and body:
// functions/api/v1/reports/export.get.ts, CSV download
import { defineFunction } from '@trayai/helix-sdk';
export default defineFunction(async (ctx) => {
const csv = 'id,name\n1,Ada\n2,Grace\n';
return new Response(csv, {
status: 200,
headers: {
'Content-Type': 'text/csv',
'Content-Disposition': 'attachment; filename="report.csv"',
},
});
});
Returning a Response with a ReadableStream body streams it to the client. See the streaming guide.
A complete example
A users API with full CRUD is five files. Three appear above (users.post.ts, users.get.ts, [userId].put.ts); here are the remaining two.
functions/
└── api/v1/
├── users.get.ts GET /api/v1/users
├── users.post.ts POST /api/v1/users
└── users/
├── [userId].get.ts GET /api/v1/users/:userId
├── [userId].put.ts PUT /api/v1/users/:userId
└── [userId].delete.ts DELETE /api/v1/users/:userId
There is no schema file and no migration step: records are JSON values in the project-scoped key-value store, and the user:index key holds the ids because the store cannot list keys for you.
// functions/api/v1/users/[userId].get.ts, GET /api/v1/users/:userId
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
export const input = z.object({
userId: z.string().uuid(),
});
export default defineFunction<typeof input>(async (ctx) => {
const user = await ctx.kv().getItem(`user:${ctx.input.userId}`);
if (!user) throw ctx.error(404, 'User not found');
return user;
});
// functions/api/v1/users/[userId].delete.ts, DELETE /api/v1/users/:userId
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';
export const input = z.object({
userId: z.string().uuid(),
});
export default defineFunction<typeof input>(async (ctx) => {
const kv = ctx.kv();
const { userId } = ctx.input;
await kv.removeItem(`user:${userId}`);
const index = (await kv.getItem<string[]>('user:index')) ?? [];
await kv.setItem('user:index', index.filter((id) => id !== userId));
ctx.status(204);
});
Any other method on these paths returns 405 automatically. Requests with a malformed userId return 400 before either handler runs.