references/ai-sdk.md
# AI SDK agents on Neon Functions
A Neon Function is a long-lived Node.js 24 process, which makes it a natural host for a [Vercel AI SDK](https://ai-sdk.dev) agent: the handler keeps streaming for the life of the request (15-minute budget, see [Timeouts](../SKILL.md#timeouts-and-runtime-limits)), so multi-step tool loops and image/video generation don't get cut off the way they do on lambda-style serverless. Point the model at the **Neon AI Gateway** (see the `neon-ai-gateway` skill) and there are no extra provider keys to manage — one Neon credential reaches the whole catalog.
The AI SDK is the **recommended** way to build agents on Functions from TypeScript: one set of primitives (`streamText`, `generateText`, tool calling, structured output) over every catalog model. For a memory- and workflow-heavy agent with built-in tracing, use Mastra instead (see [references/mastra-studio.md](mastra-studio.md)); both point at the same gateway.
The pattern below is a complete agent: it streams chat and, when asked, generates an image, uploads it to Object Storage, and indexes it in Postgres.
## 1. Declare the gateway and the function
The agent needs the AI Gateway (and, for the image example, an Object Storage bucket). Declare both in `neon.ts` alongside the function — `neon deploy` provisions them and injects the credentials at runtime (see the `neon-ai-gateway` and `neon-object-storage` skills):
```typescript
// neon.ts
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
aiGateway: true,
buckets: { images: {} },
functions: {
agent: { name: "ai agent", source: "src/index.ts" },
},
});
```
## 2. The handler: stream a tool-calling agent
The function's default export is a web-standard `{ fetch }` handler. The `@neon/ai-sdk-provider` reads the injected gateway credentials automatically, so `neon("<model>")` is all the model config you need — it routes each model to the right dialect (Anthropic → Messages, OpenAI/Codex → Responses, everything else → MLflow). Return `result.toUIMessageStreamResponse()` so the AI SDK's `useChat` hooks can consume the stream:
```typescript
// src/index.ts
import { neon } from "@neon/ai-sdk-provider";
import { attachDatabasePool } from "@neon/functions";
import { streamText, tool, stepCountIs, type ModelMessage } from "ai";
import { z } from "zod";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { todos } from "./db/schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);
const db = drizzle(pool);
export default {
async fetch(request: Request) {
if (request.method !== "POST") {
return new Response("POST chat messages here", { status: 405 });
}
const { messages } = (await request.json()) as { messages: ModelMessage[] };
const result = streamText({
model: neon("claude-sonnet-4-6"), // swap to gpt-5-mini, gemini-3-flash, …
system: "You are a concise assistant with access to the user's todos.",
messages,
tools: {
countOpenTodos: tool({
description: "Count the user's open todos.",
inputSchema: z.object({}),
execute: async () => ({ open: await db.$count(todos) }),
}),
},
// Let the model call tools and then summarize, instead of stopping after
// the first tool call. The loop runs in-process — no host timeout.
stopWhen: stepCountIs(5),
onError({ error }) {
console.error("[streamText] error:", error);
},
});
return result.toUIMessageStreamResponse({
onError: (error) =>
error instanceof Error ? error.message : String(error),
});
},
};
```
`tool({ inputSchema, execute })` is the AI SDK v5+ shape (the parameter is `inputSchema`, not the old `parameters`). The tool's `execute` runs **inside the function**, right next to Postgres — no extra network hop.
## 3. Generate images and persist them
The gateway exposes the OpenAI Responses **`image_generation`** built-in tool (GPT-5 models only; the image comes back inline as base64). Persist generated assets to Object Storage and index them in Postgres so they branch together — the **recommended** storage client is the Files SDK `neon` adapter (see the `neon-object-storage` skill):
```typescript
import { neon } from "@neon/ai-sdk-provider";
import { streamText } from "ai";
import { Files } from "files-sdk";
import { neon as neonFiles } from "files-sdk/neon";
import { randomUUID } from "node:crypto";
const files = new Files({ adapter: neonFiles({ bucket: "images" }) });
const result = streamText({
model: neon("gpt-5-mini"),
system:
"Use image_generation when the user asks for a picture, then describe it.",
messages,
tools: {
image_generation: neon.tools.imageGeneration({
model: "gpt-image-2", // or gpt-image-1.5, gpt-image-1-mini
outputFormat: "jpeg",
size: "1024x1024",
}),
},
async onStepFinish({ toolResults }) {
for (const tr of toolResults) {
if (tr.toolName !== "image_generation") continue;
const base64 = imageResultBase64(tr.output);
if (!base64) continue;
const key = `generated/${randomUUID()}.jpg`;
await files.upload(key, Buffer.from(base64, "base64"), {
contentType: "image/jpeg",
});
// …insert a row keyed by `key` into Postgres; serve later via files.url(key)
}
},
});
```
Always stream image generation with `streamText`. A non-streaming response whose image is over 655,360 bytes fails with `400: The response content length cannot be greater than 655360 bytes long`; streamed images have no such cap. See the `neon-ai-gateway` skill's Generate Images section.
## 4. Call it directly from the client (don't proxy the stream)
So the long stream isn't cut off by your web host's serverless limits, have the **browser call the function directly** and authenticate at the top of the handler — see [Functions as an agent backend](../SKILL.md#functions-as-an-agent-backend-nextjs-and-similar-frameworks) for the JWT-verify + CORS pattern and the AI SDK `DefaultChatTransport` wiring.
## 5. Run and deploy
```bash
neon dev # injects DATABASE_URL + the gateway/storage creds; hot reload
neon deploy # provisions the gateway + bucket and deploys the function
```
```bash
curl -N -X POST "$(neon functions get agent -o json | jq -r .invocation_url)" \
-H "content-type: application/json" \
-d '{"messages":[{"role":"user","content":"How many open todos do I have?"}]}'
```
## Further reading
- Neon AI Gateway dialects, models, and the `@neon/ai-sdk-provider`: the `neon-ai-gateway` skill
- Storing generated assets that branch with the database: the `neon-object-storage` skill
- AI SDK agents/tools: https://ai-sdk.dev/docs/foundations/agents
references/function-triggers.md
# Function Triggers
A Function Trigger is a branch-scoped rule that POSTs to a Neon Function so recurring work does not need a separate scheduler. The request is a normal `fetch` invocation: same public URL, same 15-minute time-to-first-byte limit, same injected env (`DATABASE_URL`, …).
Same regions as Functions: `aws-us-east-2`, `aws-us-east-1`, `aws-eu-central-1`, and `aws-ap-southeast-1`. Needs Neon CLI 4.21 or newer to declare triggers in `neon.ts`.
If `neon deploy` returns 404 `function triggers not available for this project`, the project does not have Function Triggers yet. Deploy the function without applying the trigger (`neon functions deploy <slug> --src <entry>`) and retry `neon deploy` once the project has them.
## Supported types
`triggers` is a keyed map on `defineConfig`. Types:
| `type` | When it fires | `neon.ts` fields | CLI create |
| ------------------------ | -------------------------------------------------- | ---------------------------------------- | ----------------------------------------------- |
| `schedule` | On a five-field UTC cron expression | `function`, `cron` | `neon triggers create --cron '…'` |
| `storage_object_created` | When an object is created in a declared bucket | `function`, `bucket`, optional `prefix` | `neon triggers create --bucket <name>` |
`create` takes `--cron` or `--bucket`, not both. `@neon/functions` ≥ 0.11.0: `parseTriggerDelivery` accepts both types; `parseTriggerInvocation` and Hono `parseTrigger(c)` stay schedule-only (`storage_object_created` is `invalid_body` there).
## Fields
The trigger name is the `neon.ts` map key (CLI `--name`). It must be unique among every trigger visible on the branch, including other functions.
| Field | Required | Notes |
| -------------- | -------- | --------------------------------------------------------------------- |
| `type` | yes | `"schedule"` or `"storage_object_created"` |
| `function` | yes | Function slug. REST/MCP: `function_slug` |
| `cron` | schedule | Five-field UTC expression, e.g. `0 * * * *`, `*/15 * * * *` |
| `bucket` | storage | Bucket name. REST: `storage_object_created.bucket_name` |
| `prefix` | no | Object-key prefix filter. REST: `storage_object_created.prefix` |
| `functionPath` | no | Path on the function. Default `/`. CLI: `--function-path` |
| `enabled` | no | Default `true`. CLI: `--enabled false` to create disabled |
## neon.ts (preferred)
Declare `triggers` next to `functions` (and `buckets` when using storage). `neon deploy` applies triggers **after** the functions they target. Triggers that exist remotely but are omitted from `neon.ts` are left alone.
```typescript
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
ingest: { name: "Object ingest", source: "src/index.ts" },
cron: { name: "Cron", source: "src/cron.ts" },
},
buckets: { assets: { access: "public_read" } },
triggers: {
"on-upload": {
type: "storage_object_created",
function: "ingest",
bucket: "assets",
prefix: "logos/",
functionPath: "/object",
},
"every-minute": {
type: "schedule",
function: "cron",
cron: "* * * * *",
functionPath: "/cron",
},
},
});
```
```bash
neon deploy
```
Change the cron string, bucket, or prefix and deploy again to reschedule. Starter: `neon bootstrap --template cron-job`.
## CLI
Use when you are not applying `neon.ts`, or to list, enable, disable, or delete.
```bash
neon triggers create --function-slug cron --name hourly --cron '0 * * * *' --function-path /cron
neon triggers create --function-slug ingest --name on-upload --bucket assets --prefix 'logos/' --function-path /object
neon triggers list
neon triggers list --output json
neon triggers update <id> --branch <branch> --cron '*/30 * * * *'
neon triggers update <id> --branch <branch> --bucket assets --prefix 'incoming/'
neon triggers enable <id> --branch <branch>
neon triggers disable <id> --branch <branch>
neon triggers delete <id> --branch <branch>
```
`enable` / `disable` wrap `update --enabled`. Updating the cron recomputes `Next Run At`. Disabling clears `Next Run At`. Alias: `neon trigger`. `--cron` on a storage trigger, or `--bucket` / `--prefix` on a schedule trigger, is rejected.
Inspect a trigger with `neon triggers list --output json`. Pass `--branch` on get/update/enable/disable/delete: without it the CLI resolves the trigger id as a branch name.
Project and branch otherwise resolve from `--project-id` / `--branch`, then `.neon`, then a single-project auto-detect.
## MCP backup
The Neon MCP server (`?category=functions`) exposes `list_triggers`, `get_trigger`, `create_trigger`, `update_trigger`, and `delete_trigger`. `branch_id` is a `br-…` id, not a branch name (`list_branches` to resolve). Create a schedule trigger with snake_case:
```json
{
"type": "schedule",
"function_slug": "cron",
"name": "hourly",
"function_path": "/cron",
"schedule": { "cron": "0 * * * *" },
"enabled": true
}
```
`create_trigger` required fields for schedule: `type`, `function_slug`, `name`, `schedule`. REST is the same payload at `POST /projects/{project_id}/branches/{branch_id}/triggers`. For `storage_object_created`, use CLI or REST with `"type": "storage_object_created"` and `storage_object_created: { "bucket_name": "assets", "prefix": "logos/" }`. CLI docs: https://neon.com/docs/cli/triggers.md.
## Delivery payload
Neon POSTs JSON. The Functions proxy drops client-supplied `x-neon-*` headers, so a present `x-neon-trigger-invocation-id` is from a trigger delivery. It must match `invocation_id` in the body.
A Function that also serves app or public HTTP must not apply JWT or `X-Secret` middleware to the trigger path. Neon trigger POSTs do not send those. Caller shapes: [production-hardening.md](production-hardening.md).
Schedule wire JSON (snake_case):
```json
{
"version": 1,
"invocation_id": "…",
"trigger": {
"type": "schedule",
"id": "trigger-…",
"name": "hourly"
},
"data": { "scheduled_at": "2026-09-15T23:35:00Z" }
}
```
Storage-object-created wire JSON:
```json
{
"version": 1,
"invocation_id": "…",
"trigger": {
"type": "storage_object_created",
"id": "trigger-…",
"name": "on-upload"
},
"data": { "bucket_name": "uploads", "object_key": "smoke.txt" }
}
```
Parsed (`@neon/functions` ≥ 0.11.0) is camelCase. `parseTriggerDelivery` also sets a top-level `type`. Schedule: `data.scheduledAt`. Storage: `data.bucketName`, `data.objectKey`. Narrow on `invocation.type` (or `isScheduleTriggerInvocation` / `isStorageObjectCreatedTriggerInvocation`) before reading `data` — a check on `trigger.type` does not narrow the sibling `data` field.
### `parseTriggerDelivery` (both types)
```typescript
import { parseTriggerDelivery } from "@neon/functions/triggers";
export default {
async fetch(request: Request): Promise<Response> {
const parsed = await parseTriggerDelivery(request);
if (!parsed.ok) {
const status = parsed.error === "invalid_body" ? 400 : 401;
return new Response(parsed.error, { status });
}
const invocation = parsed.invocation;
if (invocation.type === "storage_object_created") {
return Response.json({
bucketName: invocation.data.bucketName,
objectKey: invocation.data.objectKey,
});
}
return Response.json({
scheduledAt: invocation.data.scheduledAt,
});
},
};
```
`parseTriggerDelivery(request)` clones the Request before `json()`, so `request.json()` still works. If you already have the body: `parseTriggerDelivery({ headers, body })` (sync). `parsed.error` is `missing_header`, `invalid_body`, or `invocation_id_mismatch`. Unknown `trigger.type` values fail as `invalid_body`.
Hono: `parseTriggerDelivery(c.req.raw)`.
### `parseTrigger` (Hono, schedule only)
Throws `HTTPException`. `c.req.json()` still works afterwards. Returns `ScheduleTriggerInvocation`. A `storage_object_created` delivery is `invalid_body`.
| Failure | Status | Message |
| ------------------------ | ------ | --------------------------------------------- |
| missing header | 401 | `Missing x-neon-trigger-invocation-id header` |
| header ≠ `invocation_id` | 401 | `Invocation id mismatch` |
| invalid JSON or payload | 400 | `Invalid trigger payload` |
```typescript
import { parseTrigger } from "@neon/functions/hono";
app.post("/cron", async (c) => {
const invocation = await parseTrigger(c);
return c.json({ ok: true, invocationId: invocation.invocationId });
});
```
### `parseTriggerInvocation` (`fetch`, schedule only)
```typescript
import { parseTriggerInvocation } from "@neon/functions/triggers";
export default {
async fetch(request: Request): Promise<Response> {
const parsed = await parseTriggerInvocation(request);
if (!parsed.ok) {
const status = parsed.error === "invalid_body" ? 400 : 401;
return new Response(parsed.error, { status });
}
return Response.json({
ok: true,
invocationId: parsed.invocation.invocationId,
});
},
};
```
## Local `neon dev`
`neon dev` forwards `x-neon-trigger-invocation-id`, so you can simulate a tick:
```bash
curl -X POST http://localhost:8787/cron \
-H 'content-type: application/json' \
-H 'x-neon-trigger-invocation-id: local-dev' \
-d '{
"version": 1,
"invocation_id": "local-dev",
"trigger": { "type": "schedule", "id": "trigger-local", "name": "hourly" },
"data": { "scheduled_at": "2026-09-15T00:00:00Z" }
}'
```
A public POST to the **deployed** function that includes that header still returns 401: the proxy strips client `x-neon-*` headers.
## Inheritance
Triggers are branch-scoped. A trigger created on a parent is visible on children (`inherited: true`, `source_branch_id` points at the origin) and starts disabled there.
`neon deploy` of a `neon.ts` that declares the same trigger (default `enabled: true`) enables that inherited copy on the child. Omit it from `neon.ts` to leave the inherited trigger disabled. Enable without applying `neon.ts` with `neon triggers enable <id> --branch <branch>`.
## Logs
```bash
neon logs query --source function --since 1h
```
Pass `--branch` when the function is not on the branch in `.neon`.
references/mastra-studio.md
# Mastra agents with Mastra Studio observability
A Neon Function is a long-lived Node.js 24 process, which makes it a natural host for a [Mastra](https://mastra.ai) agent: the agent keeps running for the life of the request, and you point its model at the Neon AI Gateway so there are no extra provider keys. You can keep **running the agent on Neon Functions** while shipping its traces to a **Mastra Studio (Mastra Cloud) project** for observability — the agent runs on Neon, the traces are viewable in Mastra.
The shape mirrors any other Node integration (see [sentry.md](sentry.md)): instantiate at module load, gate on env vars so local dev and unconfigured branches stay a no-op, and pass secrets at deploy time via `neon.ts`. `@mastra/core` and `@mastra/observability` bundle cleanly through `neon deploy`'s esbuild with no extra config.
## 1. Define the agent against the Neon AI Gateway
With `@mastra/core` 1.47+, use a `neon/<model>` magic string — Mastra reads `NEON_AI_GATEWAY_BASE_URL` and `NEON_AI_GATEWAY_TOKEN` from the environment (injected by `neon deploy` / `neon env pull` when `aiGateway` is enabled in `neon.ts`). No manual `url`/`apiKey` or MLflow dialect swap is needed; Mastra routes each model to the correct gateway endpoint.
```typescript
// src/mastra/agents/pricing.ts
import { Agent } from "@mastra/core/agent";
export const pricingAgent = new Agent({
id: "pricing-analyst",
name: "pricing-analyst",
instructions: "You are a meticulous pricing analyst. …",
model: "neon/gpt-5-mini",
});
```
## 2. Wire observability to Mastra Studio
The `MastraPlatformExporter` (from `@mastra/observability`) sends traces to a Mastra Studio project. It reads `MASTRA_PLATFORM_ACCESS_TOKEN` and `MASTRA_PROJECT_ID` from the environment.
Gotcha: `Observability` requires **at least one exporter** — passing an empty `exporters` array throws `OBSERVABILITY_INVALID_INSTANCE_CONFIG`. So omit the `observability` option entirely until the platform creds are present, keeping the app runnable before the Mastra project exists (and in local dev).
```typescript
// src/mastra/index.ts
import { Mastra } from "@mastra/core/mastra";
import { Observability, MastraPlatformExporter } from "@mastra/observability";
import { pricingAgent } from "./agents/pricing";
const platformReady = Boolean(
process.env.MASTRA_PLATFORM_ACCESS_TOKEN && process.env.MASTRA_PROJECT_ID,
);
const observability = platformReady
? new Observability({
configs: {
default: { serviceName: "my-app", exporters: [new MastraPlatformExporter()] },
},
})
: undefined;
export const mastra = new Mastra({
agents: { pricingAgent },
...(observability ? { observability } : {}),
});
```
Agents must be **registered on the `Mastra` instance** (the `agents` map) for their `.generate()` / `.stream()` calls to be traced. Call them via `mastra.getAgent("pricingAgent")`.
## 3. Structured output through the gateway
The gateway does not enforce **native** structured output, so a bare `structuredOutput: { schema }` can come back missing fields (e.g. a nested `meta` object), failing Zod validation. Set `jsonPromptInjection: true` so Mastra injects the schema into the prompt and the model returns the full shape:
```typescript
const agent = mastra.getAgent("pricingAgent");
const result = await agent.generate(prompt, {
structuredOutput: { schema: myZodSchema, jsonPromptInjection: true },
abortSignal: AbortSignal.timeout(70_000), // bound each attempt; the gateway has an upstream timeout
});
const data = result.object; // validated against myZodSchema
```
For resilience, register a second agent on a different model (e.g. `neon/claude-haiku-4-5`) and fall back to it if the primary attempt throws — both models are reachable on the gateway via the same env vars.
## 4. Create the Mastra project + token with the CLI
Install the Mastra CLI (`npm i -g mastra`) and authenticate. Project/token creation needs a **live login session**:
```bash
mastra auth login # opens a browser; required before the steps below
mastra auth whoami # shows your user + org id (org_…)
```
- **Access token (non-interactive):** `mastra auth tokens create <name>` prints a one-time secret (`sk_…`). This is your `MASTRA_PLATFORM_ACCESS_TOKEN`.
- **Project:** the interactive `mastra studio projects create` TUI is hard to script. Instead, register the project as part of a Studio deploy, which is non-interactive with `-y` and writes the project id to `.mastra-project.json`:
```bash
mastra studio deploy --org org_xxx --project my-app -y
# → .mastra-project.json: { "projectId": "…", "projectName": "my-app", "organizationId": "org_…" }
```
Use that `projectId` as `MASTRA_PROJECT_ID`.
Two gotchas:
- **Don't set `MASTRA_API_TOKEN` in the env for project/deploy commands** — it makes the CLI report `No organizations found`. Rely on the interactive login session instead.
- If you keep multiple env files (e.g. `.env.deploy` and `.env.local`), `studio deploy` errors with `Multiple env files found`; pass `--env-file <file>` to disambiguate.
## 5. Pass the creds via `neon.ts` (third-party env)
Neon-injected vars (`DATABASE_URL`, AI Gateway `NEON_AI_GATEWAY_*`) are automatic. Declare only third-party vars under the function's `env`, each with `value` and `required`:
```typescript
// neon.ts
functions: {
myapp: {
name: "my app",
source: "src/index.ts",
env: {
MASTRA_PROJECT_ID: { value: process.env.MASTRA_PROJECT_ID, required: true },
MASTRA_PLATFORM_ACCESS_TOKEN: {
value: process.env.MASTRA_PLATFORM_ACCESS_TOKEN,
required: true,
},
},
},
}
```
Load the values from a git-ignored file at deploy time:
```bash
neon deploy --env .env.deploy
```
## 6. Verify
Send a request that exercises the agent, then open the Mastra Studio project's **Observability / Traces** view — you'll see the agent run (model calls, latency, token usage) under the `serviceName` you configured. Only `SPAN_ENDED` events are exported, buffered and flushed periodically, so a trace appears a few seconds after the agent run completes.
## Further reading
- https://mastra.ai/docs/observability/tracing/exporters/cloud
- https://mastra.ai/reference/observability/tracing/exporters/mastra-platform-exporter
- https://mastra.ai/docs/agents/structured-output
- Neon AI Gateway dialects: the `neon-ai-gateway` skill
references/mcp.md
# MCP servers on Neon Functions
A [Model Context Protocol](https://modelcontextprotocol.io) server is a textbook Neon Functions workload: it's a long-running HTTP handler that an AI client (Cursor, Claude, ChatGPT, an agent) calls to discover and invoke tools, and those tools usually read and write a database. Running it as a Neon Function puts the MCP server's compute next to its Postgres data, gives it a public HTTPS URL, and lets it branch with the rest of your backend — each branch gets its own MCP server against its own isolated data.
MCP's **streamable HTTP transport** is a plain `POST`/`GET` on a single endpoint (conventionally `/mcp`), so it maps directly onto a function's web-standard `fetch` handler — no `upgrade` method or extra protocol like [WebSockets](../SKILL.md#websocket-servers) needed. A Hono app is the simplest host.
## The server
Two packages do the work: the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) (defines the server and its tools) and [`@hono/mcp`](https://github.com/honojs/middleware/tree/main/packages/mcp) (bridges MCP's streamable HTTP transport to a Hono route). Tools query Postgres through Drizzle on a module-scope `pg` pool, exactly like any other function (see [Connecting to Postgres](../SKILL.md#connecting-to-postgres)).
```typescript
// src/index.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPTransport } from "@hono/mcp";
import { attachDatabasePool } from "@neon/functions";
import { contacts } from "./db/schema";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);
const db = drizzle(pool);
const mcpServer = new McpServer({ name: "contacts", version: "1.0.0" });
// Each tool: a name, a config (description + a Zod input schema), and a handler
// that returns MCP content. The Zod shape becomes the tool's JSON schema, which
// the client uses to call the tool correctly.
mcpServer.registerTool(
"create_contact",
{
title: "Create contact",
description: "Create a new contact.",
inputSchema: {
name: z.string().describe("Full name (required)."),
email: z.string().optional().describe("Email address."),
},
},
async ({ name, email }) => {
const [row] = await db.insert(contacts).values({ name, email }).returning();
return { content: [{ type: "text", text: JSON.stringify(row) }] };
},
);
mcpServer.registerTool(
"delete_contact",
{
title: "Delete contact",
description: "Delete a contact by id.",
inputSchema: { id: z.number().int().positive() },
},
async ({ id }) => {
const [row] = await db
.delete(contacts)
.where(eq(contacts.id, id))
.returning();
return {
content: [
{ type: "text", text: JSON.stringify(row ?? { error: "not found" }) },
],
};
},
);
// Connect the server to the transport once per isolate, then let the Hono route
// hand every /mcp request (POST for calls, GET for the stream) to the transport.
const transport = new StreamableHTTPTransport();
const app = new Hono();
app.all("/mcp", async (c) => {
if (!mcpServer.isConnected()) await mcpServer.connect(transport);
return transport.handleRequest(c);
});
export default app;
```
Key points:
- **Module scope.** Build the `McpServer`, register its tools, create the `StreamableHTTPTransport`, and open the `pg` pool once at module load — they're reused across every request the isolate serves (see [runtime limits](../SKILL.md#timeouts-and-runtime-limits)). Connect the transport lazily with the `isConnected()` guard so it happens once.
- **State in Postgres.** Module memory doesn't survive isolate eviction, and several isolates run in parallel — so the source of truth for anything a tool reads or writes belongs in Postgres, not an in-memory structure.
- **The URL.** After `neon deploy`, the server lives at `https://<branch_id>-<slug>.compute.…neon.tech/mcp`. Point any streamable-HTTP MCP client at that `/mcp` path.
## Authenticating the server
> [!WARNING]
> A Neon Function has a **public HTTPS URL — anyone can reach it.** An unauthenticated MCP server hands every caller your tools (and the database behind them). Authenticate at the top of the handler before touching the transport, exactly as for [any client-facing function](../SKILL.md#functions-as-an-agent-backend-nextjs-and-similar-frameworks).
Use **Managed Better Auth (Neon Auth) as the OAuth server** and guard the Function with `@neon/oauth` (0.3.0 or later). MCP clients that support OAuth (Cursor, Claude, ChatGPT) discover the server, sign the user in on a login and consent page you host, and call `/mcp` with an access token. Keep the app's existing login; the OAuth server sits beside it. The full path (CLI setup, clients, pages, verification) is [neon-auth references/oauth-server.md](https://neon.com/docs/ai/skills/neon-auth/references/oauth-server.md).
### Option 1 — OAuth via Managed Better Auth (best for third-party clients)
Configure the server once:
```bash
neon neon-auth config oauth-server update --enabled \
--login-page https://app.example.com/auth/sign-in \
--consent-page https://app.example.com/auth/consent \
--mcp-resource https://<your-function-url>/mcp
```
Your Neon Function is the **resource server**. `honoMcp` verifies the token's signature, issuer, and audience, serves the protected-resource metadata, and answers unauthenticated requests with a `401` challenge. It sets `c.get("auth")`, whose `sub` is the user identity. Scope every tool to it:
```typescript
import { createMcpAuth } from "@neon/oauth/mcp";
import { honoMcp, type McpAuthVariables } from "@neon/oauth/mcp/hono";
const mcpAuth = createMcpAuth({
baseUrl: process.env.NEON_AUTH_BASE_URL!, // the branch's Auth URL
resource: process.env.MCP_RESOURCE!, // exactly the --mcp-resource value
});
const app = new Hono<McpAuthVariables>();
app.use(honoMcp(mcpAuth, { path: "/mcp" }));
app.all("/mcp", async (c) => {
const userId = c.get("auth").sub;
// Build a McpServer whose tools close over userId; stateless transport per request.
const server = createServer(userId);
const transport = new StreamableHTTPTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
return transport.handleRequest(c);
});
```
A per-request server and transport replace the module-scope server above because tools need the caller's `sub`; keep the `pg` pool at module scope. Pass `NEON_AUTH_BASE_URL` and `MCP_RESOURCE` via the function's `env` in `neon.ts` (see [Environment variables](../SKILL.md#environment-variables)). `/mcp` has no CORS or `OPTIONS` handling from `honoMcp`, so add it for browser-based clients.
### Option 2 — API key or session JWT (simplest)
When the callers are your own agents/services or a personal MCP server, you don't need the OAuth flow. Either:
- **API keys** — issue and store your own keys, and have the function verify the `Authorization: Bearer <key>` (or an `x-api-key` header) on every request; or
- **Session JWT** — verify a Managed Auth JWT (`NEON_AUTH_JWKS_URL`, issuer from `NEON_AUTH_BASE_URL`) with `jose`, the same pattern used for the [agent backend](../SKILL.md#functions-as-an-agent-backend-nextjs-and-similar-frameworks).
Either way it's one check at the top of the `/mcp` route — reject anything that doesn't carry a valid key/token before connecting the transport:
```typescript
app.all("/mcp", async (c) => {
const auth = c.req.header("authorization");
if (!(await isValidApiKey(auth)))
return c.json({ error: "unauthorized" }, 401);
if (!mcpServer.isConnected()) await mcpServer.connect(transport);
return transport.handleRequest(c);
});
```
This keeps the secret server-side, costs nothing to operate, and is trivial to rotate — a solid default until you need third-party clients to self-authorize, at which point reach for Option 1.
## Testing
Drive the server with any MCP client. [`mcporter`](https://github.com/instructa/mcporter) is a quick CLI for it — `mcporter list <url>/mcp --schema` lists the tools and `mcporter call "<url>/mcp.<tool>" key=value` invokes one (`--allow-http` for a local `neon dev` URL). To wire it into a client interactively, `npx add-mcp <url>/mcp -a <agent>` writes the client config for you.
references/native-binaries.md
# Shipping native binaries with a Function
The default deploy bundles `source` into a single `index.mjs` with esbuild. A compiled binary cannot be inlined into that file, so it has to ship as a separate file in the deploy archive. There are three ways to put it there. Pick by what the binary is:
| The binary is | Use |
| ------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| An npm package backed by a Node-API addon (`sharp`, most `@napi-rs/*` packages) | [`externalPackages`](#externalpackages-npm-packages-with-a-node-addon) |
| A file your own build step produces: a `.node` addon, a `.so`, a `.wasm`, model weights | [`bundler: "none"`](#bundler-none-ship-a-prebuilt-directory) or a [custom `bundler`](#custom-bundler-return-the-file-map) |
| A standalone executable you spawn (`ffmpeg`, a Go or Rust CLI) | A custom `bundler` or `"none"`, plus [a copy to `/run` at startup](#standalone-executables) |
The examples use top-level `functions`, which needs `neon` CLI 4.20 or newer and `@neon/config` 1.6 or newer.
## The runtime a binary lands on
- **linux, arm64, glibc**, Node.js 24. A binary built for macOS or x64 cannot load there.
- The archive is extracted to `/opt/function`, a read-only filesystem. `import.meta.url` in the root `index.mjs` points there, so `new URL("./bin/tool", import.meta.url)` resolves to `/opt/function/bin/tool`.
- **Every file arrives with mode `644`.** Unix permission bits in the zip are dropped on extraction, and `chmod` in place fails with `EROFS`. A `.node` or `.so` loads fine (it is `dlopen`ed, not executed). Spawning a file from `/opt/function` fails with `EACCES`.
- `/tmp` is writable and mounted `noexec`: a copied binary still fails with `EACCES` after `chmod 755`.
- `/run` is a writable tmpfs that allows exec (about 990 MiB, backed by the function's 2048 MiB of memory).
The filesystem layout is observed behavior, not documented by Neon. Re-check it if spawning starts failing.
## `externalPackages`: npm packages with a Node addon
```typescript
// neon.ts
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
resize: {
name: "Resize",
source: "./src/resize.ts",
externalPackages: ["sharp"],
},
},
});
```
esbuild leaves `import sharp from "sharp"` unresolved. At deploy, the CLI installs the version of `sharp` from your project with `npm install --cpu=arm64 --os=linux --libc=glibc --ignore-scripts` into a temp directory, traces the files it reaches with `@vercel/nft`, and copies them into the archive under `node_modules/` with the tree layout intact. Only the installed version is read from your `node_modules`; the shipped files come from the temp install, and your `node_modules` is not modified.
The deploy fails with a named error when:
- the package is not installed in your project (no version to pin)
- the declared package itself does not install for linux-arm64 glibc (`EBADPLATFORM`)
- a staged `.node` or `.so` is not an AArch64 ELF binary
- `npm` is not on `PATH`
- the archive exceeds the [size limits](#size-limits)
It does not fail when a platform-specific optional dependency is silently skipped, or when a package that compiles from source at install time ships no binary (the staging install runs with `--ignore-scripts`). Those deploy and then fail at invoke. Use packages that publish a linux-arm64 glibc prebuild (`sharp` does), and invoke the deployed function once to confirm the addon loads.
A deploy and `neon dev` print an advisory warning for any bundled package that carries native code and is not declared. A package with a working JavaScript fallback (`ws` with `bufferutil`) triggers it too and needs no change. Do not silence it with `{ name, includeFiles: false }`: that externalizes the package and ships nothing, so a reached import throws `Cannot find module` on every invoke. `includeFiles: false` is only for an import the function never evaluates.
Under `neon dev`, the package is only kept out of the bundle and resolves from your own `node_modules` for your host.
Pros: one line in `neon.ts`; the target-platform install, file tracing, arch check, and size check are automatic; local dev uses your host build.
Cons: only for npm packages that publish a linux-arm64 glibc prebuild; transitive pins, overrides, and patches from your lockfile are not carried into the staged install; esbuild bundler only (`externalPackages` with `bundler: "none"` or a function fails validation).
## `bundler: "none"`: ship a prebuilt directory
Run your own build, then point `source` at its output. The directory root must contain `index.mjs` or `index.js`. Everything else in the directory ships as-is:
```text
dist/fn/
index.mjs # built by your step, reads ./native/addon.node
native/addon.node
```
```typescript
// neon.ts
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
api: { name: "API", source: "./dist/fn", bundler: "none" },
},
});
```
```bash
npm run build:fn && neon deploy --env .env.local
neon functions deploy api --src dist/fn --no-bundle # same thing, without neon.ts
```
`neon deploy` does not run your build step. Run it first, or use a [custom `bundler`](#custom-bundler-return-the-file-map) to keep the build inside `neon deploy`.
Pros: works with any build tool or framework output (`.mastra/output` is the common one); full control over the archive layout.
Cons: no architecture check, so a binary built for your laptop deploys and then fails at invoke; the build step is yours to keep in sync with `neon deploy`; TypeScript cannot ship unbundled.
## Custom `bundler`: return the file map
An inline function in `neon.ts` returns archive paths mapped to bytes. `neon deploy` zips the map, and `neon dev` serves the same map locally:
```typescript
// neon.ts
import { readFile } from "node:fs/promises";
import { build } from "esbuild";
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
transcode: {
name: "Transcode",
source: "./src/transcode.ts",
bundler: async (fn) => {
const out = await build({
entryPoints: [fn.source],
bundle: true,
platform: "node",
format: "esm",
write: false,
outfile: "index.mjs",
// CommonJS dependencies call require(); ESM output has none in scope.
banner: {
js: "import{createRequire}from'module';const require=createRequire(import.meta.url);",
},
});
return {
"index.mjs": out.outputFiles[0].contents,
"bin/ffmpeg": new Uint8Array(
await readFile(new URL("./vendor/linux-arm64/ffmpeg", import.meta.url)),
),
};
},
},
},
});
```
`esbuild` must be a dependency of your project. The map needs `index.mjs` or `index.js` at the root.
Pros: the build lives in `neon.ts`, so `neon deploy` and `neon dev` always build the same thing; any file from anywhere can go in the archive.
Cons: no architecture check; `externalPackages` cannot be combined with it, so an npm addon has to be copied into the map by hand, with its `node_modules/` layout.
## Standalone executables
Ship the executable with a custom `bundler` or `bundler: "none"`, then copy it to `/run` and mark it executable once per isolate:
```typescript
// src/transcode.ts
import { execFile } from "node:child_process";
import { chmod, copyFile, mkdir } from "node:fs/promises";
import { promisify } from "node:util";
const run = promisify(execFile);
let ffmpeg: Promise<string> | undefined;
function ffmpegPath(): Promise<string> {
if (process.env.FFMPEG_PATH) return Promise.resolve(process.env.FFMPEG_PATH);
ffmpeg ??= (async () => {
await mkdir("/run/bin", { recursive: true });
await copyFile(new URL("./bin/ffmpeg", import.meta.url), "/run/bin/ffmpeg");
await chmod("/run/bin/ffmpeg", 0o755);
return "/run/bin/ffmpeg";
})();
return ffmpeg;
}
export default {
async fetch() {
const { stdout } = await run(await ffmpegPath(), ["-version"]);
return new Response(stdout);
},
};
```
`neon dev` runs this code on your machine, where `/run` may not exist and a linux-arm64 binary cannot execute. Point it at a host install from the shell, and leave `FFMPEG_PATH` out of the function's deployed `env`:
```bash
FFMPEG_PATH="$(command -v ffmpeg)" neon dev
```
The binary must be statically linked, or its shared libraries must exist in the runtime image. The copy costs memory: `/run` is RAM-backed and counts against the function's 2048 MiB.
## Size limits
`bundler: "none"`, a custom `bundler`, and `externalPackages` staging check the archive before upload:
| Limit | Value |
| ------------------------ | ------- |
| Compressed zip | 10 MiB |
| Total uncompressed bytes | 64 MiB |
| Files in the archive | 4,096 |
Exceeding one fails the deploy. The byte-limit errors list the four largest files. Check an executable's size before building around it: a single static binary can use most of the 64 MiB.
references/production-hardening.md
# Production hardening for Neon Functions
A Function has a public HTTPS URL. Authenticate the caller, then pick extra
protection from who actually calls it. This file is the production path; JWT
and trigger parsers stay in [SKILL.md](../SKILL.md) and
[function-triggers.md](function-triggers.md).
https://neon.com/docs/compute/functions/authentication.md
## Choose the caller shape
Pick a row before writing code. Mixed routes: apply the matching row per
path, not one middleware for the whole Function.
| Caller | What to do | Do not |
| ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| Trusted app server (Vercel/Netlify route, server action, queue worker) that finishes within that host's duration | The server calls the Function. Browser never sees the Function URL or the origin secret. Function returns 401 before any work. | Put the secret in client code. Proxy a long agent, WebSocket, or SSE stream through the app host without checking that host's duration. |
| Function Triggers only | `parseTriggerDelivery` (both types). Keep this path outside JWT and `X-Secret` middleware. | Require a user JWT or `X-Secret` on the trigger path. Invent `x-neon-invocation-id`. Treat `invocation_id` as a secret. |
| Public consumers (Open API, MCP, third-party HTTP) | A Cloudflare Worker Custom Domain you own, **not** registered as a Neon custom domain, proxies to the native invocation URL, sets `X-Secret`, and rate-limits at the edge. Function requires `X-Secret`, then the route's existing consumer auth. | Orange-cloud a Neon-registered custom-domain CNAME. Assume the native URL is closed. Put a human challenge in front of MCP. |
Browser-direct JWT agents stay on the client-direct path in SKILL.md. They
are not the trusted-app-server row.
## Native URL
The native invocation URL stays reachable after you add a custom domain or a
Worker. Application checks reject work; the request still occupies a Function
invocation until the handler returns.
Neon also enforces a default account-wide cap of 100 concurrent invocations
(`429`, body `per-account concurrency limit reached`, `Retry-After` in
seconds). That is not per-client DDoS protection.
https://neon.com/docs/compute/functions/reference/runtime-limits.md
Functions docs do not document a customer-configurable WAF, a switch to
disable the native URL, or a Functions ingress IP allowlist. Do not invent
those.
## Trusted app server
Keep the Function URL and origin secret in **server** env on the app host and
in Function `env`. Authenticate the app route first. Then `fetch` the
Function.
Headers:
```text
X-Secret: <server-only origin secret>
Authorization: <existing consumer credential, unchanged>
```
`X-Secret` is application-defined. Use it for the server hop when
`Authorization` already carries a user or consumer bearer token.
If the Function currently only checks `Authorization: Bearer <API_KEY>` and
nothing forwards a user token, keep that check. Do not migrate that header.
Compare `X-Secret` before parsing the body or touching Postgres. Missing
`ORIGIN_SECRET` fails at startup. Do not add CORS if browsers must not call
this Function.
```typescript
import { timingSafeEqual } from "node:crypto";
const originSecret = process.env.ORIGIN_SECRET;
if (!originSecret) throw new Error("ORIGIN_SECRET is required");
const expected = Buffer.from(originSecret);
function hasOriginSecret(request: Request): boolean {
const header = request.headers.get("x-secret");
if (header === null) return false;
const provided = Buffer.from(header);
if (provided.byteLength !== expected.byteLength) return false;
return timingSafeEqual(provided, expected);
}
export default {
async fetch(request: Request): Promise<Response> {
if (!hasOriginSecret(request)) {
return new Response("Unauthorized", { status: 401 });
}
return Response.json({ ok: true });
},
};
```
Declare `ORIGIN_SECRET` in `neon.ts` `env` and on the app host.
Buffer the incoming body on the app-server `fetch`. Node `fetch` throws
`duplex option is required when sending a body` if you pass a streamed
`request.body`. `duplex: "half"` is Node-only; this hop is short, so
buffer instead. `redirect: "manual"` keeps `X-Secret` from following a
cross-origin redirect.
`NEON_FUNCTION_URL` is `invocation_url` from `neon functions get`. It ends
with `/`. This example calls that root. For a Function path, concatenate
onto that slash (`new URL("orders?limit=2", functionUrl)`). Do not copy the
app request's host or pathname onto the Function; a Next.js `/api/...` route
is not the Function path.
```typescript
const functionUrl = process.env.NEON_FUNCTION_URL;
const originSecret = process.env.ORIGIN_SECRET;
if (!functionUrl || !originSecret) {
throw new Error("NEON_FUNCTION_URL and ORIGIN_SECRET are required");
}
const headers = new Headers({ "x-secret": originSecret });
const contentType = request.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
const authorization = request.headers.get("authorization");
if (authorization) headers.set("authorization", authorization);
return fetch(functionUrl, {
method: request.method,
headers,
body: request.body ? await request.arrayBuffer() : undefined,
redirect: "manual",
});
```
## Function Triggers only
Use the parsers in [function-triggers.md](function-triggers.md).
`parseTriggerDelivery` covers `schedule` and `storage_object_created`.
Hono `parseTrigger` and `parseTriggerInvocation` are schedule-only.
Neon POSTs to the native URL and does not send `X-Secret` or a user JWT. A
trigger path that requires those credentials drops real deliveries.
`invocation_id` is a correlation id. Presence of `x-neon-trigger-invocation-id`
after Neon strips client `x-neon-*` headers is the provenance check; matching
the body is consistency. https://neon.com/docs/compute/functions/triggers/overview.md
Local `neon dev` can send that header to simulate a tick. On the deployed
Function a client-supplied `x-neon-*` header is stripped.
## Public consumers through a Worker
Consumers call a hostname you control. Volumetric filtering happens there.
The Function still authenticates.
DNS:
- A Neon Function custom-domain CNAME must be DNS-only (grey cloud). A proxied
(orange-cloud) record blocks Neon domain validation.
https://neon.com/docs/compute/functions/custom-domains.md
- Attach a [Cloudflare Worker Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/)
that is **not** registered with Neon. The Worker fetches the **native**
invocation URL.
Worker:
- Preserve method, pathname, query, body, and consumer `Authorization`.
- Set `X-Secret`. Do not use it as the consumer credential.
- Build the upstream URL from a fixed origin. Assign `pathname` and `search`
separately so a request path cannot change the authority.
- Apply Cloudflare DDoS / WAF / rate-limiting rules on that Worker hostname.
The forwarding snippet does not configure those rules.
- Disable the Worker's `workers.dev` route and Preview URLs. Hostname-scoped
rules do not apply to those endpoints, and they still run this forwarding
code. Inventory clients on those URLs first.
https://developers.cloudflare.com/workers/configuration/routing/workers-dev.md
Dashboard: Worker → Settings → Domains & Routes. A later Wrangler deploy
without `workers_dev: false` turns `workers.dev` back on.
- Do not buffer a streaming body. Do not add a browser challenge MCP or API
clients cannot pass.
Wrangler:
```jsonc
{
"workers_dev": false,
"preview_urls": false
}
```
```typescript
const FUNCTION_ORIGIN = "https://<invocation-host>"; // neon functions get
export default {
async fetch(
request: Request,
env: { ORIGIN_SECRET: string },
): Promise<Response> {
const incoming = new URL(request.url);
const upstream = new URL(FUNCTION_ORIGIN);
upstream.pathname = incoming.pathname;
upstream.search = incoming.search;
const headers = new Headers(request.headers);
headers.set("x-secret", env.ORIGIN_SECRET);
headers.delete("host");
return fetch(upstream, {
method: request.method,
headers,
body: request.body,
redirect: "manual",
});
},
};
```
Function: require `X-Secret` first (same helper as above), then apply **that
route's existing** consumer authentication. Browser preflight, OAuth
discovery, and intentionally public routes keep their current behavior. An
origin secret is not a user API key.
Requiring `X-Secret` on the native URL is a migration. Move legitimate
native-URL clients to the Worker first. Anyone who still hits the native URL
without the secret gets 401 after the request has reached Function compute.
HTTP-triggered Workers have no documented hard wall-clock duration while the
client stays connected. Still verify WebSocket, SSE, and long agent streams
against the chosen Worker before using this row for those workloads. If the
edge cannot hold the stream, keep client-direct JWT.
MCP: after switching consumers to the Worker hostname, confirm advertised
resource URLs, token audiences, existing client registrations, and one live
session as well as a fresh authorization. https://developers.cloudflare.com/workers/platform/limits/
## Limit authenticated application work
Edge rate limiting drops traffic before Neon. A limiter **inside** the
Function runs after the request arrived. Use it to cap expensive work for a
**verified** principal (user id, org id, API-key hash). Never use a
caller-supplied id. A global budget can cap total capacity; one caller can
consume it for everyone.
Request-rate limits do not cap simultaneous long-running work. Bound in-flight
expensive operations separately, in a shared store, not in module-scope
counters (those are per-isolate and vanish on eviction).
A query to Postgres on every anonymous request adds database work to a flood.
Postgres can hold per-principal counters at low-to-moderate authenticated
volume when you already have it; measure before relying on it. Prefer Redis
or edge limits when a database round trip per request is too expensive.
Optional Upstash quota after credential checks, before expensive work.
`60` per minute and `timeout: 1_000` are example policy. `RATE_LIMIT_PREFIX`
is the application + environment + quota name; `NEON_BRANCH` (a branch
**name**) is appended so branches do not share counters.
`Redis.fromEnv()` reads `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`.
Upstash `reset` is milliseconds since epoch. Upstash can report a timeout as
a successful `limit()` result; check `reason` before `success`. Await the
admission decision; pass `pending` to `waitUntil` so background writes do not
delay the response.
```typescript
import { waitUntil } from "@neon/functions";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const namespace = process.env.RATE_LIMIT_PREFIX;
if (!namespace) throw new Error("RATE_LIMIT_PREFIX is required");
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(60, "1 m"),
prefix: `${namespace}:${process.env.NEON_BRANCH ?? "local"}`,
timeout: 1_000,
analytics: false,
});
export async function checkQuota(
authenticatedPrincipalId: string,
): Promise<Response | null> {
let result: Awaited<ReturnType<typeof limiter.limit>>;
try {
result = await limiter.limit(authenticatedPrincipalId);
} catch (error) {
console.error("Rate-limit store failed", error);
return new Response("Rate limiter unavailable", {
status: 503,
headers: { "Retry-After": "1" },
});
}
waitUntil(result.pending);
if (result.reason === "timeout") {
console.error("Rate-limit store timed out");
return new Response("Rate limiter unavailable", {
status: 503,
headers: { "Retry-After": "1" },
});
}
if (result.success) return null;
const retryAfterSeconds = Math.max(
1,
Math.ceil((result.reset - Date.now()) / 1000),
);
return new Response("Too Many Requests", {
status: 429,
headers: {
"Retry-After": String(retryAfterSeconds),
"RateLimit-Limit": String(result.limit),
"RateLimit-Remaining": String(result.remaining),
"RateLimit-Reset": String(retryAfterSeconds),
},
});
}
```
Preserve CORS headers on these responses when the route already sets them.
Do not relabel limiter failures as `401` or as quota exhaustion. `Retry-After`
is required on `429`. `RateLimit-*` is optional metadata; `RateLimit-Reset`
here is seconds until the window renews.
Do not rate-limit in the Function by `X-Forwarded-For`. That header is not a
documented trustworthy client-IP on Functions. IP limits belong at the edge.
## Preserve existing clients
- Direct-client JWT agents keep their token, JWKS, issuer, and audience.
Do not require `X-Secret` on those routes.
- CORS still has to succeed for legitimate browser origins, including on
401/429. CORS is not authentication.
- Inventory production and preview origins before tightening an allowlist.
- WebSocket and SSE handshake, heartbeat, and reconnect stay as documented
in SKILL.md.
- MCP OAuth discovery, tokens, methods, and streaming stay as documented in
[mcp.md](mcp.md). Authenticate before the transport.
- Adding a Worker does not by itself change the API's consumer credentials.
- Stored rows stay authorized by the verified identity, not by a quota key.
## Reject these
- Orange-cloud proxying a **Neon-registered** custom-domain CNAME.
- Shipping a server origin secret to the browser.
- Putting a long stream behind a host or Worker whose duration or transport
cannot hold it.
- Treating header/body equality as trigger provenance outside Neon's edge.
- In-memory counters as a cross-isolate quota.
- A Postgres lookup on every anonymous request as DDoS protection.
- Treating a native-URL `401` as traffic blocked before Function compute.
references/sentry.md
# Sentry error monitoring on Neon Functions
A Neon Function is a **long-lived Node.js 24 process running a web-standard request/response handler** — not an edge worker or a short-lived lambda. That means any integration SDK that works in an ordinary Node process works here unchanged: you initialize it once at module load, before your handler starts serving requests, and it stays instrumented for the life of the isolate.
This reference walks through wiring up Sentry for errors, logs, and traces — including AI-agent tracing, since agents are the workload Functions are built for. The same shape (init module imported first, gated on an env var, secret passed at deploy time) applies to other Node SDKs — see [Other Node integrations](#other-node-integrations) at the end.
## Sentry (errors, logs, and traces)
Because the runtime is a normal Node process, use the Node SDK `@sentry/node` — not an edge/serverless wrapper. **Use `@sentry/node` ≥10.67.0**: older versions fail to register their tracer against the OpenTelemetry API global the runtime pre-creates, and every span is silently non-recording while errors and logs keep working. The SDK bundles cleanly through `neon deploy`'s esbuild with no extra build config.
Sentry has distinct signals, and picking the right one is the main instrumentation decision:
1. **Errors** — unhandled route errors and failures your code can't recover from. Each becomes a grouped, alertable _issue_.
2. **Logs** — recoverable failures and narrative events (a model attempt failed and the agent moved on, a retry, a fallback). Structured, searchable, linked to the trace — and they don't pollute the issue stream.
3. **Traces** — the request's span tree: the incoming request, outbound fetches, and (for agents) the full model/tool call hierarchy with token usage.
All three carry the same trace ID, so from an error you can pivot to the logs and spans of the same request.
### Environment variables
| Variable | Purpose |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `SENTRY_DSN` | Project DSN; keep it configurable through the deployment environment. |
| `SENTRY_RELEASE` | Optional release identifier such as a commit SHA — unlocks regression detection. |
| `SENTRY_TRACES_SAMPLE_RATE` | Trace sample rate, default `1`. Agents are low-throughput and every trace is interesting; lower it for high-volume plain HTTP. |
| `PRODUCTION_BRANCH` | Your default branch's name, so it reports as environment `production` (see below). |
### 1. Initialize before anything else
Put `Sentry.init` in its own module and import it as the very first import of your entry file, so the process is instrumented before any other code (your handler, the DB pool, the agent) loads.
```typescript
// src/instrument.ts
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: process.env.SENTRY_DSN,
enabled: Boolean(process.env.SENTRY_DSN),
enableLogs: true,
tracesSampleRate: Number(process.env.SENTRY_TRACES_SAMPLE_RATE ?? 1),
traceLifecycle: "stream",
streamGenAiSpans: true,
integrations: [
Sentry.vercelAIIntegration({ force: true }),
Sentry.httpIntegration({ disableIncomingRequestSpans: true }),
],
release: process.env.SENTRY_RELEASE,
environment:
process.env.NEON_BRANCH &&
process.env.NEON_BRANCH !== process.env.PRODUCTION_BRANCH
? process.env.NEON_BRANCH
: "production",
});
process.on("SIGTERM", () => void Sentry.flush(2000));
process.on("SIGINT", () => void Sentry.flush(2000));
export { Sentry };
```
```typescript
// src/index.ts
import "./instrument"; // MUST be the first import, before the framework/agent
import { Sentry } from "./instrument";
import { attachDatabasePool } from "@neon/functions";
import { Hono } from "hono";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool, {
onUnexpectedError: (err) => Sentry.captureException(err),
});
// ... rest of the function
```
- **Gate `enabled` on the DSN.** Local dev (`neon dev`) and any branch where you haven't configured the secret then become a no-op — no init, no noise — without changing code.
- **`enableLogs: true`** — the `Sentry.logger.*` API is off by default.
- **`traceLifecycle: "stream"`** — sends each span as it finishes instead of holding the whole tree until the request ends, so spans that complete after the response (streaming agent calls) aren't lost. `streamGenAiSpans: true` is the default on current SDKs (sends gen_ai spans as standalone items so large prompts aren't truncated); set it to `false` on self-hosted Sentry.
- **The two integrations:** `vercelAIIntegration({ force: true })` because `neon deploy` bundles your code, which defeats the integration's module detection; `httpIntegration({ disableIncomingRequestSpans: true })` because the request root span comes from the middleware in step 3 (the runtime's internal server would otherwise add a duplicate with an unhelpful name).
- **Environment:** `NEON_BRANCH` is injected on every branch — including the default — and holds the branch **name** (e.g. `main`, `preview/add-auth`). Because it's always present, don't use it as a boolean flag; compare it against your default branch's name (passed in as `PRODUCTION_BRANCH`) so the default branch reads as `production` and other branches tag by name. Pass `SENTRY_ENVIRONMENT` explicitly per deploy to override.
- **Flush on shutdown:** the runtime sends `SIGTERM`/`SIGINT` before evicting an idle isolate; Sentry buffers logs and batches spans, so flush or the tail gets dropped.
- **Idle `pg` pool errors:** call `attachDatabasePool(pool)` (or pass `onUnexpectedError: (err) => Sentry.captureException(err)` on the first call). Don't `pool.end()` on SIGINT — Neon's pooler reclaims those connections. See [Connecting to Postgres](../SKILL.md#connecting-to-postgres).
### 2. Provide the DSN as a deploy-time secret
The DSN is your own secret, so set it per-deployment (see [Environment Variables](../SKILL.md#environment-variables)). Either pass it on deploy:
```bash
neon functions deploy <slug> --src src/index.ts \
--env "SENTRY_DSN=https://…@…ingest.us.sentry.io/…" \
--env "SENTRY_RELEASE=$(git rev-parse --short HEAD)" \
--env "SENTRY_TRACES_SAMPLE_RATE=1"
```
or declare it under the function's `env` in `neon.ts` (read from `process.env` to avoid hardcoding). Deploy env vars **persist and accumulate across deployments** — omitting `--env` on a later deploy does not clear a variable set earlier.
### 3. Create the request span and catch route errors
The runtime invokes your handler through its own ingress rather than a plain `node:http` server, so give each request an isolation scope and a root span yourself — one Hono middleware covers it, and everything else (gen_ai spans, logs, outbound fetches) nests under it with clean route names. The `flush` at the end matters: an idle isolate can be suspended, so buffered telemetry has to ship while the request is alive.
```typescript
app.use("*", (c, next) =>
Sentry.withIsolationScope(() =>
Sentry.startSpan(
{
op: "http.server",
name: `${c.req.method} ${c.req.path}`,
forceTransaction: true,
attributes: {
"http.request.method": c.req.method,
"url.path": c.req.path,
},
},
async (span) => {
await next();
span.setAttribute("http.response.status_code", c.res.status);
},
).finally(() => Sentry.flush(2000)),
),
);
```
Then wire a top-level error handler so any error thrown in a route is reported. With Hono, `onError` covers this. Watch out for one gotcha: framework middleware such as `cors()` usually does **not** decorate error responses, so re-add any headers you need on the 500 yourself.
```typescript
app.onError((err, c) => {
Sentry.captureException(err);
c.header("access-control-allow-origin", "*"); // cors() doesn't run on error responses
return c.json({ error: "internal_error" }, 500);
});
```
(There is a dedicated `@sentry/hono` package, but it is alpha and its Node entry point assumes the app is served by `@hono/node-server`, which is not how Functions run Hono — stick with `onError`.)
### 4. Errors are for failures; logs are for the story
Long-running agent workloads — the case Neon Functions are built for — typically **catch their own errors and fall back** (retry a different model, return a degraded result) rather than throwing. It's tempting to `captureException` those too, but every recovered retry then opens a warning-level issue: the issue stream fills with things nobody needs to act on, and the terminal failures drown in them.
Split by whether someone needs to act:
- **`Sentry.captureException` — terminal, needs attention.** The agent exhausted every fallback; an invariant broke. These become issues, group, and alert.
- **`Sentry.logger.*` — recoverable or narrative.** A model attempt failed and the agent moved on; an input couldn't be fetched; a milestone was reached. Structured log records, searchable by attribute and attached to the request's trace — the story you read _after_ an issue fires.
A representative agent that tries several models in order:
```typescript
for (const model of models) {
try {
const { text: summary } = await generateText({
model: neon(model),
prompt,
experimental_telemetry: { isEnabled: true },
});
Sentry.logger.info("summary produced", { component: "agent", model });
return c.json({ summary, model });
} catch (err) {
lastError = err;
Sentry.logger.warn("model attempt failed", {
component: "agent",
phase: "summarize-attempt",
model,
error: String(err),
});
}
}
Sentry.captureException(lastError, {
tags: { component: "agent", phase: "summarize-all-failed" },
contexts: { agent: { attempts: models.length } },
});
return c.json({ error: "all models failed" }, 502);
```
- Log **attributes** (the second argument — flat `string | number | boolean` values) are individually searchable and filterable in Sentry's Logs view.
- On the remaining `captureException` calls, use `tags` for the dimensions you'll filter and group by, and `contexts` for structured per-event detail (`contexts` replaces the legacy `extra`).
- Logs emitted during a request automatically link to its trace, so from the terminal error you can pull up every preceding attempt.
### 5. Trace the agent itself
If the function runs a [Vercel AI SDK](https://ai-sdk.dev) agent (see [references/ai-sdk.md](ai-sdk.md)), Sentry captures the full agent → model → tool span hierarchy with token usage per call — `gen_ai.invoke_agent`, `gen_ai.generate_content`, `gen_ai.execute_tool` spans in the request's trace, plus the **Insights → AI Agents** dashboard. On top of the init config from step 1:
**Opt each call in** — the AI SDK only emits spans when asked — and **report stream errors**, because `streamText` never throws: failures surface as error parts inside the stream and the HTTP response just ends, so without `onError` a dead agent looks like an empty reply.
```typescript
const result = streamText({
model: neon(MODEL),
messages,
tools,
experimental_telemetry: { isEnabled: true },
onError: ({ error }) => {
Sentry.captureException(error, {
tags: { component: "agent", phase: "chat-stream" },
});
},
});
```
**Flush when the stream completes.** The middleware's flush runs when the `Response` object is created — before the model finishes — and the gen_ai spans only end with the stream. Ship them from the stream's own finalizer, while the request is still alive:
```typescript
const stream = result.textStream
.pipeThrough(
new TransformStream<string, string>({
async flush() {
await new Promise((r) => setTimeout(r, 0));
await Sentry.flush(2000);
},
}),
)
.pipeThrough(new TextEncoderStream());
return new Response(stream, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
```
(Once the runtime's `waitUntil` is no longer a preview stub, `waitUntil(Sentry.flush(2000))` is the cleaner way to express this.)
With telemetry enabled the AI SDK records prompts and outputs by default — set `recordInputs: false` / `recordOutputs: false` on the same `experimental_telemetry` object if conversation content must not leave the application. Optionally, group multi-turn chats into a timeline (**Explore → Conversations**) and attribute them to users — set both once per request before the model call:
```typescript
Sentry.setConversationId(chatId);
Sentry.setUser({ id: userId });
```
Direct provider SDKs (`openai`, `@anthropic-ai/sdk`, `@langchain/*`, `@google/genai`) have equivalent Sentry auto-instrumentation, but it patches those modules at import time — which bundling defeats. The Vercel AI SDK path is bundle-safe (the `ai` package emits its own OTel spans), which is why it's the recommended route here. The same caveat applies to other module-patching instrumentation (`pg` spans, for example): if a specific library's spans are missing from a deployed function, bundling is the first thing to check.
### Verifying the wiring
- **Errors:** temporarily add a route that throws (`app.get("/debug-sentry", () => { throw new Error("sentry test"); })`), hit it, confirm the 500 surfaces as an issue in Sentry, then remove the route.
- **Logs:** trigger a recoverable failure (e.g. pass a bogus model name to the fallback path) and confirm the `Sentry.logger` records show up in **Explore → Logs**, linked to the same trace.
- **Traces:** hit a real route and confirm a trace appears (**Explore → Traces**) with the spans you expect — the request root, outbound fetches, and (for agents) `gen_ai.*` spans with token counts. Two things to know before declaring it broken:
- **Streamed span ingestion lags several minutes behind errors and logs.** An error visible in seconds does not mean its trace is lost — check again after a few minutes.
- If spans **never** appear while errors and logs flow, check the SDK version — `@sentry/node` <10.67.0 cannot register its tracer against the runtime's pre-created OpenTelemetry API global. Upgrade, or on an older SDK run `delete globalThis[Symbol.for("opentelemetry.js.api.1")]` before `Sentry.init`.
## Other Node integrations
The same pattern generalizes to any Node integration (structured logging, analytics):
1. Initialize once at module scope in a dedicated init module, imported before your handler.
2. Gate it on an env var so local dev and unconfigured branches are a no-op.
3. Pass secrets via `--env KEY=VALUE` on deploy or the function's `env` in `neon.ts`.
Standard Node SDKs bundle through `neon deploy`'s esbuild without changes — but module-patching auto-instrumentation does not, and anything that registers OpenTelemetry globals contends with the runtime's own registration (see the version note at the top).
references/sse.md
# Server-sent events (SSE) on Neon Functions
SSE is the one-way (server → client) streaming counterpart to WebSockets: the browser opens a long-lived `GET` with [`EventSource`](https://developer.mozilla.org/en-US/docs/Web/API/EventSource) and the server pushes text frames down it. On Neon Functions an SSE endpoint is just a normal `fetch` handler that returns a `Response` whose body is a `ReadableStream` with `Content-Type: text/event-stream` — there is no library to install and nothing to upgrade. The runtime holds the response open as long as bytes keep flowing (15-minute heartbeat, see [Timeouts](../SKILL.md#timeouts-and-runtime-limits)).
Reach for SSE over WebSockets when you only need server → client updates (live counters, notifications, progress, token streams) — it's simpler to run (plain HTTP, no `upgrade`), and `EventSource` **reconnects on its own**, so there's no client backoff to write.
## Minimal SSE endpoint (no framework)
A function's default export is `{ fetch }`; SSE needs nothing more. Return a `ReadableStream` and write `data:` frames into it:
```typescript
// src/index.ts
const encoder = new TextEncoder();
export default {
fetch(request: Request): Response {
const url = new URL(request.url);
if (url.pathname !== "/events") return new Response("ok");
let timer: ReturnType<typeof setInterval>;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
// An SSE frame is `data: <payload>\n\n`. A line starting with `:` is a
// comment — used here as a heartbeat to keep the stream from going idle.
controller.enqueue(encoder.encode("data: hello\n\n"));
timer = setInterval(
() => controller.enqueue(encoder.encode(": ping\n\n")),
25_000,
);
},
// cancel() fires when the client disconnects.
cancel() {
clearInterval(timer);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
},
};
```
> `cancel()` is a method on the stream's underlying source; it fires when the client disconnects. Use it to drop the client from any broadcast set and clear timers. A cleanup function returned from `start()` is ignored, so it has to be a real `cancel()` method.
## With Hono
Hono routes the HTTP side; the SSE response is the same `ReadableStream`. Returning a raw `Response` keeps full control over the stream (and sidesteps concurrent-write edge cases in stream helpers):
```typescript
// src/index.ts
import { Hono } from "hono";
import { cors } from "hono/cors";
const app = new Hono();
app.use("*", cors({ origin: process.env.WEB_ORIGIN ?? "*" })); // EventSource is cross-origin from a SPA
app.get("/events", (c) => {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: connected\n\n"));
// ...register `controller` in a broadcast set; see fan-out below.
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
},
});
});
export default app;
```
## Push to every client, across isolates
If the data is database rows (live dashboards, feeds, chat over stored messages), use Neon Realtime (the `neon-realtime` skill) instead of hand-rolling fan-out. Otherwise, the fan-out rule is identical to WebSockets ([Fan-out across isolates](../SKILL.md#fan-out-across-isolates-do-not-skip-this)): each isolate keeps its **own** set of open streams, so broadcasting in-process only reaches the clients on that isolate. Hold a `Set` of stream controllers and pick a strategy there — **poll Postgres** by default (keeps Scale to Zero), or `LISTEN`/`NOTIFY` (shown below) for lowest latency on always-on compute. Keep the source-of-truth state in Postgres — module state doesn't survive eviction.
```typescript
import { attachDatabasePool } from "@neon/functions";
import { Pool, Client } from "pg";
const encoder = new TextEncoder();
const clients = new Set<ReadableStreamDefaultController<Uint8Array>>();
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);
const CHANNEL = "events";
// One dedicated DIRECT connection per isolate to receive events (LISTEN needs a
// real session — use DATABASE_URL_UNPOOLED, not the pooled URL).
// Don't call attachDatabasePool here: it would silence the idle drop that killed the feed.
// The error listener keeps the process alive; reconnect the client on error in production (omitted here).
const listener = new Client({
connectionString: process.env.DATABASE_URL_UNPOOLED,
});
listener.on("error", (err) => {
console.error(err);
});
listener.connect().then(() => listener.query(`LISTEN ${CHANNEL}`));
listener.on("notification", (msg) => {
if (!msg.payload) return;
const frame = encoder.encode(`data: ${msg.payload}\n\n`);
for (const controller of clients) {
try {
controller.enqueue(frame); // enqueue is synchronous — no concurrent-await hazard
} catch {
clients.delete(controller); // controller already closed
}
}
});
// Anywhere you mutate state, NOTIFY so every isolate pushes to its own streams.
function publish(payload: unknown) {
return pool.query("SELECT pg_notify($1, $2)", [
CHANNEL,
JSON.stringify(payload),
]);
}
```
Register/unregister each connection in `clients` from the stream's `start`/`cancel`, and add a module-scope heartbeat (`setInterval`, every ~25–30s) that enqueues `: ping\n\n` to every controller so idle streams stay alive (see [Caveats](#caveats)).
## Wire format (just text)
Each event is newline-delimited fields ending in a blank line:
```
data: a one-line payload\n\n
event: count\ndata: 42\n\n # named event → addEventListener("count", …)
id: 7\ndata: resumable\n\n # sets EventSource.lastEventId for resume
: this is a comment / heartbeat\n\n # ignored by the client; keeps the stream warm
retry: 5000\n\n # tells the client how long to wait before reconnecting
```
Send `data:` with no `event:` field to deliver the default `message` event, which the client reads with `EventSource.onmessage` (no `addEventListener` needed).
## Client
```typescript
const source = new EventSource(`${FUNCTION_URL}/events`); // GET only
source.onmessage = (e) => console.log("update", e.data);
source.onerror = () => {
/* EventSource auto-reconnects; nothing to do */
};
// source.close() to stop.
```
`EventSource` **reconnects automatically** with the server's `retry:` interval, replaying `Last-Event-ID` if you set `id:` — so unlike WebSockets you don't write a reconnect loop. Its constraints: it's **GET-only and can't set request headers**, so authenticate the same way as a [WebSocket](../SKILL.md#websocket-servers) — a `?token=` query param (verify with `jwtVerify` before streaming) or a cookie. (Use the modern `eventsource` polyfill if you need `Authorization` headers.)
## Caveats
- **Heartbeat or it dies.** Streams stay open only while bytes flow — Neon's window is 15 min ([Timeouts](../SKILL.md#timeouts-and-runtime-limits)), but intermediary proxies are usually far stricter (tens of seconds). Emit a `: ping\n\n` comment every ~25–30s so the stream never goes quiet.
- **`no-transform`.** Set `Cache-Control: no-cache, no-transform` so proxies don't buffer or rewrite the stream.
- **Enqueue is synchronous.** `controller.enqueue()` doesn't return a promise, so broadcasting from the `LISTEN` handler can't interleave awaits mid-write — wrap each in `try/catch` and drop dead controllers.
- **CORS.** A SPA hits the function cross-origin, so set `Access-Control-Allow-Origin`. `EventSource` sends no credentials by default, so `*` is fine for public streams.
- **One-way only.** SSE is server → client. For client → server, the browser makes normal `fetch`/`POST` calls (often to the same function); reach for [WebSockets](../SKILL.md#websocket-servers) only when you need bidirectional, low-latency frames.
Together — a Hono `fetch` SSE endpoint, cross-isolate fan-out, heartbeat, a counter persisted in Postgres, and a client-only TanStack Router SPA consuming it with `EventSource` — these compose into a complete push backend on a single function, for push that doesn't come from database rows (presence, typing, LLM streams) or where Realtime isn't available. For persisted chat, feeds, and other live UIs over database rows, use Neon Realtime (the `neon-realtime` skill).
SKILL.md
---
name: neon-functions
description: >-
Long-running, serverless Node.js HTTP functions deployed onto your Neon
branch, with DATABASE_URL injected automatically and compute that runs next
to your data. Use when a user wants to host an API, an AI agent with long
streaming responses, a WebSocket or server-sent-events (SSE) server, a
webhook handler, a Discord bot, an MCP server, or any request/response
workload that risks timing out on short, lambda-style serverless functions —
and wants it to branch with their database. Also use for Function Triggers:
a cron or an object-storage event that POSTs to a function. Triggers include
"serverless function", "deploy an API", "long-running function",
"streaming agent", "SSE server", "WebSocket server", "webhook handler",
"MCP server", "cron", "scheduled function", "object storage trigger",
"on upload", "function that won't time out", "function logs",
"Neon Functions", "Neon Compute", "DDoS protection", "rate limiting", and
"production hardening". For live UIs over database rows, use neon-realtime.
metadata:
parent: neon
source: https://github.com/neondatabase/agent-skills/tree/main/skills/neon-functions
---
**FIRST**: Use the parent `neon` skill for a Neon overview, getting started with Neon, Neon development best practices, and more.
If the `neon` skill is not installed, fetch it from https://neon.com/docs/ai/skills/neon/SKILL.md or install it with:
```bash
neon skills -s neon -y
```
# Neon Functions
Currently available in `aws-us-east-2`, `aws-us-east-1`, `aws-eu-central-1`, and `aws-ap-southeast-1`.
Neon Functions are long-running Node.js HTTP handlers deployed onto a Neon branch. Each function gets a public HTTPS URL, runs in the same region as your database, and — if the branch has Postgres — gets `DATABASE_URL` injected automatically. You deploy and manage them through the same Neon CLI, `neon.ts`, and API you already use.
Use this skill to help the user define, run locally, deploy, and manage functions next to their database. Deliver a deployed function with its invocation URL, a working local `neon dev` loop, or a precise answer from the official Neon docs.
## When to Use
Reach for Neon Functions when the workload is a request/response handler that benefits from staying alive and staying close to the data:
- **Long-running request/response flows that outlast lambda-style limits.** Agents that make several LLM calls and tool invocations per request, or image/video generation, routinely blow past the ~10–60s execution caps and short streaming windows of traditional serverless functions. Neon Functions are long-running: the handler just needs to _start_ responding within 15 minutes, and an open stream stays alive as long as bytes keep flowing. That's enough headroom for real agent workloads.
- **Stateful streaming without bolting on Redis.** Because a function stays alive across a request, it can host an SSE endpoint or a WebSocket server and hold the connection open in-process — no external state store (Redis, etc.) needed just to keep a stream coherent. Module-scope state (a `pg` pool, an in-memory counter) persists across requests on the same isolate.
- **Compute that must sit next to Postgres.** The function runs in the same region as the branch's database, so there are no cross-region round trips on every query. `DATABASE_URL` is injected for you.
- **A backend that branches with your data.** Each branch runs its own version of the function at its own URL, against its own isolated database (and storage, and gateway) state. Preview deployments, CI, and dev environments each get a self-contained backend — deploying to a child never affects the parent.
- **Query Postgres from the Function (or an existing framework handler).** Prefer that over the Data API. Use the Data API when the application already uses PostgREST or Supabase-js database calls, or is migrating that client.
- **Webhooks, bots, and post-response work.** Webhook handlers that fan out into multiple DB writes, Discord/WebSocket bots, and fire-and-forget follow-ups via `waitUntil` (analytics, audit logs) all fit.
- **Recurring HTTP work.** A Function Trigger POSTs to the function on a cron (`type: "schedule"`) or when an object is created in Object Storage (`type: "storage_object_created"`). Same `fetch` handler, same 15-minute time-to-first-byte limit. See [Function Triggers](#function-triggers).
If the workload is a pure static site, or something that must run outside the supported regions (`aws-us-east-2`, `aws-us-east-1`, `aws-eu-central-1`, and `aws-ap-southeast-1`) today, this isn't the right tool yet (see [Timeouts and Runtime Limits](#timeouts-and-runtime-limits) and [Availability](#availability)).
## What It Does
- **Long-running & serverless** — Built for WebSocket servers (see [WebSocket Servers](#websocket-servers)), SSE endpoints (see [Server-Sent Events (SSE)](#server-sent-events-sse)), long agent HTTP streams, and APIs. Still scales to zero when idle.
- **Web-standard handler** — A function is any default export with a `fetch(request)` method returning a `Response` (Workers/WinterTC-compatible). A Hono app exports exactly that shape, so `export default app` just works. Runs on Node.js 24, so all Node APIs are available.
- **Close to your database** — Runs in the branch's region; `DATABASE_URL` injected automatically when the branch has Postgres.
- **Branchable** — Each branch runs its own function version at its own URL against its own isolated state.
- **Same CLI/API** — Deploy and manage via `neon`, `neon.ts`, or the Neon API.
- **Function Triggers** — Neon POSTs to the function on a cron or an object-storage upload. See [Function Triggers](#function-triggers).
## Availability
Check this precondition before setting anything up: Neon Functions is currently available in `aws-us-east-2`, `aws-us-east-1`, `aws-eu-central-1`, and `aws-ap-southeast-1`. Confirm the user's Neon project is in one of these regions.
## Architecture: Where Functions Fit
Neon (Functions included) is **backend primitives, not full-stack app hosting**. Host your app on a frontend hosting provider like **Vercel** (or Netlify, Cloudflare, etc.), or serve a small static page from a `public_read` bucket behind a CDN (see the `neon-object-storage` skill for caching); Functions are the long-running, stateful slice of your backend that lives next to your data. They compose with that platform in two ways:
- **Add a Function to a full-stack app.** Your Next.js / TanStack Start app on Vercel (or Netlify) owns UI, auth (Managed Auth, Better Auth, Clerk, or another IdP), and talks directly to Lakebase Postgres and Object Storage. Add a Function as a Hono API layer for the web app and other clients, or for one job next to the data: Object Storage uploads, AI agents, Discord bots, WebSocket or SSE servers. (See [Functions as an Agent Backend](#functions-as-an-agent-backend-nextjs-and-similar-frameworks) for the client-direct pattern.)
- **Run the whole backend control plane on Functions.** Especially when the frontend is **client-only** — TanStack Router, React Router in client mode, and similar SPAs hosted on Vercel or Netlify — the client calls Functions **directly**. Build REST APIs and request/response agents, host **MCP servers**, and run anything stateful or that belongs close to Postgres and Object Storage.
Either way, authenticate by caller: JWT or API key for app and public HTTP (see the WARNING under [Functions as an Agent Backend](#functions-as-an-agent-backend-nextjs-and-similar-frameworks)); `parseTriggerDelivery` for Function Trigger routes; production hardening in [Production hardening](references/production-hardening.md). Because a Function is just your backend, you can **move pieces between your host and Neon** — relocate an agent or a stateful WebSocket server onto a Function when it needs more runtime, and back if needed.
Prefer a Function, or an existing framework handler, that queries Postgres. Use Data API when the application already uses PostgREST/Supabase-js database calls or is migrating that client.
## Production hardening
Before exposing production routes, read [Production hardening](references/production-hardening.md).
Pick by caller: trusted app server, Function Trigger, or public consumer. Keep long browser streams on the client-direct JWT path unless a verified streaming-compatible proxy is required. Authentication rejects application work; requests to the native URL still reach the Function.
## Setup
Functions are declared in `neon.ts` (see the `neon` skill for the branch-first workflow and `neon.ts` basics). Add `@neon/config` and declare functions under `functions`, keyed by **slug**:
```typescript
// neon.ts
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
todos: {
// slug: ^[a-z0-9]{1,20}$ — lowercase letters/digits, no hyphens
name: "todo api", // display label only
source: "src/index.ts", // entry file, relative to neon.ts
},
},
});
```
The slug is the function's permanent identity (it appears in the invocation URL and CLI commands) and can't be changed after the first deploy. Use `name` for a human-readable label.
A minimal function — a Hono app that queries the branch's Postgres via the injected `DATABASE_URL`:
```typescript
// src/index.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { parseEnv } from "@neon/env";
import { attachDatabasePool } from "@neon/functions";
import config from "../neon";
import { todos } from "./db/schema";
const env = parseEnv(config);
const pool = new Pool({ connectionString: env.postgres.databaseUrl, max: 5 });
attachDatabasePool(pool);
const db = drizzle(pool);
const app = new Hono();
app.get("/", (c) => c.text("Neon + Hono + Drizzle"));
app.post("/todos", async (c) => {
const { text } = await c.req.json<{ text: string }>();
const [row] = await db.insert(todos).values({ text }).returning();
return c.json(row, 201);
});
app.get("/todos", async (c) => c.json(await db.select().from(todos)));
export default app;
```
Create the `pg` pool at module scope (reused across requests on the same isolate) and keep `max` small (e.g. 5), since each isolate keeps its own pool. Call `attachDatabasePool(pool)` so an idle disconnect is not an `uncaughtException` — see [Connecting to Postgres](#connecting-to-postgres).
`parseEnv(config)` requires _every_ variable the config implies. A function that only talks to Postgres over the pooled URL can scope it to just that key — `parseEnv` then validates and returns only what you asked for (the keys autocomplete from your `neon.ts`):
```typescript
const { postgres } = parseEnv(config, ["DATABASE_URL"]); // not the unpooled URL, auth, etc.
const pool = new Pool({ connectionString: postgres.databaseUrl, max: 5 });
attachDatabasePool(pool);
```
## Develop Locally and Deploy
```bash
neon dev # serves every function in neon.ts with hot reload; injects DATABASE_URL & friends
neon deploy --env <file> # preferred full deploy from neon.ts; --env is the file Function env is read from
```
Keep `.env` or `.env.local` up to date with every key under `functions.*.env`. `neon env pull` writes Neon-managed vars only; add Function secrets to that file, then pass it as `--env`. `neon deploy --env <file>` loads that file into `process.env` each time, then uploads the declared values. Declare each secret as `{ value: process.env.X, required: true }` (see "Your own secrets" below): an unset required value fails `neon deploy` and `neon dev` before anything changes, and never fails an import of `neon.ts`. Never coerce a missing value to `""` (that uploads `""` and deletes the live key); an empty assignment (`KEY=`) counts as unset.
To deploy a single function without applying `neon.ts`: `neon functions deploy <slug> --src src/index.ts` (`--src` takes either the entry file or a directory containing `index.ts`, `index.mjs`, or `index.js`). That command's `--env` is `KEY=VALUE` (repeatable), not a file path. Use it for a targeted env update. Retrieve the public URL with `neon functions get <slug>` (the `invocation_url` field, of the form `https://<branch_id>-<slug>.compute.<cell>.us-east-2.aws.neon.tech`). Manage with `neon functions list|get|delete`.
When `neon checkout` _creates_ a new branch and a `neon.ts` is present, it applies the policy automatically. Pass `--env <file>` on that create so Function env that reads `process.env` resolves (`neon checkout feat --create --env .env.local`). Existing process env wins over the file. Checking out an existing branch never reconciles it — apply config changes with `neon deploy --env <file>` (add `--update-existing` only after reviewing those changes).
### Native binaries and custom builds
The default deploy bundles `source` into one `index.mjs`, which cannot hold a compiled binary. Ship one as a separate archive file:
- npm package with a Node addon (`sharp`): `externalPackages: ["sharp"]`. The deploy installs its linux-arm64 glibc build and copies the files in.
- Your own build output (`.node`, `.so`, `.wasm`, data files): `bundler: "none"` with a prebuilt directory, or an inline `bundler: async (fn) => ({ "index.mjs": …, "bin/tool": … })`.
- A standalone executable you spawn: ship it as above, then copy it to `/run` and `chmod 755` it at startup. Files in `/opt/function` arrive as mode `644` on a read-only filesystem, and `/tmp` is `noexec`.
Runtime target, pros and cons, working snippets, and size limits: [references/native-binaries.md](references/native-binaries.md).
## Neon Infrastructure as Code (`neon.ts`)
The `functions` block from [Setup](#setup) is part of `neon.ts`, Neon's infrastructure-as-code file — one TypeScript file declares every function (its `source`, display `name`, and `env`) alongside any other branch services, in version control (see the `neon` skill for the full reference). Treat it like Terraform for your branch:
```bash
neon config status # print the branch's live config (deployed functions)
neon config plan # dry-run diff of what apply would change
neon config apply --env <file> # bundle + deploy the declared functions (neon deploy is an alias; pass --env when Function env reads process.env)
```
Functions are **branch-scoped**: each branch runs its own deployment at its own URL. When a `neon.ts` is present, `neon checkout` applies the policy as it _creates_ a branch. Pass `--env <file>` on that create when Function env reads `process.env`. Checking out an _existing_ branch doesn't redeploy — run `neon deploy --env <file>` to apply changes.
Per-branch deploy tuning (e.g. `runtime`) lives in the `branch` closure, keyed by slug, so it can vary by branch without changing which functions exist:
```typescript
export default defineConfig({
functions: { todos: { name: "todo api", source: "src/index.ts" } },
branch: (branch) => ({
functions: { todos: { runtime: "nodejs24" } },
}),
});
```
## Environment Variables
Neon injects branch-scoped connection strings and service URLs at runtime — you don't declare these or pass them at deploy time:
| Variable | Notes |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `NEON_BRANCH` | The branch **name** (e.g. `main`, `preview/foo`). Injected on every branch, including the default. |
| `NEON_BRANCH_ID` | The branch **id** (`br-…`). Injected on every branch. |
| `NEON_FUNCTION_HOST_SUFFIX` | The hostname suffix every function on the branch shares (`.compute.c-2.us-east-2.aws.neon.tech`). See [How to get the function URL](#how-to-get-the-function-url). |
| `DATABASE_URL` | Pooled connection string. Use for most queries. Present only if the branch has Postgres. |
| `DATABASE_URL_UNPOOLED` | Direct connection. Use for migrations, `LISTEN`/`NOTIFY`, multi-round-trip transactions. |
| `NEON_AUTH_BASE_URL` | Present when Neon Auth is enabled on the branch. |
| `NEON_AUTH_JWKS_URL` | Present when Neon Auth is enabled on the branch. JWKS for verifying Managed Auth JWTs. |
| `NEON_DATA_API_URL` | Present when the Data API is enabled on the branch. |
Object storage (`AWS_*`) and AI Gateway (`NEON_AI_GATEWAY_*`) vars are also injected when those services are declared — see the `neon-object-storage` and `neon-ai-gateway` skills.
`neon env pull` / `neon-env run` / `neon dev` emit `NEON_BRANCH`, `NEON_BRANCH_ID`, `NEON_FUNCTION_HOST_SUFFIX` (and the connection strings) into your local dev environment too, so local runs mirror the deployed runtime.
**Your own secrets** are per-deployment. Preferred path: declare them in `neon.ts` and run `neon deploy --env <file>`. `<file>` is the gitignored file env pull already writes (`.env` if that file exists, otherwise `.env.local`). Env pull writes Neon-managed vars only; add Function secrets to that file. Declare each one with both `value` and `required` (needs `@neon/config` 1.13.0 and `neon` 9.2.0 or later, and `@neon/env` 2.1.0 or later where the function calls `parseEnv`; older `parseEnv` treats every declared key as required):
```typescript
functions: {
todos: {
name: "todo api",
source: "src/index.ts",
env: {
RESEND_API_KEY: { value: process.env.RESEND_API_KEY, required: true },
SENTRY_DSN: { value: process.env.SENTRY_DSN, required: "default-branch" },
LOG_LEVEL: "info",
},
},
}
```
| `required` | Unset deploying the default branch | Unset deploying another branch | Unset in `neon dev` | `parseEnv(config, slug).function` |
| --- | --- | --- | --- | --- |
| `true` | fails before any change | fails before any change | fails before any function starts | `string` |
| `"default-branch"` | fails before any change | removed from the deployment | runs without it | `string \| undefined` |
| `false` | removed from the deployment | removed from the deployment | runs without it | `string \| undefined` |
An undefined or empty value counts as unset; put a fallback in `value` (`process.env.X ?? "1"`). `"default-branch"` is the branch Neon marks as default, not a branch name. Add `onUndefined: "skip"` to a `required: false` or `"default-branch"` variable to keep its deployed value instead of removing it, for a value set with `neon functions deploy --env`. Keys `neon.ts` does not declare are left alone.
Prefer a declaration over a string such as `process.env.X!`. A string is validated wherever `neon.ts` is imported, so a function that calls `parseEnv(config, "<slug>")` fails to load when another function's secret is missing from its runtime. A declaration is checked only when a command needs its value.
`neon functions deploy --env KEY=VALUE` is the manual path (repeatable; `--env KEY=` deletes a key; unmentioned keys carry over). Use it for a targeted env update, not a full `neon.ts` apply.
Load Function secrets into the same file env pull wrote, then `neon deploy --env <file>`. Pull the branch's Neon-managed vars onto disk for local dev with `neon env pull` (`link`/`checkout` do this automatically; pass `--no-env-pull` to skip and use `neon-env run -- <cmd>` for runtime injection). Limits: ≤1,000 vars, ≤64 KiB total, and the `NEON_` prefix is reserved.
## How to get the function URL
A function answers at `https://<NEON_BRANCH_ID>-<slug><NEON_FUNCTION_HOST_SUFFIX>`, for example `https://br-cool-lab-a1b2c3d4-todos.compute.c-2.us-east-2.aws.neon.tech`. Each branch has its own URL.
**From a terminal:** `neon functions get <slug>` prints it as `invocation_url`. `neon deploy` prints it for each function it deploys.
**From code** (another function, a script, or an app after `neon env pull`): call `functionBaseUrl(slug)` from `@neon/env` (2.0.0 or later). It returns the base URL with no trailing slash:
```typescript
import { functionBaseUrl } from "@neon/env";
const res = await fetch(`${functionBaseUrl("todos")}/api/todos`);
```
| Where the code runs | `functionBaseUrl("todos")` returns |
| ------------------------------------------ | --------------------------------------------------- |
| A deployed function on the branch | `https://<branch_id>-todos.compute.…` (runtime env) |
| An app or script after `neon env pull` | the same URL, from the pulled env |
| `neon dev`, when it serves `todos` | `http://localhost:<port>` |
| `neon dev`, when it does not serve `todos` | the deployed URL on the linked branch |
It reads `process.env` by default. Pass the two variables explicitly when they come from elsewhere: `functionBaseUrl("todos", { NEON_BRANCH_ID, NEON_FUNCTION_HOST_SUFFIX })`. It throws when either variable is missing; run `neon env pull` (or `neon env pull -s functions` for only the branch variables) to fix that locally. The function does not have to be deployed for the URL to resolve, but the request fails until it is.
Browser code has no access to these variables. Build the URL on the server with `functionBaseUrl` and pass it to the client.
Older releases wrote one `NEON_FUNCTION_<SLUG>_BASE_URL` per function and exposed `env.functions.<slug>.baseUrl`. `neon` 9 and `@neon/env` 2 removed both, and `neon env pull` deletes those lines. Replace them with `functionBaseUrl("<slug>")`.
## Connecting to Postgres
When the branch has Postgres, Neon **injects the connection strings at runtime** — you don't declare them, pass them at deploy time, or hardcode anything. The two you'll use:
- `DATABASE_URL` — **pooled** connection string (routed through Neon's connection pooler). Use it for normal request/response query traffic. Kept un-prefixed because every Postgres ORM (Drizzle, Prisma, Knex, …) reads `DATABASE_URL` by default.
- `DATABASE_URL_UNPOOLED` — **direct** connection string to the same database. Use it for migrations, `LISTEN`/`NOTIFY`, and long multi-statement transactions.
**Use Drizzle (or another ORM) on top of node-postgres (`pg`)** for queries and schema management — not Neon's serverless driver. Functions are long-running and reuse an isolate across many requests, so a persistent `pg` pool is the right fit; the serverless driver's HTTP transport is meant for fully isolated, lambda-style runtimes.
Create the connection pool **once at module scope** and reuse it across requests — don't open a connection per request:
```typescript
import { attachDatabasePool } from "@neon/functions";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);
const db = drizzle(pool);
```
node-postgres emits idle-client failures as `error` on the pool. With no listener that is an `uncaughtException` and Node exits the isolate. Call `attachDatabasePool(pool)` once after `new Pool`. Requires `@neon/functions` ≥ 0.8.0. Expected idle disconnects (`ECONNRESET`, `EPIPE`, `ETIMEDOUT`, Postgres `57P01`, node-postgres's `Connection terminated unexpectedly`) are silent. Anything else is `console.error`, or `onUnexpectedError` if you pass it on the first call. The first call wins; a later call that passes `onUnexpectedError` is ignored and warns. This does not close the pool.
**Pooling is recommended because an isolate is reused across many requests** (and several requests can be in flight on the same isolate at once — see [Timeouts and Runtime Limits](#timeouts-and-runtime-limits)). A module-scope pool is opened once on cold start and then shared by every subsequent request that isolate serves, so you amortize connection setup instead of paying it on every request and you avoid exhausting Postgres connections under load.
Keep `max` small (e.g. `5`): each isolate keeps its own pool, so total connections to Postgres scale with the number of live isolates. You don't need to close the pool on shutdown — when the runtime evicts an isolate it sends `SIGINT`/`SIGTERM`, and Neon's pooler reclaims those connections for you, so an explicit drain handler is redundant.
> Reading `process.env.DATABASE_URL` directly works everywhere. The function in [Setup](#setup) instead uses `@neon/env`'s `parseEnv(config)` to read the same value in a typed, validated way — either is fine.
## Timeouts and Runtime Limits
Functions are long-running but **still serverless** — they are a request/response runtime, not a background job runner. The hard limits:
- **Time to first byte: 15 minutes.** Your handler must _begin_ returning a response within 15 minutes of receiving a request. Most handlers finish in seconds; the 15-minute ceiling exists so agent workloads like image/video generation have room.
- **Heartbeat: 15 minutes.** Open WebSocket/SSE connections stay alive as long as data flows. The timeout only fires when a connection goes silent — send at least one byte every 15 minutes to keep a quiet stream alive.
- **`waitUntil`: 15 minutes.** Work registered with `waitUntil` (from `@neon/functions`) keeps the invocation alive after the response is sent, up to 15 minutes — for cleanup like analytics writes and audit logs, **not** a background job runner. Off the Neon runtime (local `neon dev`, tests) it's a no-op: the promise still runs but isn't tracked.
- **Idle eviction.** With no active connections Neon shuts the function down; it may also evict/restart for operational reasons — e.g. maintenance, or moving the function to a different compute node (active functions can run for hours first). Treat eviction like a process restart — WebSocket/SSE clients must reconnect. Neon sends `SIGINT` before evicting, so a `process.on("SIGINT", ...)` handler lets you detect that the function is about to be evicted and run any last-minute cleanup. You don't need one just to close Postgres connections — Neon's pooler reclaims those on its own.
- **Runtime:** Node.js 24, memory fixed at 2048 MiB. Slugs must match `^[a-z0-9]{1,20}$`. **An isolate is reused across many requests** — multiple requests can be in flight on the same isolate at once (interleaved on Node's single-threaded event loop), and under load the runtime runs several isolates in parallel, each with its own copy of module state. State held in module scope is therefore per-isolate (shared by every request that isolate handles) and in-memory only — persist anything that must survive eviction in Postgres. This reuse is exactly why you create a connection pool once at module scope rather than per request (see [Connecting to Postgres](#connecting-to-postgres)).
## Functions as an Agent Backend (Next.js and Similar Frameworks)
A Neon Function is a great home for an AI agent precisely because it **doesn't time out** the way lambda-style serverless does (15-minute budget, see [Timeouts and Runtime Limits](#timeouts-and-runtime-limits)). Proxying that stream through a Next.js route handler, Remix/SvelteKit/Nuxt action, or similar hosted on Vercel, Netlify, and the like **cuts the stream when it exceeds that host's configured duration or transport limits**, even though the Function would keep going. Keep the client-direct JWT path below as the default. A streaming-compatible proxy (HTTP-triggered Cloudflare Worker, after you verify the stream) is the public-consumer exception in [Production hardening](references/production-hardening.md).
**Building the agent itself.** The [Vercel AI SDK](https://ai-sdk.dev) and [Mastra](https://mastra.ai) are the recommended ways to build the agent — point either at the Neon AI Gateway (see the `neon-ai-gateway` skill) for one credential across every model, with no extra provider keys. For a complete AI SDK agent running as a Function (streaming `toUIMessageStreamResponse`, multi-step tool calling next to Postgres, and persisting generated images to Object Storage), see [references/ai-sdk.md](https://neon.com/docs/ai/skills/neon-functions/references/ai-sdk.md); for the Mastra equivalent with built-in tracing, see [references/mastra-studio.md](https://neon.com/docs/ai/skills/neon-functions/references/mastra-studio.md).
**The fix: call the function directly from the client.** Don't route the long request through your app server.
```
Browser ──(Authorization: Bearer <JWT>)──▶ Neon Function (agent) ✅ no host timeout
Browser ──▶ your app backend ──▶ Neon Function ❌ host cuts the stream
```
- Get a **short-lived bearer token** from the identity the app already uses. Do not switch Clerk, Better Auth, Auth.js, Supabase Auth, or Managed Auth in order to call a Function.
- Managed Auth, default client (`createAuthClient` / Next wrapper): `authClient.token()`, then `data.token`. Verify with injected `NEON_AUTH_JWKS_URL` and issuer `new URL(process.env.NEON_AUTH_BASE_URL!).origin`.
- Managed Auth with `SupabaseAuthAdapter()`: that client has no `.token()`. Use `getSession()`, then `data.session.access_token`. Same JWKS/issuer as above.
- Existing Better Auth / Auth.js / other signer that already publishes JWKS: use that JWKS URL, issuer, and audience. Inspect the installed contract; cookie or database sessions are not a JWKS.
- Cookie/database sessions only: mint a short token on the existing app backend (that call is fast and stays within host limits), then the browser calls the Function **directly** with `Authorization: Bearer`. The Function stream must not go through the app host.
- Hand the token to the client, e.g. with the Vercel AI SDK: `new DefaultChatTransport({ api: NEON_FUNCTION_URL, fetch })` where `fetch` attaches `Authorization: Bearer <token>`. Your app server is never in the path of the long stream.
- Add **CORS** so the browser can reach it (handle `OPTIONS`, set `Access-Control-Allow-Origin`/`-Headers`).
> [!WARNING]
> A Neon Function has a **public HTTPS URL — it is reachable by anyone.** A direct client→function call means there is no app backend in front of it to gate access, so **you must authenticate the function yourself.** Verify a JWT against the caller's JWKS, check a shared secret / API key, or reject the request. Never deploy an unauthenticated agent. Browser callers use short-lived user tokens. Server or proxy origin secrets (`X-Secret`) stay server-side; see [Production hardening](references/production-hardening.md).
```typescript
// src/index.ts — verify the caller before doing any work
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL(process.env.NEON_AUTH_JWKS_URL!));
const issuer = new URL(process.env.NEON_AUTH_BASE_URL!).origin;
export default {
async fetch(request: Request) {
if (request.method === "OPTIONS")
return new Response(null, { status: 204, headers: cors(request) });
const auth = request.headers.get("authorization");
if (!auth?.toLowerCase().startsWith("bearer ")) {
return new Response("Unauthorized", {
status: 401,
headers: cors(request),
});
}
let userId: string;
try {
const { payload } = await jwtVerify(auth.slice(7), jwks, { issuer });
if (!payload.sub) {
return new Response("Unauthorized", {
status: 401,
headers: cors(request),
});
}
userId = payload.sub;
} catch {
return new Response("Unauthorized", {
status: 401,
headers: cors(request),
});
}
// Authorize resource access by userId, then run the agent scoped to that user.
// ... return result.toUIMessageStreamResponse({ headers: cors(request) })
},
};
```
That snippet is Managed Auth verification. Mint the bearer token with `.token()` (`data.token`) on the default client, or `getSession()` then `data.session.access_token` on `SupabaseAuthAdapter()`. For another identity, pass that app's JWKS URL and issuer through Function `env` (see [Environment Variables](#environment-variables)) and include `audience` only when that token contract requires it. https://neon.com/docs/compute/functions/authentication.md
A valid token is not permission to read another user's rows. Exercise two users: each can access their own data; cross-user access is denied. Repeat after restarting the Function against stored rows. A request-supplied owner id cannot grant access.
Persist anything you need to keep (generated images, history) in Postgres — module state doesn't survive eviction.
## WebSocket Servers
A WebSocket server is the canonical Functions workload: a long-running handler holds connections open in-process, with no external state store needed to keep a stream coherent. The connection stays alive as long as bytes flow (15-minute heartbeat, see [Timeouts](#timeouts-and-runtime-limits)).
**Upgrade from inside `fetch`.** Call `upgradeWebSocket(request)` from [`@neon/functions`](https://www.npmjs.com/package/@neon/functions) and return the response it gives you. Hono apps use the same primitive via `@neon/functions/hono` (see [Hono](#hono) below). There is one entrypoint and no WebSocket dependency to install:
```typescript
import { upgradeWebSocket } from "@neon/functions";
export default {
async fetch(req: Request): Promise<Response> {
if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
return new Response("expected a websocket upgrade", { status: 426 });
}
const { socket, response } = upgradeWebSocket(req);
socket.addEventListener("message", (event) => socket.send(event.data));
return response;
},
};
```
`socket` is a standard [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket), so `addEventListener` and the `onopen`/`onmessage`/`onclose`/`onerror` properties both work. It is still `CONNECTING` when you get it — the runtime writes the `101` only once your handler returns `response`, and the socket opens then.
Three rules that matter:
- **Return `response` unchanged.** A `101` can't be built as a plain `Response` (the fetch spec caps constructed responses at 200–599), so the runtime hands back an object carrying the pending upgrade. `clone()`, or rebuilding it with `new Response(res.body, res)` as response-rewriting middleware does, discards the upgrade and fails the request.
- **Refuse a handshake by returning an ordinary `Response`.** Return a `401`, `403`, or `404` from `fetch`, before you upgrade, to gate a socket. A browser client can't read why a handshake was refused; it sees only a generic connection failure, not your status or body. Refuse to keep clients out, but send any detail the client needs over a separate authenticated request.
- **`binaryType` defaults to `"arraybuffer"`**, not the browser's `"blob"`. `event.data` is a `string` for text frames and an `ArrayBuffer` for binary ones, so branch on `typeof`.
**With auth.** Browsers can't set headers on a WebSocket, so authenticate with a `?token=` query param (verify it the same way as the [agent backend](#functions-as-an-agent-backend-nextjs-and-similar-frameworks): `jwtVerify` against your JWKS) and refuse before upgrading:
```typescript
// src/index.ts
import { upgradeWebSocket } from "@neon/functions";
const clients = new Set<WebSocket>();
export default {
async fetch(request: Request): Promise<Response> {
if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
return new Response("WebSocket endpoint — connect with ?token=<jwt>");
}
const url = new URL(request.url);
const identity = await verifyToken(url.searchParams.get("token"));
if (!identity) return new Response("unauthorized", { status: 401 });
const { socket, response } = upgradeWebSocket(request);
clients.add(socket);
socket.addEventListener("close", () => clients.delete(socket));
socket.addEventListener("message", (event) => {
if (typeof event.data !== "string") return;
persist(identity.id, event.data); // fan out to every isolate — see below
});
return response;
},
};
```
**Subprotocols.** Pass `{ protocol }` to select one the client offered; it is echoed in `Sec-WebSocket-Protocol` and exposed as `socket.protocol`. Selecting one the client did not offer throws a `TypeError`. Omit it and no protocol is negotiated. No extensions are negotiated either — `socket.extensions` is always `""` and `permessage-deflate` is not available.
**Hono.** Use `upgradeWebSocket` from `@neon/functions/hono` — the same primitive as Hono's own WebSocket helper, with no `ws` dependency and not the deprecated `@hono/node-ws`. Auth is ordinary middleware; gate upgrade requests before `next()`:
```typescript
// src/index.ts
import { Hono } from "hono";
import { upgradeWebSocket } from "@neon/functions/hono";
const clients = new Set<WebSocket>();
const app = new Hono<{ Variables: { userId: string } }>();
app.use("/ws", async (c, next) => {
const identity = await verifyToken(c.req.query("token"));
if (!identity) return c.text("Unauthorized", 401);
c.set("userId", identity.id);
await next();
});
app.get(
"/ws",
upgradeWebSocket((c) => ({
onOpen(_event, ws) {
clients.add(ws.raw);
ws.send("welcome");
},
onClose(_event, ws) {
clients.delete(ws.raw);
},
onMessage(event, ws) {
ws.send(`echo: ${event.data}`);
},
})),
);
export default app;
```
Connect from the browser with the function's `wss://` URL (from `neon functions get <slug>`), for example `new WebSocket("wss://<branch>-<slug>.compute.<region>.aws.neon.tech/ws?token=<jwt>")`. Reconnect on close — isolates are evictable and idle connections may be terminated after 15 minutes.
Do not put `cors()` on the upgrade route, and do not read `c.res` before `await next()` or call `c.header()` after it — both rebuild the `101` and break the upgrade. See `@neon/functions` README for the full middleware table.
### Heartbeat (keep the socket alive)
A connection stays open **only while bytes flow**: Neon evicts a silent stream after 15 minutes ([Timeouts and Runtime Limits](#timeouts-and-runtime-limits)), and intermediary proxies / load balancers are usually far stricter (often tens of seconds). Don't rely on the app being chatty enough — send a periodic keepalive from the server so the socket never goes quiet.
The standard `WebSocket` interface has no `ping()`, so send an application-level message the client filters out:
```typescript
const HEARTBEAT_MS = 25_000; // comfortably under proxy idle timeouts
const beat = setInterval(() => {
for (const socket of clients) {
if (socket.readyState === socket.OPEN) socket.send('{"type":"ping"}');
}
}, HEARTBEAT_MS);
beat.unref?.();
```
The client skips these when handling messages. There is no protocol-level shortcut here: the standard `WebSocket` from `upgradeWebSocket` has no `ping()`, and a browser can't send ping frames from JavaScript, so an application-level message is the only keepalive a browser client can use. (A Node `ws` client can send ping frames, and the server auto-replies with a pong, but a browser can't.)
### Fan-out across isolates (do not skip this)
Under load the runtime runs **several isolates in parallel, each with its own copy of module state** — so each isolate has its own `clients` set. Broadcasting only to that local set means a client on isolate A never sees an event produced on isolate B, and the feed silently fractures. It's easy to miss: `neon dev` runs a single process (one isolate), so in-process broadcast always _looks_ fine locally but breaks in production, where concurrent connections spread across many isolates.
If the data is database rows (live dashboards, feeds, chat over stored messages), use Neon Realtime (the `neon-realtime` skill) instead of hand-rolling fan-out. Using Realtime does not prevent your database from scaling to zero. The strategies below are for push that doesn't come from database rows (presence, typing, LLM streams), and the fallback where Realtime isn't available, such as on Claimable projects until they're claimed.
Module state doesn't survive eviction anyway, so **Postgres is the shared source of truth**. Pick a fan-out strategy. In every snippet below, `pool` is a pooled `pg` client and `clients` is this isolate's `Set` of live connections.
**1. Poll Postgres — the default, and it keeps Scale to Zero.** Each isolate re-reads the shared state (or rows past a cursor) on a short interval and pushes changes to its own clients. One query per isolate per tick (not per client), and none when the isolate has no clients — so an idle compute still suspends.
```typescript
let lastId = "0"; // bigint id, so a string
let polling = false;
async function poll() {
if (polling || clients.size === 0) return; // guard overlap; no clients → no query → compute can scale to zero
polling = true;
try {
const { rows } = await pool.query(
"SELECT id, payload FROM events WHERE id > $1 ORDER BY id",
[lastId],
);
for (const { id, payload } of rows) {
lastId = id;
for (const socket of clients) {
if (socket.readyState === socket.OPEN) socket.send(payload);
}
}
} catch (err) {
console.error("[poll]", err);
} finally {
polling = false;
}
}
// Seed from the latest id so a fresh isolate sends only new rows, not the whole table, then poll.
pool
.query("SELECT coalesce(max(id), 0)::text AS id FROM events")
.then((seed) => {
lastId = seed.rows[0].id;
})
.catch((err) => console.error("[seed]", err))
.finally(() => setInterval(poll, 1000).unref?.());
```
- **Latency:** up to the interval (~1s).
- **Scaling:** database load grows with the number of live isolates, not clients. Keep the cursor on an indexed `serial`/`bigserial` PK and the interval sane.
- **Scale to Zero:** ✅ preserved — polling stops when no clients are connected, so the compute suspends on its normal timer.
- **Ordering:** `WHERE id > cursor` can skip a row that commits out of sequence: a transaction that took a lower id but commits after a higher one is already behind the cursor, so the poll never returns it. For a broadcast feed occasional loss is usually fine; when you need every row, use `LISTEN`/`NOTIFY` or poll by `created_at` with a small overlap window and dedupe by id.
**2. `LISTEN`/`NOTIFY` — lowest latency, but requires disabling Scale to Zero.** Each isolate `LISTEN`s on a channel over a dedicated **unpooled** connection; broadcasting is `NOTIFY`, so every isolate (including the sender's) re-pushes to its sockets. Near-instant — but the listener holds an idle connection that **does not count as active**, so [Scale to Zero](https://neon.com/docs/introduction/scale-to-zero) suspends the compute and drops it, silently killing the feed. Only use it on an **always-on** compute (Scale to Zero disabled — a paid-plan setting: `neon postgres computes update <compute-id> --suspend-timeout -1`).
```typescript
import { attachDatabasePool } from "@neon/functions";
import { Pool, Client } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
attachDatabasePool(pool);
const CHANNEL = "chat_events";
// One dedicated DIRECT connection per isolate, just to receive events.
// Use DATABASE_URL_UNPOOLED — LISTEN needs a real session, not a pooled one.
// Don't call attachDatabasePool here: it would silence the idle drop that killed the feed.
// The error listener keeps the process alive; reconnect the client on error in production (omitted here).
const listener = new Client({
connectionString: process.env.DATABASE_URL_UNPOOLED,
});
listener.on("error", (err) => {
console.error(err);
});
listener.connect().then(() => listener.query(`LISTEN ${CHANNEL}`));
listener.on("notification", (msg) => {
if (!msg.payload) return;
for (const socket of clients) {
if (socket.readyState === socket.OPEN) socket.send(msg.payload);
}
});
// Broadcast by NOTIFYing through the pool — every isolate's listener fires.
function broadcast(event: unknown) {
return pool.query("SELECT pg_notify($1, $2)", [
CHANNEL,
JSON.stringify(event),
]);
}
```
**3. External pub/sub (e.g. [Upstash](https://upstash.com) Redis) — best at scale.** For high fan-out, sub-second latency at large connection counts, or multi-region, publish/subscribe through a dedicated broker. Highest throughput, and it doesn't touch Postgres or block Scale to Zero — at the cost of another service to run.
**Rule of thumb:** for database rows, use **Neon Realtime**. Otherwise start with **polling** (works with Scale to Zero, no extra infra); switch to `LISTEN`/`NOTIFY` only on always-on compute that needs sub-second latency; move to Redis when fan-out outgrows Postgres.
### Client must reconnect
Idle functions are evicted (and isolates restart for operational reasons), so a client's socket **will** drop — treat reconnection as normal, not exceptional. Reconnect with exponential backoff, capped, and **re-mint a fresh token on every attempt** (tokens are short-lived, so a stale one fails the `upgrade` auth check):
```typescript
let closed = false,
retry = 0,
timer: ReturnType<typeof setTimeout>;
async function connect() {
if (closed) return;
const token = await getToken(); // re-mint each attempt; short-lived
const ws = new WebSocket(`${WS_URL}?token=${encodeURIComponent(token)}`);
ws.onopen = () => {
retry = 0; // reset backoff on success
};
ws.onmessage = (e) => {
/* apply the event */
};
ws.onclose = () => {
if (!closed)
timer = setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000));
};
ws.onerror = () => ws.close(); // let onclose drive the retry
}
connect();
```
Together — `upgradeWebSocket` inside `fetch`, JWT auth over `?token=`, cross-isolate fan-out, and client backoff — these compose into a complete WebSocket backend on a single function, for push that doesn't come from database rows (presence, typing, LLM streams) or where Realtime isn't available. For persisted chat, feeds, and other live UIs over database rows, use Neon Realtime (the `neon-realtime` skill).
## Server-Sent Events (SSE)
When you only need **server → client** streaming (live counters, notifications, progress, token streams), SSE is simpler than a WebSocket and needs no upgrade at all: a plain `fetch` handler returns a `Response` whose body is a `ReadableStream` with `Content-Type: text/event-stream`, and the runtime holds it open as long as bytes flow. The browser consumes it with `EventSource`, which **reconnects on its own** — so there's no client backoff to write.
```typescript
// src/index.ts — minimal SSE endpoint
const encoder = new TextEncoder();
export default {
fetch: () => {
let t: ReturnType<typeof setInterval>;
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("data: hello\n\n"));
t = setInterval(
() => controller.enqueue(encoder.encode(": ping\n\n")),
25_000,
);
},
cancel() {
clearInterval(t); // fires when the client disconnects
},
}),
{
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
},
},
);
},
};
```
The same rules as WebSockets apply. **Heartbeat:** a stream stays open only while bytes flow — Neon's window is 15 minutes ([Timeouts and Runtime Limits](#timeouts-and-runtime-limits)) but proxies are usually far stricter, so emit a `: ping\n\n` comment every ~25–30s (shown above) to keep idle streams from being dropped. Keep state in Postgres, and fan out across isolates using one of the [fan-out strategies](#fan-out-across-isolates-do-not-skip-this) (hold a `Set` of stream controllers and `enqueue` to each). `EventSource` is GET-only and can't set headers, so authenticate with a `?token=` query param or cookie, exactly like the WebSocket case. [references/sse.md](https://neon.com/docs/ai/skills/neon-functions/references/sse.md) has the full pattern — Hono variant, cross-isolate fan-out, wire format, client, and caveats.
## Function Triggers
A Function Trigger POSTs JSON to your function on a cron (`schedule`) or when an object is created in Object Storage (`storage_object_created`). Declare it in `neon.ts`, apply with `neon deploy`, and authenticate the delivery with `parseTriggerDelivery` (`@neon/functions/triggers`). `parseTrigger` (Hono) and `parseTriggerInvocation` stay schedule-only. Prefer `neon.ts`; CLI and the Neon MCP trigger tools (`list_triggers`, `create_trigger`, …) are the backup.
Trigger routes must not require a user JWT or `X-Secret`; Neon POSTs to the native URL without those. Production caller shapes: [references/production-hardening.md](references/production-hardening.md). Full field list, CLI, MCP, payload, inheritance, and both handler shapes: [references/function-triggers.md](references/function-triggers.md).
## MCP Servers
An [MCP](https://modelcontextprotocol.io) server is a natural Functions workload: a long-running HTTP handler that exposes tools to AI clients (Cursor, Claude, ChatGPT, agents), with those tools reading and writing the branch's Postgres right next to the compute. MCP's **streamable HTTP transport** is a plain `POST`/`GET` on a single endpoint (conventionally `/mcp`), so it maps onto a function's `fetch` handler with no `upgrade` method or extra protocol.
The simplest host is a Hono app using the official [`@modelcontextprotocol/sdk`](https://github.com/modelcontextprotocol/typescript-sdk) plus [`@hono/mcp`](https://github.com/honojs/middleware/tree/main/packages/mcp), which bridges the transport to a route. Build the server, register its tools, and create the transport once at module scope, then hand every `/mcp` request to it:
```typescript
const transport = new StreamableHTTPTransport();
app.all("/mcp", async (c) => {
if (!mcpServer.isConnected()) await mcpServer.connect(transport);
return transport.handleRequest(c);
});
```
Because the function's URL is public, **authenticate before connecting the transport** — Managed Better Auth is the OAuth server for third-party MCP clients (guard the Function with `@neon/oauth`; see the `neon-auth` skill's [oauth-server.md](https://neon.com/docs/ai/skills/neon-auth/references/oauth-server.md)), and a simpler API-key / session-JWT check covers your own callers. Public-consumer edge protection: [references/production-hardening.md](references/production-hardening.md). [references/mcp.md](https://neon.com/docs/ai/skills/neon-functions/references/mcp.md) has the full pattern — server with Postgres-backed tools via Drizzle, the auth options, and testing with `mcporter` / `add-mcp`.
## Integrations and Observability
### Built-in branch logs
```bash
neon logs query --branch production --source function --since 1h
```
Functions is one of the two sources branch logs cover today, alongside Object Storage. Logs are scoped to a single branch, so pass `--branch` when the deployed function isn't on the branch you're checked out on. Everything else about logs — the required CLI version, filters, the SDK, and the Loki-compatible read API — is in the parent `neon` skill's **Observability** section.
### Application instrumentation
A function is a long-lived Node.js process running a web-standard request/response handler, so standard Node integration SDKs work unchanged. Initialize them once at module load, gated on an env var so local dev and unconfigured branches stay a no-op, and pass secrets via `--env` or `neon.ts` `env`.
- **Sentry** — error monitoring across the HTTP framework, the function runtime, and an agent's own caught/fallback failures (the long-running case Functions target): see [references/sentry.md](https://neon.com/docs/ai/skills/neon-functions/references/sentry.md).
- **Mastra Studio (Mastra Cloud)** — run a Mastra agent on a function and ship its traces to a Studio project for observability: see [references/mastra-studio.md](https://neon.com/docs/ai/skills/neon-functions/references/mastra-studio.md).
## Neon Documentation
The Neon documentation is the source of truth and Functions is evolving rapidly, so always verify against the official docs. Any doc page can be fetched as markdown by appending `.md` to the URL or by requesting `Accept: text/markdown`. Find the right page from the docs index (https://neon.com/docs/llms.txt) and the changelog announcements.
## Further Reading
- https://neon.com/docs/compute/functions/overview.md
- https://neon.com/docs/compute/functions/get-started.md
- https://neon.com/docs/compute/functions/deploy.md
- https://neon.com/docs/compute/functions/environment-variables.md
- https://neon.com/docs/compute/functions/reference/neon-ts.md
- https://neon.com/docs/compute/functions/reference/runtime-limits.md
- https://neon.com/docs/compute/functions/authentication.md
- https://neon.com/docs/compute/functions/custom-domains.md
- https://neon.com/docs/cli/triggers.md
- [references/function-triggers.md](references/function-triggers.md)
- [references/native-binaries.md](references/native-binaries.md)
- [references/production-hardening.md](references/production-hardening.md)