Functions

Interfold Functions run server-side TypeScript at /api/* on your site. Use them for form handlers, webhooks, small APIs, and any logic that shouldn’t live in the browser. No build step, no server to manage — deploy is just uploading a file.

The contract

A function is a single TypeScript file with one rule: export a named handler that returns a Response.

export async function handler(req: Request, ctx: InterfoldContext) {
  const url = new URL(req.url)
  const name = url.searchParams.get('name') ?? 'world'

  return Response.json({
    ok: true,
    message: `hello ${name}`,
    siteId: ctx.siteId,
  })
}
  • Source path: /api/<name>.ts — exactly one segment under /api/.
  • Runtime path: /api/<name> — no .ts extension.
  • Signature: handler(req: Request, ctx: InterfoldContext).
  • Return: a standard Response (use Response.json(...) for JSON).

Invocation and authorization

The runtime endpoint is anonymously callable for both PUBLIC and PRIVATE Sites. Site access controls only /web/ static content; it neither exposes function source nor authorizes a function request.

If a function protects content or performs a privileged action, its handler must validate its own cookie, token, signature, or other credential and return the appropriate 401 or 403. Do not rely on a PRIVATE Site as an outer function gate.

v0 limits

Functions v0 runs a single self-contained file. It does not support:

  • import statements, npm packages, or Node built-ins
  • nested paths like /api/foo/bar.ts

Keep each function to one file and the standard web Request/Response APIs. For scheduled execution, see Cron Jobs.

The ctx object

Every handler receives a context object:

Property Description
ctx.siteId The id of the site the function belongs to.
ctx.data Text, JSON, and JSONL under /data/. See Data files.
ctx.db SQL database queries. See Databases.
ctx.secrets Read site secrets at runtime. See Secrets.
ctx.llm Call OpenAI models using the site’s account usage allowance.

Model calls

Use ctx.llm.messages.create from a function or cron job. No OpenAI API key, SDK, or site secret is needed.

// /api/summarize.ts
export async function handler(req: Request, ctx: InterfoldContext) {
  if (req.method !== 'POST') {
    return new Response('Use POST', { status: 405 })
  }

  // Validate your application's user/session here before spending account usage.
  const input = await req.json().catch(() => null)
  if (
    typeof input?.text !== 'string' ||
    !input.text.trim() ||
    input.text.length > 8000
  ) {
    return Response.json(
      { error: 'Provide 1–8000 characters of text' },
      { status: 400 }
    )
  }

  const result = await ctx.llm.messages.create({
    model: 'gpt-6-luna',
    messages: [
      { role: 'system', content: 'Summarize the text in three short bullets.' },
      { role: 'user', content: input.text },
    ],
    maxOutputTokens: 4096,
  })

  return Response.json({ summary: result.message.content })
}

model defaults to gpt-6-luna; gpt-6.1-sol is also supported. reasoningEffort defaults to low and accepts none, low, medium, or high. Only Luna supports none, which is useful for short classifications.

maxOutputTokens defaults to 4,096 and accepts 16–8,192. It includes internal reasoning and formatting, not just visible text. Avoid tiny budgets with reasoning enabled, even for one-word answers. If the limit is hit, increase the budget, request a shorter answer, or lower the reasoning effort.

Send 1–100 text messages using user and assistant roles, with an optional first system message and at least one user message. Each message is limited to 32,000 characters; combined content is limited to 64,000 UTF-8 bytes. Calls time out after 45 seconds. Streaming, images, tools, and provider-managed conversation state are not supported; send the conversation history with each call.

Read the answer from result.message.content. The result also includes id, model, and usage. Calls share the site’s owning account allowance with browser-agent usage. Failed calls can also consume usage.

Errors include a stable code, an actionable message, and an HTTP status when available. Preserve the code and message in your application’s error response. Uncaught model errors return their status, code, and message as JSON. Calls are not automatically retried.

Deploying

Upload the source under /api/:

interfold files put ./hello.ts /api/hello.ts --site <site-id> --content-type text/typescript

Then invoke the runtime path:

interfold sites get <site-id>
curl -i "https://<subdomain>.interfold.site/api/hello?name=agent"

Functions accept any HTTP method — read req.method to branch:

export async function handler(req: Request, ctx: InterfoldContext) {
  if (req.method !== 'POST') {
    return new Response('Method not allowed', { status: 405 })
  }

  const body = await req.json()
  return Response.json({ received: body })
}

Cookies and password-protected content

Same-origin cookies are available through the standard Request headers. A function can also return Set-Cookie; the browser stores it and sends it with later requests to the same site. Interfold’s own reserved session cookie is not exposed to user functions and cannot be overwritten by them.

Keep the protected content behind the function rather than embedding it in the public HTML. For example, configure CONTENT_PASSWORD and a separate random CONTENT_SESSION_TOKEN as site secrets, then deploy this as /api/protected.ts:

function readCookie(req: Request, name: string): string {
  const cookieHeader = req.headers.get('cookie') ?? ''

  for (const cookie of cookieHeader.split(';')) {
    const [cookieName, ...valueParts] = cookie.trim().split('=')
    if (cookieName === name) {
      return decodeURIComponent(valueParts.join('='))
    }
  }

  return ''
}

const privateResponseHeaders = {
  'Cache-Control': 'private, no-store',
}

export async function handler(req: Request, ctx: InterfoldContext) {
  const password = await ctx.secrets.get('CONTENT_PASSWORD')
  const sessionToken = await ctx.secrets.get('CONTENT_SESSION_TOKEN')

  if (req.method === 'POST') {
    const body = (await req.json()) as { password?: string }
    if (body.password !== password) {
      return new Response('Unauthorized', {
        status: 401,
        headers: privateResponseHeaders,
      })
    }

    return new Response(null, {
      status: 204,
      headers: {
        ...privateResponseHeaders,
        'Set-Cookie': `protected_session=${encodeURIComponent(sessionToken)}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400`,
      },
    })
  }

  if (readCookie(req, 'protected_session') !== sessionToken) {
    return new Response('Unauthorized', {
      status: 401,
      headers: privateResponseHeaders,
    })
  }

  return Response.json(
    { content: 'The protected content goes here.' },
    { headers: privateResponseHeaders }
  )
}

From the page, submit the password and then request the content. Same-origin fetch includes the stored cookie automatically:

await fetch('/api/protected', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ password }),
})

const response = await fetch('/api/protected')
const { content } = await response.json()

A stateful example

Combine ctx.data for a hit counter that survives restarts:

export async function handler(req: Request, ctx: InterfoldContext) {
  const previous = (await ctx.data
    .readJson('/data/state.json')
    .catch(() => ({ visits: 0 }))) as { visits: number }

  const next = {
    visits: previous.visits + 1,
    updatedAt: new Date().toISOString(),
  }
  await ctx.data.writeJson('/data/state.json', next)

  return Response.json(next)
}

Troubleshooting a deploy

A failed deploy can leave the source saved while the function status is failed. Read the CLI response and check:

  • The path is exactly one segment under /api/, e.g. /api/hello.ts.
  • The file exports a named handler.
  • The handler returns a Response.
  • There are no imports, npm packages, or Node built-ins.

Fix the source and re-upload. Then re-verify the runtime path with curl -i.

On this page