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({
preview: {
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({
outputFormat: "jpeg",
quality: "low", // the gateway caps a response near 640 KB — keep images small
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)
}
},
});
```
Keep generated images small: the gateway caps a single response near 640 KB and has an upstream timeout, so request a compressed JPEG rather than a full-size PNG.
## 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/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 `preview.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`, resolved from `process.env` at deploy time:
```typescript
// neon.ts
functions: {
myapp: {
name: "my app",
source: "src/index.ts",
env: {
MASTRA_PROJECT_ID: process.env.MASTRA_PROJECT_ID!,
MASTRA_PLATFORM_ACCESS_TOKEN: process.env.MASTRA_PLATFORM_ACCESS_TOKEN!,
},
},
}
```
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).
[Better Auth](https://better-auth.com) (self-hostable, runs alongside your app) is a good fit, and it covers both common shapes. **Better Auth is evolving quickly** — the MCP plugin is moving out of `better-auth/plugins` into its own `@better-auth/mcp` package (built on the OAuth Provider plugin), which renames `withMcpAuth` → `requireMcpAuth` and `createMcpAuthClient` → `createMcpResourceClient`. Verify the current package and import paths against the [Better Auth MCP docs](https://better-auth.com/docs/plugins/mcp) before wiring it up.
### Option 1 — OAuth via the Better Auth MCP plugin (best for third-party clients)
The [MCP plugin](https://better-auth.com/docs/plugins/mcp) makes your **Better Auth app the OAuth authorization server** for MCP, implementing the MCP authorization spec end to end: discovery (`/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`), dynamic client registration, and the consent/token flow. MCP clients that support OAuth (Cursor, Claude, ChatGPT) then sign the user in and obtain a token with no API key to copy around.
Your Neon Function is the **resource server** — a separate service from the Better Auth app, so it doesn't share a process. Use Better Auth's **remote MCP client** to validate the incoming Bearer token against the auth server's published JWKS, and serve the protected-resource metadata so clients can discover where to authenticate:
```typescript
// src/index.ts (sketch) — verify the bearer token against your remote Better Auth server.
// Import path/name depend on your Better Auth version (createMcpAuthClient in better-auth/plugins/mcp/client,
// or createMcpResourceClient in @better-auth/mcp/client) — check the docs.
import { createMcpAuthClient } from "better-auth/plugins/mcp/client";
const mcpAuth = createMcpAuthClient({ authURL: process.env.AUTH_URL }); // your Better Auth base URL
app.all("/mcp", async (c) => {
const session = await mcpAuth.verify?.(c.req.raw); // verifies the Bearer token via the remote JWKS
if (!session) {
// Tell the client where to authenticate (RFC 9728 / MCP spec).
return c.json({ error: "unauthorized" }, 401, {
"WWW-Authenticate": `Bearer resource_metadata="${process.env.AUTH_URL}/.well-known/oauth-protected-resource"`,
});
}
if (!mcpServer.isConnected()) await mcpServer.connect(transport);
return transport.handleRequest(c); // scope tools to session.userId
});
```
Pass `AUTH_URL` (and any signing/JWKS config) to the function via its `env` in `neon.ts` (see [Environment variables](../SKILL.md#environment-variables)). Because the function only verifies tokens against the remote server, the Better Auth instance can live anywhere — typically your Next.js / app host on Vercel.
### Option 2 — API key or session JWT via self-hosted Better Auth (simplest)
When the callers are your own agents/services or a personal MCP server, you don't need the full OAuth dance. Run Better Auth self-hosted and either:
- **API keys** — enable Better Auth's [API Key plugin](https://better-auth.com/docs/plugins/api-key), issue a key, and have the function verify the `Authorization: Bearer <key>` (or an `x-api-key` header) on every request; or
- **Session JWT** — mint a short-lived JWT with Better Auth's `jwt` plugin and verify it in the function against the app's JWKS, the same `jose` 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/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
The fan-out rule is identical to WebSockets ([Keeping clients in sync across isolates](../SKILL.md#keeping-clients-in-sync-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 realtime backend on a single function.
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. Triggers include "serverless
function", "deploy an API", "long-running function", "streaming agent",
"SSE server", "WebSocket server", "webhook handler", "MCP server",
"run code next to my database", "function that won't time out",
"function logs", "Neon Functions", and "Neon Compute".
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
npx skills add neondatabase/agent-skills --skill neon
```
# Neon Functions
This is a public beta feature, currently available in `us-east-2` and `eu-central-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.
- **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.
If the workload is a pure static site, a cron/background job that needs its own lifecycle and cancellation, or something that must run outside the supported regions (`us-east-2`, `eu-central-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.
## Availability
Check this precondition before setting anything up: Neon Functions is a public beta feature currently available in `us-east-2` and `eu-central-1`. Confirm the user's Neon project is in one of these regions. Functions usage isn't billed during the public beta.
## Architecture: Where Functions Fit
Neon (Functions included) is **backend primitives, not full-stack app hosting**. Host your app on **Vercel** (or Netlify, or another frontend/app host); 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 (e.g. Neon Auth), and talks directly to Lakebase Postgres and Object Storage. When one workload outgrows the host's short serverless limits — a WebSocket or SSE server, or a long-running agent that would time out — move just that piece onto a Neon Function. (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, secure a Function like any standalone REST API: verify a JWT or API key at the top of the handler (see the WARNING under [Functions as an Agent Backend](#functions-as-an-agent-backend-nextjs-and-similar-frameworks)). 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.
## 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 `preview.functions`, keyed by **slug**:
```typescript
// neon.ts
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
preview: {
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 `preview.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 those values. A missing value is `undefined` and `defineConfig` throws. Omit the key from `neon.ts` if you do not want to write it. Never coerce a missing `process.env` value to an empty string (that uploads `""` and deletes the live key). An empty assignment (`KEY=`) is also `""`. Use `process.env.X!` when TypeScript needs an assertion.
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. That create-apply does not load `--env`. If Function env reads `process.env`, run `neon deploy --env <file>` after checkout (add `--update-existing` if checkout already created the branch). Checking out an existing branch does not re-deploy; run `neon deploy --env <file>` explicitly.
## Neon Infrastructure as Code (`neon.ts`)
The `preview.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. That create-apply does not load `--env`. If Function env reads `process.env`, run `neon deploy --env <file>` after checkout. 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({
preview: {
functions: { todos: { name: "todo api", source: "src/index.ts" } },
},
branch: (branch) => ({
preview: { 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. |
| `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_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` (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. All declared Function env keys must be present. Omit a key from `neon.ts` if you do not want to write it. `undefined` means you asked to write the key and the value is missing (`defineConfig` throws). Never coerce a missing `process.env` value to an empty string: that uploads `""` and deletes the live key. An empty assignment in the file (`KEY=`) is also `""`. If TypeScript needs an assertion, use `process.env.X!` and make sure the file has the value:
```typescript
functions: {
todos: {
name: "todo api",
source: "src/index.ts",
env: { RESEND_API_KEY: process.env.RESEND_API_KEY! },
},
}
```
`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.
## 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 during the preview. 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)). But that advantage disappears the moment you **proxy the agent stream through your web app's backend** — a Next.js route handler, Remix/SvelteKit/Nuxt action, etc. hosted on Vercel, Netlify, Cloudflare, and the like. Those platforms cap serverless/edge execution at short windows (often ~10–60s, sometimes up to ~300s), so a long agent or image/video generation stream gets cut off mid-response even though the Neon Function would happily keep going.
**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
```
- Mint a **short-lived JWT** on your app backend (e.g. better-auth's `jwt` plugin, NextAuth, or your own signer) — that call is fast and well within host limits.
- Hand the token to the client and have it call the Neon Function **directly** (cross-origin), 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 (e.g. against your app's JWKS), check a shared secret / API key, or validate a session token at the top of the handler and reject anything else. Never deploy an unauthenticated agent.
```typescript
// src/index.ts — verify the caller before doing any work
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(
new URL(`${process.env.AUTH_BASE_URL}/api/auth/jwks`),
);
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),
});
}
try {
const { payload } = await jwtVerify(auth.slice(7), jwks, {
issuer: process.env.AUTH_BASE_URL,
audience: process.env.AUTH_BASE_URL,
});
const userId = payload.sub; // scope the agent to this user
// ... run the agent, return result.toUIMessageStreamResponse({ headers: cors(request) })
} catch {
return new Response("Unauthorized", {
status: 401,
headers: cors(request),
});
}
},
};
```
Pass the JWKS/issuer URL to the function via its `env` (see [Environment Variables](#environment-variables)). 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.)
### Keeping clients in sync 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.
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 the only option that 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) — fine for counters, chat, and dashboards.
- **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).
```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:** 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 realtime chat backend on a single function.
## 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 [sync strategies](#keeping-clients-in-sync-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.
## 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** — [Better Auth](https://better-auth.com) covers both OAuth (its MCP plugin makes your app the authorization server so third-party clients self-authorize per the MCP spec) and a simpler API-key / session-JWT check for your own callers. [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, both Better Auth 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/preview-access.md