# Functions and routing

> Define HTTP endpoints as files: the path sets the URL, the suffix sets the method, and Zod schemas validate input and output.

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.ts` each handle only that verb.
- A file with no method suffix (`status.ts`) is a catch-all: it handles every HTTP method. Branch on `ctx.method` inside 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.

```typescript
// 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 };
});
```

:::tip
Prefer method-specific files. Use a catch-all only for webhooks or the rare handler that genuinely needs method branching in one place.
:::

### 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](/documentation/guides/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:

1. Function routes (static segments before dynamic ones)
2. Static files from `app/public/` or built SPA assets
3. SPA fallback: `app/index.html` for client-side routing

Functions always win over SPA routes. See [the frontend guide](/documentation/guides/frontend/) for how the SPA side works.

## Defining a function

The simplest function takes a context and returns a value:

```typescript
// 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](/documentation/reference/functions/)). 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.

```typescript
// 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:

```typescript
// 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.

```typescript
// 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:

```typescript
// 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](/documentation/guides/identity-and-roles/). |

## Sending responses

Return a plain object and the runtime sends it as JSON with a 200 status:

```typescript
// 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 example `ctx.status(201)` after a create, or `ctx.status(204)` with no return value after a delete.
- **`throw ctx.error(status, message)`** short-circuits the handler with an error response, for example `throw ctx.error(404, 'User not found')`.
- **Return a `Response`** for full control over status, headers, and body:

```typescript
// 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](/documentation/guides/streaming/).

## 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](/documentation/guides/key-value-store/), and the `user:index` key holds the ids because the store cannot list keys for you.

```typescript
// 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;
});
```

```typescript
// 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.

---

Canonical: https://helix.tray.ai/documentation/guides/functions-and-routing/
Any link on this page is available as markdown by appending .md to its URL.
Full corpus: https://helix.tray.ai/documentation/llms-full.txt