
Neon Functions
- 649 installs
- 82 repo stars
- Updated August 4, 2026
- neondatabase/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
neon-functions is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- neon-functions
- AI & Agent Building
- AI-coding skill
Neon Functions by the numbers
- 649 all-time installs (skills.sh)
- +119 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,497 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/neondatabase/agent-skills --skill neon-functionsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 649 |
|---|---|
| repo stars | ★ 82 |
| Last updated | August 4, 2026 |
| Repository | neondatabase/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Neon Functions
This is a preview feature and only available in us-east-2. 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 neonctl 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
pgpool, 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_URLis 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 us-east-2 today, this isn't the right tool yet (see Timeouts and Availability below).
What It Does
- Long-running & serverless — Built for WebSocket servers (see WebSocket servers), SSE endpoints (see 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 aResponse(Workers/WinterTC-compatible). A Hono app exports exactly that shape, soexport default appjust works. Runs on Node.js 24, so all Node APIs are available. - Close to your database — Runs in the branch's region;
DATABASE_URLinjected 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
neonctl,neon.ts, or the Neon API.
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 Neon 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 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). 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 @neondatabase/config and declare functions under preview.functions, keyed by slug:
// neon.ts
import { defineConfig } from "@neondatabase/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:
// src/index.ts
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { parseEnv } from "@neondatabase/env";
import config from "../neon";
import { todos } from "./db/schema";
const env = parseEnv(config);
const pool = new Pool({ connectionString: env.postgres.databaseUrl, max: 5 });
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.
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):
const { postgres } = parseEnv(config, ["DATABASE_URL"]); // not the unpooled URL, auth, etc.
const pool = new Pool({ connectionString: postgres.databaseUrl, max: 5 });Develop locally and deploy
neonctl dev # serves every function in neon.ts with hot reload; injects DATABASE_URL & friends
neonctl deploy # bundles with esbuild, uploads, and applies neon.ts to the linked branchTo deploy a single function without neon.ts: neonctl functions deploy <slug> --path . --entry src/index.ts. Retrieve the public URL with neonctl functions get <slug> (the invocation_url field, of the form https://<branch_id>-<slug>.compute.c-1.us-east-2.aws.neon.tech). Manage with neonctl functions list|get|delete.
When neonctl checkout _creates_ a new branch and a neon.ts is present, it applies the policy automatically — deploying the function to the fresh branch. Checking out an existing branch does not re-deploy; run neonctl deploy explicitly.
Neon Infrastructure as Code (neon.ts)
The preview.functions block from 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:
neonctl config status # print the branch's live config (deployed functions)
neonctl config plan # dry-run diff of what apply would change
neonctl config apply # bundle + deploy the declared functions (neonctl deploy is an alias)Functions are branch-scoped: each branch runs its own deployment at its own URL. When a neon.ts is present, neonctl checkout applies the policy as it _creates_ a branch, so a fresh preview/CI branch comes up with the function already deployed. Checking out an _existing_ branch doesn't redeploy — run neonctl deploy 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:
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 (OPENAI_*, NEON_AI_GATEWAY_*) vars are also injected when those services are declared — see the neon-object-storage and neon-ai-gateway skills.
neonctl env pull / neon-env run / neonctl 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. Set them with --env KEY=VALUE on neonctl functions deploy (repeatable; --env KEY= deletes a key, unmentioned keys carry over), or declare them in neon.ts under the function's env (resolved at deploy time, so read from process.env to avoid hardcoding):
functions: {
todos: {
name: "todo api",
source: "src/index.ts",
env: { OPENAI_API_KEY: process.env.OPENAI_API_KEY! },
},
}Load a .env before deploy with neonctl deploy --env .env.production. Pull the branch's Neon-managed vars onto disk for local dev with neonctl 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, …) readsDATABASE_URLby 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:
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
// Created once per isolate; reused by every request that isolate handles.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
const db = drizzle(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). 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. Drain the pool when the runtime evicts the isolate so connections close cleanly:
process.on("SIGINT", () => {
pool.end().then(() => process.exit(0));
});Readingprocess.env.DATABASE_URLdirectly works everywhere. The function in Setup instead uses@neondatabase/env'sparseEnv(config)to read the same value in a typed, validated way — either is fine.
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. Because a function is a real Node.js process (not a lambda), the WebSocket handshake works the way it does in any Node server — the `ws` library upgrades the socket, and the connection stays alive as long as bytes flow (15-minute heartbeat, see Timeouts).
The return signature is the whole trick. A function's default export is normally { fetch }. To also accept WebSockets, export an upgrade method alongside it — the runtime routes plain HTTP to fetch and the WebSocket handshake to upgrade:
export default {
fetch(request: Request): Response | Promise<Response> { /* HTTP */ },
async upgrade(req: IncomingMessage, socket: Duplex, head: Buffer) { /* WS handshake */ },
};Simple example — raw ws, no framework, 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: jwtVerify against your JWKS) before accepting the connection:
// src/index.ts
import type { IncomingMessage } from "node:http";
import type { Duplex } from "node:stream";
import { WebSocketServer, type WebSocket } from "ws";
const clients = new Set<WebSocket>();
const wss = new WebSocketServer({ noServer: true });
export default {
// Plain HTTP (health checks, REST) is handled by fetch.
fetch: () => new Response("WebSocket endpoint — connect with ?token=<jwt>"),
// The runtime hands the WebSocket handshake to upgrade().
async upgrade(req: IncomingMessage, socket: Duplex, head: Buffer) {
const url = new URL(req.url ?? "/", "http://localhost");
const identity = await verifyToken(url.searchParams.get("token")); // reject if invalid
if (!identity) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
clients.add(ws);
ws.on("close", () => clients.delete(ws));
ws.on("message", (data) => broadcast(data.toString())); // see fan-out below
});
},
};Hono variant. If you only need Hono for the HTTP side and are happy driving ws yourself, just swap fetch in the simple example for app.fetch and keep the raw upgrade — Hono serves routing/middleware, ws serves the socket.
To instead declare WebSocket routes _inside_ the Hono app — app.get("/ws", upgradeWebSocket(...)) with the standard onOpen/onMessage/onClose lifecycle — you need an adapter that bridges Hono's upgradeWebSocket() helper to Neon's upgrade(req, socket, head). Hono ships adapters for Cloudflare/Deno/Bun/Node, but none for Neon, and the Node one (@hono/node-ws) is deprecated and assumes it owns the HTTP server. references/hono-websockets.md has a small self-contained createNeonWebSocket(app) adapter to copy in — it depends only on hono and ws (no deprecated package; adapted from @hono/node-ws, MIT) and returns a ready-to-export { fetch, upgrade } handler. Usage is idiomatic Hono, and because the handshake routes through app.request, auth is just normal route middleware:
// src/index.ts
import { Hono } from "hono";
import { createNeonWebSocket } from "./hono-ws";
const app = new Hono();
const { upgradeWebSocket, handler } = createNeonWebSocket(app);
app.get(
"/ws",
async (c, next) => {
if (!(await verifyToken(c.req.query("token")))) return c.text("Unauthorized", 401);
await next();
},
upgradeWebSocket(() => ({
onOpen: (_evt, ws) => ws.send("welcome"),
onMessage: (evt, ws) => ws.send(`echo: ${evt.data}`),
onClose: () => console.log("disconnected"),
})),
);
export default handler; // Neon's { fetch, upgrade } contractDon't put header-modifying middleware (e.g. CORS) on an upgradeWebSocket route — the helper rewrites headers internally and will throw. The fan-out and reconnect guidance below applies unchanged.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), 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 ping from the server so the socket never goes quiet. ws.ping() sends a WebSocket ping frame and the browser answers with a pong automatically, so there's no client code to write:
const HEARTBEAT_MS = 25_000; // comfortably under proxy idle timeouts
const beat = setInterval(() => {
for (const ws of clients) if (ws.readyState === ws.OPEN) ws.ping();
}, HEARTBEAT_MS);
beat.unref?.();
process.on("SIGINT", () => clearInterval(beat));(With the Hono upgradeWebSocket helper you don't hold the raw socket, so send an application-level keepalive instead — e.g. ws.send("ping") on the same interval, ignored by the client.)
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 the local set means a client connected to isolate A never sees a message sent by a client on isolate B. The chat would silently fracture.
Fan out across every isolate with Postgres `LISTEN`/`NOTIFY`: each isolate LISTENs on a channel over a dedicated unpooled connection, and broadcasting means NOTIFY (so every isolate, including the sender's, re-broadcasts to its own sockets). This is also why message state must live in Postgres, not module memory — module state doesn't survive eviction.
import { Pool, Client } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
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.
const listener = new Client({ connectionString: process.env.DATABASE_URL_UNPOOLED });
listener.connect().then(() => listener.query(`LISTEN ${CHANNEL}`));
listener.on("notification", (msg) => {
if (!msg.payload) return;
for (const ws of clients) if (ws.readyState === ws.OPEN) ws.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)]);
}
// Drain both on eviction so connections close cleanly.
process.on("SIGINT", () => {
Promise.allSettled([pool.end(), listener.end()]).then(() => process.exit(0));
});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):
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 — Hono fetch + ws upgrade, JWT auth over ?token=, LISTEN/NOTIFY 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 method or extra library: 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.
// src/index.ts — minimal SSE endpoint
const encoder = new TextEncoder();
export default {
fetch: () =>
new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode("data: hello\n\n"));
const t = setInterval(() => controller.enqueue(encoder.encode(": ping\n\n")), 25_000);
return () => 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) 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 with `LISTEN`/`NOTIFY` (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 has the full pattern — Hono variant, cross-isolate fan-out, wire format, client, and caveats.
MCP servers
An MCP 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 — a Hono app using the official `@modelcontextprotocol/sdk` plus `@hono/mcp` (which bridges the transport to a route) is the simplest host. Build the server, register its tools, and create the transport once at module scope, then hand every /mcp request to it:
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 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 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
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. For wiring up 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. For running a Mastra agent on a function and shipping its traces to a Mastra Studio (Mastra Cloud) project for observability, see references/mastra-studio.md.
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
waitUntilkeeps 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. (waitUntilfrom@neondatabase/functionsis currently a stub during the preview.) - Idle eviction. With no active connections the platform shuts the function down; it may also evict/restart for operational reasons (active functions can run for hours first). Treat eviction like a process restart — WebSocket/SSE clients must reconnect. The platform sends
SIGINTbefore evicting, so register a handler to drain gracefully:
process.on("SIGINT", () => {
pool.end().then(() => process.exit(0));
});- 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).
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 above). 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 and Mastra 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; for the Mastra equivalent with built-in tracing, see 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
jwtplugin, 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 })wherefetchattachesAuthorization: Bearer <token>. Your app server is never in the path of the long stream. - Add CORS so the browser can reach it (handle
OPTIONS, setAccess-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.
// 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). Persist anything you need to keep (generated images, history) in Postgres — module state doesn't survive eviction.
Availability
Neon Functions is a preview (early access) feature available only on new projects in the us-east-2 region. Confirm the user's Neon project is a new project in us-east-2; it can't be enabled on existing projects. Functions usage isn't billed during the private preview. If the user does not yet have access, point them to the private beta sign-up: https://neon.com/blog/were-building-backends#access
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
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 agent: the handler keeps streaming for the life of the request (15-minute budget, see Timeouts), 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); 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 — neonctl deploy provisions them and injects the credentials at runtime (see the neon-ai-gateway and neon-object-storage skills):
// neon.ts
import { defineConfig } from "@neondatabase/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 @neondatabase/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:
// src/index.ts
import { neon } from "@neondatabase/ai-sdk-provider";
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 });
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-2-5-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):
import { neon } from "@neondatabase/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 for the JWT-verify + CORS pattern and the AI SDK DefaultChatTransport wiring.
5. Run and deploy
neonctl dev # injects DATABASE_URL + the gateway/storage creds; hot reload
neonctl deploy # provisions the gateway + bucket and deploys the functioncurl -N -X POST "$(neonctl 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
@neondatabase/ai-sdk-provider: theneon-ai-gatewayskill - Storing generated assets that branch with the database: the
neon-object-storageskill - AI SDK agents/tools: https://ai-sdk.dev/docs/foundations/agents
Hono WebSocket helper on Neon Functions
Neon Functions accept WebSockets via a { fetch, upgrade } default export, where upgrade(req, socket, head) is the raw Node handshake (see the WebSocket servers section). That works directly with the ws library. If you'd rather declare WebSocket routes _inside_ a Hono app — app.get("/ws", upgradeWebSocket(...)) with the standard onOpen/onMessage/onClose lifecycle — you need an adapter.
Why an adapter (and why not @hono/node-ws)
Hono's upgradeWebSocket() is runtime-agnostic at the route layer, but the actual handshake is done by a per-runtime adapter (hono/cloudflare-workers, hono/deno, hono/bun, @hono/node-server). There is no adapter for Neon. The Node one, @hono/node-ws, is deprecated and its replacement assumes it owns the HTTP server (serve({ websocket })) — which Neon's runtime does instead.
So vendor the small adapter below. It depends only on hono and ws (no deprecated package), and is adapted from @hono/node-ws (MIT). Instead of attaching to an http.Server's 'upgrade' event, it returns a ready-to-export { fetch, upgrade } handler that matches Neon's contract.
The adapter
// src/hono-ws.ts — bridges Hono's upgradeWebSocket() to Neon's { fetch, upgrade }.
import { STATUS_CODES, type IncomingMessage } from "node:http";
import type { Duplex } from "node:stream";
import type { Hono } from "hono";
import { WSContext, defineWebSocketHelper } from "hono/ws";
import { WebSocketServer, type WebSocket } from "ws";
type Wire = (ws: WebSocket) => void;
export function createNeonWebSocket(app: Hono, baseUrl = "http://localhost") {
const wss = new WebSocketServer({ noServer: true });
// Correlate a handshake to its route handler via the per-request env object identity.
const pending = new Map<unknown, Wire>();
const upgradeWebSocket = defineWebSocketHelper(async (c, events, options) => {
if (c.req.header("upgrade")?.toLowerCase() !== "websocket") return;
const url = c.req.url;
pending.set(c.env, (ws) => {
const onError = options?.onError ?? ((e: unknown) => console.error(e));
const ctx = new WSContext<WebSocket>({
send: (data, opts) => ws.send(data, { compress: opts?.compress }),
close: (code, reason) => ws.close(code, reason),
raw: ws,
url,
protocol: ws.protocol,
get readyState() {
return ws.readyState;
},
});
try {
events.onOpen?.(new Event("open"), ctx);
} catch (e) {
onError(e);
}
ws.on("message", (data, isBinary) => {
for (const chunk of Array.isArray(data) ? data : [data]) {
try {
const payload = isBinary
? chunk instanceof ArrayBuffer
? chunk
: chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength)
: chunk.toString("utf-8");
events.onMessage?.(new MessageEvent("message", { data: payload }), ctx);
} catch (e) {
onError(e);
}
}
});
ws.on("close", (code, reason) => {
try {
events.onClose?.(new CloseEvent("close", { code, reason: reason.toString() }), ctx);
} catch (e) {
onError(e);
}
});
// Node 24 has no global ErrorEvent; a browser's ws.onerror gets a plain Event anyway.
ws.on("error", (error) => {
onError(error);
try {
events.onError?.(new Event("error"), ctx);
} catch (e) {
onError(e);
}
});
});
return new Response();
});
const handler = {
fetch: (request: Request) => app.fetch(request),
async upgrade(req: IncomingMessage, socket: Duplex, head: Buffer) {
const url = new URL(req.url ?? "/", baseUrl);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value) headers.append(key, Array.isArray(value) ? value[0] : value);
}
// The env object identity links this request back to the route handler above.
const env = { incoming: req, outgoing: undefined };
const response = await app.request(url, { headers }, env);
const wire = pending.get(env);
pending.delete(env);
if (!wire) {
socket.end(`HTTP/1.1 ${response.status} ${STATUS_CODES[response.status] ?? ""}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
return;
}
wss.handleUpgrade(req, socket, head, (ws) => wire(ws));
},
};
return { upgradeWebSocket, handler };
}How it works: the upgrade handler runs the handshake request through app.request(...) so Hono's router matches the upgradeWebSocket route; the helper registers a wire callback keyed by the per-request env object; if a route matched, wss.handleUpgrade accepts the socket and wire attaches the lifecycle handlers, otherwise the socket is closed with the route's status (e.g. 401/404).
Usage
Because the handshake is routed through app.request, auth and other gating are just normal Hono middleware on the route — verify the ?token= and return 401 before the upgrade:
// src/index.ts
import { Hono } from "hono";
import { createNeonWebSocket } from "./hono-ws";
const app = new Hono();
const { upgradeWebSocket, handler } = createNeonWebSocket(app);
app.get("/", (c) => c.text("ok"));
app.get(
"/ws",
async (c, next) => {
const identity = await verifyToken(c.req.query("token")); // your JWT check
if (!identity) return c.text("Unauthorized", 401);
await next();
},
upgradeWebSocket(() => ({
onOpen: (_evt, ws) => ws.send("welcome"),
onMessage: (evt, ws) => ws.send(`echo: ${evt.data}`),
onClose: () => console.log("disconnected"),
})),
);
export default handler; // Neon's { fetch, upgrade } contractCaveats
- No header-modifying middleware on the WS route. Per Hono's docs, middleware that changes headers (e.g. CORS) on an
upgradeWebSocketroute throws ("can't modify immutable headers"), because the helper rewrites headers internally. Auth middleware that only reads the query/headers and returns 401 is fine. - Send a heartbeat. A socket is dropped once it goes silent — Neon's window is 15 minutes (Timeouts), but intermediary proxies are usually far stricter (tens of seconds). Send a keepalive every ~25–30s so the connection never goes quiet. With the raw
wssocket usews.ping()(the browser auto-replies with a pong); through this helper you don't hold the raw socket, so send an app-levelws.send("ping")the client ignores instead. See Heartbeat. - Fan-out and reconnect still apply. This adapter only covers the per-isolate handshake. For a genuinely shared chat across isolates, broadcast with Postgres
LISTEN/NOTIFY, and have clients reconnect with backoff — see Fan-out across isolates and Client must reconnect. - Node-only globals. Relies on
Event,MessageEvent, andCloseEvent(all present on Neon's Node 24 runtime).ErrorEventis intentionally avoided since it isn't a Node global.
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 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 references/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 neonctl deploy's esbuild with no extra config.
1. Define the agent against the Neon AI Gateway
Use the gateway's MLflow (chat-completions) dialect, which serves every provider (OpenAI, Anthropic, …) — derive it from the injected aiGateway.baseUrl (see the neon-ai-gateway skill). parseEnv reads the injected gateway credentials from your neon.ts.
// src/mastra/agents/pricing.ts
import { Agent } from "@mastra/core/agent";
import { parseEnv } from "@neondatabase/env";
import config from "../../../neon";
const env = parseEnv(config);
const gatewayUrl = env.aiGateway.baseUrl.replace("/openai/v1", "/mlflow/v1");
export const pricingAgent = new Agent({
id: "pricing-analyst",
name: "pricing-analyst",
instructions: "You are a meticulous pricing analyst. …",
model: { id: "neon/gpt-5-mini", url: gatewayUrl, apiKey: env.aiGateway.apiKey },
});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).
// 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:
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 myZodSchemaFor 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 — the same provider-fallback pattern works because both are reachable on the MLflow dialect.
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:
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 yourMASTRA_PLATFORM_ACCESS_TOKEN. - Project: the interactive
mastra studio projects createTUI is hard to script. Instead, register the project as part of a Studio deploy, which is non-interactive with-yand writes the project id to.mastra-project.json:
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.deployand.env.local),studio deployerrors withMultiple env files found; pass--env-file <file>to disambiguate.
5. Pass the creds via neon.ts (third-party env)
Neon-injected vars (DATABASE_URL, OPENAI_*, AI Gateway) are automatic. Declare only third-party vars under the function's env, resolved from process.env at deploy time:
// 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:
neonctl deploy --env .env.deploy6. 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-gatewayskill
MCP servers on Neon Functions
A Model Context Protocol 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 needed. A Hono app is the simplest host.
The server
Two packages do the work: the official `@modelcontextprotocol/sdk` (defines the server and its tools) and `@hono/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).
// 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 { contacts } from "./db/schema";
// One pool per isolate, reused across requests.
const pool = new Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
const db = drizzle(pool);
process.on("SIGINT", () => pool.end().then(() => process.exit(0)));
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 theStreamableHTTPTransport, and open thepgpool once at module load — they're reused across every request the isolate serves (see runtime limits). Connect the transport lazily with theisConnected()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
neonctl deploy, the server lives athttps://<branch_id>-<slug>.compute.…neon.tech/mcp. Point any streamable-HTTP MCP client at that/mcppath.
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.
Better Auth (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 before wiring it up.
Option 1 — OAuth via the Better Auth MCP plugin (best for third-party clients)
The MCP plugin 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:
// 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). 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, issue a key, and have the function verify the
Authorization: Bearer <key>(or anx-api-keyheader) on every request; or - Session JWT — mint a short-lived JWT with Better Auth's
jwtplugin and verify it in the function against the app's JWKS, the samejosepattern used for the agent backend.
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:
app.all("/mcp", async (c) => {
const auth = c.req.header("authorization");
if (!(await isValidApiKey(auth))) return c.json({ error: "unauthorized" }, 401); // your check
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` 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 neonctl dev URL). To wire it into a client interactively, npx add-mcp <url>/mcp -a <agent> writes the client config for you.
Integrations and observability
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 error and performance monitoring; the same shape (init module imported first, gated on an env var, secret passed at deploy time) applies to other Node SDKs (OpenTelemetry, logging, analytics).
Sentry (error & performance monitoring)
Because the runtime is a normal Node process, use the Node SDK @sentry/node — not an edge/serverless wrapper. It bundles cleanly through neonctl deploy's esbuild with no extra build config.
There are three layers worth instrumenting, and errors from all of them flow into a single Sentry project:
1. The HTTP framework (unhandled route errors). 2. The Node function runtime (uncaught exceptions / unhandled rejections, captured by the SDK). 3. The application's own caught failures — e.g. an agent that retries and falls back instead of throwing.
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.
// src/instrument.ts
import * as Sentry from "@sentry/node";
Sentry.init({
dsn: process.env.SENTRY_DSN,
enabled: Boolean(process.env.SENTRY_DSN),
tracesSampleRate: 1.0,
// NEON_BRANCH (the branch name) is injected on EVERY branch, including the default — so it
// can't be used as a truthy "is this a branch?" flag. Treat the project's default branch as
// "production" (its name passed in via neon.ts env) and tag every other branch by its name.
environment:
process.env.NEON_BRANCH && process.env.NEON_BRANCH !== process.env.PRODUCTION_BRANCH
? process.env.NEON_BRANCH
: "production",
});
export { Sentry };// src/index.ts
import "./instrument"; // MUST be the first import, before the framework/agent
import { Sentry } from "./instrument";
import { Hono } from "hono";
// ... rest of the function- Gate `enabled` on the DSN. Local dev (
neonctl dev) and any branch where you haven't configured the secret then become a no-op — no init, no noise — without changing code. - Tag the environment off the injected branch name. Each Neon branch runs its own copy of the function and the runtime injects
NEON_BRANCH(the branch name, e.g.mainorpreview/add-auth) into every one of them — including the default branch. The same value is written into local dev byneonctl env pull/neon-env run/neonctl dev, so local and deployed runs agree. Because it's always present, don't use it as a boolean flag (that tags every branch the same). Instead compare it against your default branch name (pass it in as e.g.PRODUCTION_BRANCHvianeon.tsenv) so the default branch reads asproductionand feature/preview branches are tagged by name — keeping them separable in the Sentry dashboard.
2. Provide the DSN as a deploy-time secret
The DSN is your own secret, so set it per-deployment (see "Environment variables" in SKILL.md). Either pass it on deploy:
neonctl functions deploy <slug> --src src/index.ts \
--env "SENTRY_DSN=https://…@…ingest.us.sentry.io/…"or declare it under the function's env in neon.ts (read from process.env to avoid hardcoding):
functions: {
<slug>: {
name: "…",
source: "src/index.ts",
env: { SENTRY_DSN: process.env.SENTRY_DSN! },
},
}3. Catch unhandled route errors
Wire a top-level error handler in your HTTP framework 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.
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);
});4. Report agent (and other swallowed) failures explicitly
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. Those failures never reach the route error handler, so report them explicitly with the same Sentry instance, and tag them so you can filter and group in the dashboard.
A representative agent that parses a page across several models reports three distinct cases:
// Recoverable: one model attempt failed, the agent will try the next model.
Sentry.captureException(err, {
level: "warning",
tags: { component: "agent", phase: "parse-attempt", model },
extra: { url, source },
});
// Terminal: every model failed.
Sentry.captureException(err, {
level: "error",
tags: { component: "agent", phase: "parse-all-failed" },
extra: { url, source },
});
// Non-exception failure: the agent couldn't fetch the input page at all.
Sentry.captureMessage("agent could not fetch page", {
level: "warning",
tags: { component: "agent", phase: "fetch" },
extra: { url, source },
});- Use
levelto separate recoverable (warning) from terminal (error) failures. - Use
tagsfor the dimensions you'll filter/group by —component,phase,model. - Use
extrafor per-event context — the URL being processed, the content source.
Verifying the wiring
- Temporarily add a route that throws (
app.get("/debug-sentry", () => { throw new Error("sentry test"); })), hit it, confirm the 500 surfaces in Sentry, then remove the route. - Trigger a real downstream failure (e.g. point a fetch at
https://httpstat.us/500) to confirm the explicit agent-level captures fire.
Other Node integrations
The same pattern generalizes to any Node integration (OpenTelemetry, structured logging, product 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 neonctl deploy's esbuild without changes.
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` and the server pushes text frames down it. On Neon Functions there's no adapter or extra library to install — unlike the Hono WebSocket helper, an SSE endpoint is just a normal fetch handler that returns a Response whose body is a ReadableStream with Content-Type: text/event-stream. The runtime holds the response open as long as bytes keep flowing (15-minute heartbeat, see Timeouts).
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:
// 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");
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"));
const timer = setInterval(
() => controller.enqueue(encoder.encode(": ping\n\n")),
25_000,
);
// cancel() fires when the client disconnects.
return () => clearInterval(timer);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
});
},
};cancel()is returned fromstart()here for brevity; you can also declare it as a separatecancel()method on the stream's underlying source. Either way, use it to drop the client from any broadcast set and clear timers.
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):
// 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 (Fan-out across isolates): 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 fan out across isolates with Postgres LISTEN/NOTIFY. Keep the source-of-truth state in Postgres — module state doesn't survive eviction.
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 });
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).
const listener = new Client({ connectionString: process.env.DATABASE_URL_UNPOOLED });
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)]);
}
process.on("SIGINT", () => {
Promise.allSettled([pool.end(), listener.end()]).then(() => process.exit(0));
});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).
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 reconnectingSend data: with no event: field to deliver the default message event, which the client reads with EventSource.onmessage (no addEventListener needed).
Client
const source = new EventSource(`${FUNCTION_URL}/events`); // GET only
source.onmessage = (e) => console.log("update", e.data); // default "message" events
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 — 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), but intermediary proxies are usually far stricter (tens of seconds). Emit a
: ping\n\ncomment every ~25–30s so the stream never goes quiet. - `no-transform`. Set
Cache-Control: no-cache, no-transformso proxies don't buffer or rewrite the stream. - Enqueue is synchronous.
controller.enqueue()doesn't return a promise, so broadcasting from theLISTENhandler can't interleave awaits mid-write — wrap each intry/catchand drop dead controllers. - CORS. A SPA hits the function cross-origin, so set
Access-Control-Allow-Origin.EventSourcesends 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/POSTcalls (often to the same function); reach for WebSockets only when you need bidirectional, low-latency frames.
Together — a Hono fetch SSE endpoint, LISTEN/NOTIFY 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.