Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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 automatical
SkillScore100/100
Test results 100
3/3 safety checks passed
30%100%of test cases passed +70 pts
Passed 23 of 23 test cases with the skill, and 7 of 23 without it.
16 fixed7 passed either way0 still failing0 broke
One test taskI ran `neon checkout -b preview/feature-1` to create and switch to a new branch, and my `neon.ts` file was automatically applied. However, my function environment variables read `process.env`. What command must I run immediately after checkout to load my secret environment file?
Adds every file the skill has, pinned so you can update it. No account needed.
pip install "decimalai>=0.15"
decimalai skills add neon-functions
claude --plugin-dir "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents"The same install from Python, for a notebook or a setup script.
pip install "decimalai>=0.15"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
print("Added. Start Claude Code with the command below.")claude --plugin-dir "$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.agents"Type these in a Claude Code session. No account or API key. It writes the skill and any files it bundles, and asks first when its safety rating isn't Passed.
/plugin marketplace add decimal-labs/decimalai-claude-plugins
/plugin install decimalai@decimalai/decimalai:install neon-functionsThe plugin needs python3 and curl on your machine.
No Python or account. Claude Code picks it up in its next session.
curl -fsSL --create-dirs -o .claude/skills/neon-functions/SKILL.md https://app.decimal.ai/s/neon-functions/SKILL.md.claude/skills/neon-functions/SKILL.mdThis gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[claude-agent-sdk]"
decimalai skills add neon-functionsimport asyncio
from claude_agent_sdk import ClaudeAgentOptions
from decimalai.claude_agent_sdk import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
async def main():
question = "Hello!"
async with support.run(question) as run: # one user message, one run
async for message in run.query(options=ClaudeAgentOptions(max_turns=6)):
print(message)
# In a notebook or async code: await main()
asyncio.run(main())Prefer its own loader? ClaudeAgentOptions(plugins=[skills_plugin()], skills="all") reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[claude-agent-sdk]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
import asyncio
from claude_agent_sdk import ClaudeAgentOptions
from decimalai.claude_agent_sdk import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
async def main():
question = "Hello!"
async with support.run(question) as run: # one user message, one run
async for message in run.query(options=ClaudeAgentOptions(max_turns=6)):
print(message)
# In a notebook or async code: await main()
asyncio.run(main())Prefer its own loader? ClaudeAgentOptions(plugins=[skills_plugin()], skills="all") reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdimport asyncio
from pathlib import Path
from claude_agent_sdk import ClaudeAgentOptions, query
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
options = ClaudeAgentOptions(system_prompt=f"You are a helpful agent.\n\n{skill}", max_turns=6)
async def main():
async for message in query(prompt="Hello!", options=options):
print(message)
# In a notebook or async code: await main()
asyncio.run(main())This gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai-agents]"
decimalai skills add neon-functionsfrom agents import Agent, Runner
from decimalai.openai_agents import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
name=support.name,
instructions=support.instructions, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# In a notebook or async code: result = await Runner.run(agent, "Hello!")
result = Runner.run_sync(agent, "Hello!")Using SandboxAgent? capabilities=[Shell(), decimalai.openai_agents.sandbox_skills()] points its own loader at the same folder.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai-agents]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
from agents import Agent, Runner
from decimalai.openai_agents import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
name=support.name,
instructions=support.instructions, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# In a notebook or async code: result = await Runner.run(agent, "Hello!")
result = Runner.run_sync(agent, "Hello!")Using SandboxAgent? capabilities=[Shell(), decimalai.openai_agents.sandbox_skills()] points its own loader at the same folder.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from agents import Agent, Runner
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
agent = Agent(name="support", instructions=f"You are a helpful agent.\n\n{skill}")
# In a notebook or async code: result = await Runner.run(agent, "Hello!")
result = Runner.run_sync(agent, "Hello!")This gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai]"
decimalai skills add neon-functionsimport json
from openai import OpenAI
from decimalai.openai import agent_config
client = OpenAI()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your tools, as Chat Completions takes them, and what answers each.
TOOLS = [{"type": "function", "function": {
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
# run.system is your prompt, then the skill list.
messages = [{"role": "system", "content": run.system}, run.message]
while True:
reply = client.chat.completions.create(
model="gpt-5.4-mini",
messages=messages,
tools=[*TOOLS, *run.tools], # your tools, then load_skill
).choices[0].message
messages.append(reply)
if not reply.tool_calls:
break
for call in reply.tool_calls:
# load_skill is answered by the run; your own tools by FUNCTIONS.
function = FUNCTIONS.get(call.function.name)
messages.append(run.tool_result(call) or {
"role": "tool", "tool_call_id": call.id,
"content": function(**json.loads(call.function.arguments)) if function
else f"There is no tool named {call.function.name!r}.",
})
print(reply.content)
# The Responses API takes run.response_tools instead of run.tools.The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[openai]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
import json
from openai import OpenAI
from decimalai.openai import agent_config
client = OpenAI()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your tools, as Chat Completions takes them, and what answers each.
TOOLS = [{"type": "function", "function": {
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
# run.system is your prompt, then the skill list.
messages = [{"role": "system", "content": run.system}, run.message]
while True:
reply = client.chat.completions.create(
model="gpt-5.4-mini",
messages=messages,
tools=[*TOOLS, *run.tools], # your tools, then load_skill
).choices[0].message
messages.append(reply)
if not reply.tool_calls:
break
for call in reply.tool_calls:
# load_skill is answered by the run; your own tools by FUNCTIONS.
function = FUNCTIONS.get(call.function.name)
messages.append(run.tool_result(call) or {
"role": "tool", "tool_call_id": call.id,
"content": function(**json.loads(call.function.arguments)) if function
else f"There is no tool named {call.function.name!r}.",
})
print(reply.content)
# The Responses API takes run.response_tools instead of run.tools.No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from openai import OpenAI
client = OpenAI()
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
reply = client.chat.completions.create(
model="gpt-5.4-mini",
messages=[
{"role": "system", "content": f"You are a helpful agent.\n\n{skill}"},
{"role": "user", "content": "Hello!"},
],
).choices[0].messageThis gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[anthropic]"
decimalai skills add neon-functionsimport anthropic
from decimalai.anthropic import agent_config
client = anthropic.Anthropic()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
question = "Hello!"
with support.run(question) as run: # one user message, one run
answer = client.beta.messages.tool_runner(
model="claude-haiku-5-5",
max_tokens=4096,
system=run.system, # your prompt, then the skill list
tools=[*run.tools], # your tools, then load_skill
messages=[{"role": "user", "content": question}],
).until_done()The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[anthropic]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
import anthropic
from decimalai.anthropic import agent_config
client = anthropic.Anthropic()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
question = "Hello!"
with support.run(question) as run: # one user message, one run
answer = client.beta.messages.tool_runner(
model="claude-haiku-5-5",
max_tokens=4096,
system=run.system, # your prompt, then the skill list
tools=[*run.tools], # your tools, then load_skill
messages=[{"role": "user", "content": question}],
).until_done()No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
import anthropic
client = anthropic.Anthropic()
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
reply = client.messages.create(
model="claude-haiku-5-5",
max_tokens=4096,
system=f"You are a helpful agent.\n\n{skill}",
messages=[{"role": "user", "content": "Hello!"}],
)This gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[google-genai]"
decimalai skills add neon-functionsfrom google import genai
from google.genai import types
from decimalai.google_genai import agent_config
client = genai.Client()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your functions, as declarations for the model, and what answers each.
TOOLS = [{"function_declarations": [{
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters_json_schema": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}]}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
config = types.GenerateContentConfig(
system_instruction=run.system, # your prompt, then the skill list
tools=[*TOOLS, *run.tool_params], # your functions, then load_skill
)
contents = [{"role": "user", "parts": [{"text": run.message}]}]
while True:
response = client.models.generate_content(
model="gemini-3.5-flash", contents=contents, config=config,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for call in response.function_calls:
# load_skill is answered by the run; your own functions by FUNCTIONS.
# A loop of your own rather than automatic function calling, which
# raises KeyError when the model asks for a function you did not pass.
function = FUNCTIONS.get(call.name)
parts.append(run.tool_result(call) or {"function_response": {
"name": call.name, "id": call.id,
"response": {"result": function(**(call.args or {})) if function
else f"There is no function named {call.name!r}."},
}})
contents.append({"role": "user", "parts": parts})
print(response.text)The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[google-genai]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
from google import genai
from google.genai import types
from decimalai.google_genai import agent_config
client = genai.Client()
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
def lookup_order(order_id: str) -> str:
return f"Order {order_id} shipped yesterday."
# Your functions, as declarations for the model, and what answers each.
TOOLS = [{"function_declarations": [{
"name": "lookup_order",
"description": "Look up the status of an order.",
"parameters_json_schema": {"type": "object", "properties": {"order_id": {"type": "string"}},
"required": ["order_id"]},
}]}]
FUNCTIONS = {"lookup_order": lookup_order}
question = "Where is order A1001?"
with support.run(question) as run: # one user message, one run
config = types.GenerateContentConfig(
system_instruction=run.system, # your prompt, then the skill list
tools=[*TOOLS, *run.tool_params], # your functions, then load_skill
)
contents = [{"role": "user", "parts": [{"text": run.message}]}]
while True:
response = client.models.generate_content(
model="gemini-3.5-flash", contents=contents, config=config,
)
if not response.function_calls:
break
contents.append(response.candidates[0].content)
parts = []
for call in response.function_calls:
# load_skill is answered by the run; your own functions by FUNCTIONS.
# A loop of your own rather than automatic function calling, which
# raises KeyError when the model asks for a function you did not pass.
function = FUNCTIONS.get(call.name)
parts.append(run.tool_result(call) or {"function_response": {
"name": call.name, "id": call.id,
"response": {"result": function(**(call.args or {})) if function
else f"There is no function named {call.name!r}."},
}})
contents.append({"role": "user", "parts": parts})
print(response.text)No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from google import genai
from google.genai import types
client = genai.Client()
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
response = client.models.generate_content(
model="gemini-3.5-flash",
contents="Hello!",
config=types.GenerateContentConfig(system_instruction=f"You are a helpful agent.\n\n{skill}"),
)This gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[langchain]" langchain-openai
decimalai skills add neon-functionsfrom langchain.agents import create_agent
from decimalai.langchain import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = create_agent(
"openai:gpt-5.4-mini",
tools=[], # your tools; the middleware adds load_skill
middleware=[support.middleware], # your prompt, the skill list and load_skill
)
result = agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[langchain]" langchain-openaifrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
from langchain.agents import create_agent
from decimalai.langchain import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = create_agent(
"openai:gpt-5.4-mini",
tools=[], # your tools; the middleware adds load_skill
middleware=[support.middleware], # your prompt, the skill list and load_skill
)
result = agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from langchain.agents import create_agent
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
agent = create_agent("openai:gpt-5.4-mini", tools=[], system_prompt=f"You are a helpful agent.\n\n{skill}")
result = agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})This gets SKILL.md only. The skill's 7 other files are not included.
CrewAI's own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" "crewai>=1.15.27"
decimalai skills add neon-functionsimport decimalai.crewai
from crewai import Agent, Crew, Task
agent = Agent(
role="Support", goal="Help the customer", backstory="A support agent.",
llm=llm, # your own CrewAI LLM
skills=decimalai.crewai.skills(), # the installed folder
tools=decimalai.crewai.skill_tools(), # reads a skill's files, runs its scripts
)
task = Task(description="Hello!", expected_output="an answer", agent=agent)
Crew(agents=[agent], tasks=[task]).kickoff()The same install from Python, for a notebook or a setup script. CrewAI's own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" "crewai>=1.15.27"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
import decimalai.crewai
from crewai import Agent, Crew, Task
agent = Agent(
role="Support", goal="Help the customer", backstory="A support agent.",
llm=llm, # your own CrewAI LLM
skills=decimalai.crewai.skills(), # the installed folder
tools=decimalai.crewai.skill_tools(), # reads a skill's files, runs its scripts
)
task = Task(description="Hello!", expected_output="an answer", agent=agent)
Crew(agents=[agent], tasks=[task]).kickoff()No DecimalAI package. CrewAI's own loader reads the folder, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from crewai import Agent, Crew, Task
agent = Agent(
role="Support", goal="Help the customer", backstory="A support agent.",
llm=llm, # your own CrewAI LLM
skills=[Path(".agents/skills")],
)
task = Task(description="Hello!", expected_output="an answer", agent=agent)
Crew(agents=[agent], tasks=[task]).kickoff()This gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[adk]"
decimalai skills add neon-functionsfrom decimalai.adk import DecimalAIPlugin, agent_config
from google.adk.agents import LlmAgent
from google.adk.apps import App
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = LlmAgent(
name="support", # ADK needs a Python identifier
model="gemini-3.5-flash",
instruction=support.instruction, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# DecimalAIPlugin makes each invocation of runners built from this App one trace.
app = App(name="support", root_agent=agent, plugins=[DecimalAIPlugin()])Prefer ADK's own loader? SkillToolset(skills=decimalai.adk.skills(), ...) reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[adk]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
from decimalai.adk import DecimalAIPlugin, agent_config
from google.adk.agents import LlmAgent
from google.adk.apps import App
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = LlmAgent(
name="support", # ADK needs a Python identifier
model="gemini-3.5-flash",
instruction=support.instruction, # your prompt, then the skill list
tools=[*support.tools], # your tools, then load_skill
)
# DecimalAIPlugin makes each invocation of runners built from this App one trace.
app = App(name="support", root_agent=agent, plugins=[DecimalAIPlugin()])Prefer ADK's own loader? SkillToolset(skills=decimalai.adk.skills(), ...) reads the same folder. Use one or the other: a skill delivered both ways reaches the prompt twice.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from google.adk.agents import LlmAgent
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
agent = LlmAgent(
name="support",
model="gemini-3.5-flash",
instruction=lambda context: f"You are a helpful agent.\n\n{skill}",
)This gets SKILL.md only. The skill's 7 other files are not included.
agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[pydantic-ai]"
decimalai skills add neon-functionsfrom pydantic_ai import Agent
from decimalai.pydantic_ai import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
"openai:gpt-4o-mini",
name=support.name,
tools=[], # your tools; the capability adds load_skill
capabilities=[support], # your prompt, the skill list and load_skill
)
# In a notebook or async code: result = await agent.run("Hello!")
result = agent.run_sync("Hello!")On the Pydantic AI harness, decimalai.pydantic_ai_harness.skills(project) uses its own loader instead.
The same install from Python, for a notebook or a setup script. agent_config() hands your agent the skill. With no DecimalAI key it reads your project's files and sends nothing. Needs SDK 0.27 or newer.
pip install "decimalai[pydantic-ai]"from decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
from pydantic_ai import Agent
from decimalai.pydantic_ai import agent_config
# With no DECIMAL_API_KEY this reads the skills your project lists, from disk.
support = agent_config("support", fallback="You are a helpful agent.")
agent = Agent(
"openai:gpt-4o-mini",
name=support.name,
tools=[], # your tools; the capability adds load_skill
capabilities=[support], # your prompt, the skill list and load_skill
)
# In a notebook or async code: result = await agent.run("Hello!")
result = agent.run_sync("Hello!")On the Pydantic AI harness, decimalai.pydantic_ai_harness.skills(project) uses its own loader instead.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from pydantic_ai import Agent
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
agent = Agent("openai:gpt-4o-mini", instructions=f"You are a helpful agent.\n\n{skill}")
# In a notebook or async code: result = await agent.run("Hello!")
result = agent.run_sync("Hello!")This gets SKILL.md only. The skill's 7 other files are not included.
Deep Agents' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" deepagents
decimalai skills add neon-functionsimport decimalai.deepagents
from deepagents import create_deep_agent
agent = create_deep_agent(model=model, **decimalai.deepagents.skills())
agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})The default backend reads files but has no shell. To run a skill's scripts, pass skills(backend=LocalShellBackend(...)).
The same install from Python, for a notebook or a setup script. Deep Agents' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" deepagentsfrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
import decimalai.deepagents
from deepagents import create_deep_agent
agent = create_deep_agent(model=model, **decimalai.deepagents.skills())
agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})The default backend reads files but has no shell. To run a skill's scripts, pass skills(backend=LocalShellBackend(...)).
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from deepagents import create_deep_agent
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
agent = create_deep_agent(model=model, system_prompt=f"You are a helpful agent.\n\n{skill}")
agent.invoke({"messages": [{"role": "user", "content": "Hello!"}]})This gets SKILL.md only. The skill's 7 other files are not included.
Its own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" agent-framework
decimalai skills add neon-functionsimport asyncio
from agent_framework import Agent
from decimalai.agent_framework import skills
agent = Agent(client=client, instructions="You are a helpful agent.", context_providers=[skills()])
asyncio.run(agent.run("Hello!"))It caches skills by default, so restart the process after decimalai skills update.
The same install from Python, for a notebook or a setup script. Its own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" agent-frameworkfrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
import asyncio
from agent_framework import Agent
from decimalai.agent_framework import skills
agent = Agent(client=client, instructions="You are a helpful agent.", context_providers=[skills()])
asyncio.run(agent.run("Hello!"))It caches skills by default, so restart the process after decimalai skills update.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdimport asyncio
from pathlib import Path
from agent_framework import Agent
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
agent = Agent(client=client, instructions=f"You are a helpful agent.\n\n{skill}")
asyncio.run(agent.run("Hello!"))This gets SKILL.md only. The skill's 7 other files are not included.
Strands' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" strands-agents
decimalai skills add neon-functionsfrom decimalai.strands import skills
from strands import Agent
from strands.vended_tools import file_editor, shell
agent = Agent(
model=model, # always pass one: the default is Amazon Bedrock
system_prompt="You are a helpful agent.",
tools=[file_editor, shell],
plugins=[skills()],
)
agent("Hello!")Strands loads an agent's skills once, so build the agent again after decimalai skills update.
The same install from Python, for a notebook or a setup script. Strands' own skill loader reads the installed folder. No DecimalAI account needed.
pip install "decimalai>=0.16" strands-agentsfrom decimalai import skillpkg
# Writes .agents/skills and .decimal/skills.toml. Safe to run twice.
result = skillpkg.add("neon-functions")
if result.exit_code:
raise SystemExit(result.problems)
from decimalai.strands import skills
from strands import Agent
from strands.vended_tools import file_editor, shell
agent = Agent(
model=model, # always pass one: the default is Amazon Bedrock
system_prompt="You are a helpful agent.",
tools=[file_editor, shell],
plugins=[skills()],
)
agent("Hello!")Strands loads an agent's skills once, so build the agent again after decimalai skills update.
No DecimalAI package. The whole skill goes in every prompt, and nothing pins or updates it.
.agents/skills/neon-functions/SKILL.mdfrom pathlib import Path
from strands import Agent
skill = Path(".agents/skills/neon-functions/SKILL.md").read_text()
agent = Agent(model=model, system_prompt=f"You are a helpful agent.\n\n{skill}")
agent("Hello!")This gets SKILL.md only. The skill's 7 other files are not included.
Adds every file the skill has, pinned so you can update it. Cursor, Copilot, Cline and Warp read this folder by themselves.
pip install "decimalai>=0.15"
decimalai skills add neon-functionsNo Python or account. Cursor, Copilot, Cline and Warp read this folder by themselves.
curl -fsSL --create-dirs -o .agents/skills/neon-functions/SKILL.md https://app.decimal.ai/s/neon-functions/SKILL.md.agents/skills/neon-functions/SKILL.mdThis gets SKILL.md only. The skill's 7 other files are not included.
Give its text to your model as instructions. No account or package needed.
This gets SKILL.md only. The skill's 7 other files are not included.
Raw files: /s/neon-functions/SKILL.md · /s/neon-functions.json
Fetch the file and give its text to your model as instructions. No account or package needed.
curl -fsSL https://app.decimal.ai/s/neon-functions/SKILL.mdcurl -fsSL https://app.decimal.ai/s/neon-functions.jsonThis gets SKILL.md only. The skill's 7 other files are not included.
You don't need a DecimalAI account.
Saved prompts and skills, pinned versions, and traces of this skill in your runs. DecimalAI does not run your agent.
Claude Code reads files from your project, so DecimalAI config does not apply there.
On Claude Agent SDK: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On OpenAI Agents: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On OpenAI SDK: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Anthropic: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Gemini API: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On LangChain: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On CrewAI: set DECIMAL_API_KEY and add decimalai.init(crewai=True) to record runs and skill use. The skill version stays in your repo.
On Google ADK: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Pydantic AI: save the skill to an agent here, set DECIMAL_API_KEY, and the same code uses it with no command to run. Changes you save reach the next run with no redeploy.
On Deep Agents: set DECIMAL_API_KEY and add decimalai.init(langchain=True) to record runs and skill use. The skill version stays in your repo.
On Microsoft Agent Framework: set DECIMAL_API_KEY and add decimalai.init(otel=True, agent_name="support") to record runs and skill use. The skill version stays in your repo.
On Strands: set DECIMAL_API_KEY and add decimalai.init(otel=True, agent_name="support") to record runs and skill use. The skill version stays in your repo.
Editors read files from your project, so DecimalAI config does not apply there.
Anywhere else: record a use with decimalai.log_skill_activation() inside a trace.
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:
npx skills add neondatabase/agent-skills --skill neon
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.
Reach for Neon Functions when the workload is a request/response handler that benefits from staying alive and staying close to the data:
pg pool, an in-memory counter) persists across requests on the same isolate.DATABASE_URL is injected for you.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 and Availability).
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.DATABASE_URL injected automatically when the branch has Postgres.neon, neon.ts, or the Neon API.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.
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:
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.
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:
// 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:
// 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.
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 });
attachDatabasePool(pool);
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.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:
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:
export default defineConfig({
preview: {
functions: { todos: { name: "todo api", source: "src/index.ts" } },
},
branch: (branch) => ({
preview: { functions: { todos: { runtime: "nodejs24" } } },
}),
});
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:
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.
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:
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). 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_URLdirectly works everywhere. The function in Setup instead uses@neon/env'sparseEnv(config)to read the same value in a typed, validated way — either is fine.
Functions are long-running but still serverless — they are a request/response runtime, not a background job runner. The hard limits:
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.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.^[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).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). 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
jwt plugin, NextAuth, or your own signer) — that call is fast and well within host limits.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.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.
// 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.
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).
Upgrade from inside fetch. Call upgradeWebSocket(request) from @neon/functions and return the response it gives you. Hono apps use the same primitive via @neon/functions/hono (see Hono below). There is one entrypoint and no WebSocket dependency to install:
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, 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:
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.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: jwtVerify against your JWKS) and refuse before upgrading:
// 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():
// 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.
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 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:
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.)
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.
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?.());
serial/bigserial PK and the interval sane.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 LISTENs 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 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).
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 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.
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 — 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.
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.
// 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) 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 (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.
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.
The simplest host is a Hono app using the official @modelcontextprotocol/sdk plus @hono/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:
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.
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.
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.
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.
Published skills whose descriptions closely match this one, with their headline benchmark lift.
Skills that do similar work, with their headline benchmark lift.
From the same source and nearby in the catalogue.