Functions — /v1/functions

App-defined server functions that always execute as the invoking user. There is no service role, no admin client, and no app identity to escalate to: a function is trusted code, not widened authority. What invocation adds is provenance — the gateway knows an operation came from inside a named function, and schema access rules can require exactly that.

This page is the maintained execution model and callable contract. Historical design notes were removed once the MVP shipped.

Writing a function#

Every module in your project's functions/ directory is one invokable function; the filename is its name (and its via identity). Export a default async function taking (ctx, args):

ts
// functions/submitMove.ts
import type { FunctionContext } from '@tallpond/sdk'

export default async (ctx: FunctionContext, args: { resourceId: string; move: unknown }) => {
  // Validate the move server-side, then write it. The insert runs as the
  // calling user and passes the moves table's `via: 'submitMove'` gate.
  const { rows } = await ctx.db.query({
    scope: { kind: 'resource', resourceId: args.resourceId },
    table: 'moves',
    op: 'insert',
    values: { move: args.move },
  })
  return rows[0]
}

Function names must be identifiers ([a-zA-Z][a-zA-Z0-9_]*). Nested directories are reserved for later scoping (functions/resource/, functions/app/).

The ctx object#

FieldWhat it is
ctx.userIdThe invoking user — every operation runs and bills as them.
ctx.resourceIdThe resource the invoke targeted, or null.
ctx.fn / ctx.invocationIdThis function's name and the invocation's unique id.
ctx.db.query(request)One /v1/db/query operation (same request shape as database.md).
ctx.db.batch(operations)Atomic multi-op write via /v1/db/batch.
ctx.gateway(path, body?, opts?)Generic call to any gateway route under the invocation's identity — the escape hatch until typed ctx.ai / ctx.files helpers land. POST with a JSON body by default; pass { method: 'GET' } for read routes.

This table is the entire ctx surface. Annotate your handler with FunctionContext from @tallpond/sdk (a type-only import — nothing is bundled) and the compiler will hold your code to exactly this contract. There are no higher-level helpers yet: no per-scope table handles, no membership or rate-limit utilities, no now()/id generators — build those on top of ctx.db and ctx.gateway, or check the roadmap. ctx.db speaks the raw /v1/db wire shape (explicit scope/table/op — see database.md), not the SDK's client-side query builder.

Read-shaped gateway routes (e.g. resolving a static resource) are plain GETs — reach them by passing the method, with query params in the path:

ts
const feed = await ctx.gateway('/v1/resources/static?type=public_feed&slug=explore', undefined, {
  method: 'GET',
})

Every ctx call round-trips the gateway carrying a signed invocation token the gateway minted for this invoke. Authorization, metering, and via checks all happen gateway-side per operation. The token is accepted only while the invocation is actively running, so a function must await its ctx calls; saving the token for detached work does not extend its authority. The runtime receives no platform dispatch or management secret. Outbound fetch to arbitrary hosts is not part of the v0 contract.

Deploying#

Both CLI deploy paths handle functions: tallpond dev sends them to the selected non-production environment, while confirmed tallpond deploy sends them to production. When a functions/ directory exists, the CLI bundles every function (with its imports inlined) into a single worker script and uploads it with a manifest of names. Production scripts run privately inside a Workers for Platforms dispatch namespace; only the gateway can select and invoke them, and no public workers.dev endpoint is enabled. See cli.md.

Deploys are validated both ways against your schema's via rules:

Invoking#

ts
// SDK
const result = await tallpond.functions.invoke('submitMove', {
  resourceId: room.id,
  args: { move: { to: 'e4' } },
})
text
POST /v1/functions/:name
{ "args": { ... }, "resourceId": "..." }   // both optional

Requires a normal app session (see authentication.md). resourceId, when present, must name a resource of this app's deployment; it is carried into the invocation context for the function to read. Per-operation access inside the function resolves against your live membership on every call — invoking a function never grants access you don't already have.

Response: 200 { "result": <the function's return value> }.

ErrorStatusMeaning
insufficient_balance / spend_cap_exceeded402The escrow hold could not be placed.
not_found404No such function (or resource).
invalid_request400Bad name/body — including a function trying to invoke another function (not in v0).
function_error502The function threw, or an operation inside it was denied by policy (the inner denial is in detail).
function_limit504The function exceeded its wall-clock limit (~30s).
function_unavailable503The runtime couldn't be reached; nothing was charged.

Provenance gating (via)#

Any table access verb may require that the operation arrive from inside a named function (see schema-and-deploy.md for the DSL):

ts
r.owns('moves', (t) => {
  t.jsonb('move').notNull()
  t.access({
    read: 'reader',
    // Only writable through submitMove — direct client inserts are denied.
    create: { role: 'writer', via: 'submitMove' },
  })
})

The check is conjunctive: the caller must hold the role and the op must be executing inside that function (via also takes an array of names). The caller remains the actor, payer, and attributed author — via narrows how, never who. Clients cannot forge provenance: via is only ever set from the gateway-minted invocation token.

Cost#

The invoke itself charges the caller a flat base plus wall-clock time (op type function_invoke), escrowed before dispatch and captured at actual duration. Everything the function does — DB ops, AI calls, file ops — meters through its own existing path with the caller as payer, exactly as if called directly. A failed function still bills its compute; an unreachable runtime bills nothing.

Limits (v0)#