Files
2026-09-07 21:21:31 +10:00
..
2026-09-07 19:53:58 +10:00
2026-09-07 19:53:58 +10:00
2026-09-07 19:53:58 +10:00
2026-09-07 19:53:58 +10:00
2026-09-07 21:21:31 +10:00
2026-09-07 19:53:58 +10:00
2026-09-07 19:53:58 +10:00

@supabase/agent-runtime

A framework for building agents on Supabase Workers with AI SDK 7. apps/assistant is the first application; Studio embeds that application through its versioned HTTP API. This is a private workspace package while its API is dogfooded.

Getting started

Use Node.js 22.13 or later and the repository's pnpm version. Add the package to your application's workspace dependencies:

{
  "dependencies": {
    "@supabase/agent-runtime": "workspace:*"
  }
}

The package exports TypeScript source for the application's bundler to compile. It is not published to npm. Its peer dependencies include AI SDK, Supabase server and client libraries, Hono, and Zod; use the compatible versions in package.json. Choose an AI SDK model provider in your application.

Minimal Worker

supabase/workers/agent/index.ts can expose a single authenticated, stateless chat route. This example accepts { "message": "Hello" } and streams an AI SDK UI message response:

import { openai } from '@ai-sdk/openai'
import { createAgentStreamResponse, defineAgent } from '@supabase/agent-runtime'
import { createAgentWorker } from '@supabase/agent-runtime/workers'
import type { UIMessage } from 'ai'
import { z } from 'zod'

const agent = defineAgent<{ userId: string }>({
  name: 'helper',
  instructions: 'Help the user with their question.',
})
const body = z.object({ message: z.string().trim().min(1).max(4000) })
const url = process.env.AGENT_SUPABASE_URL!

export default createAgentWorker({
  env: {
    url,
    publishableKeys: { default: process.env.AGENT_PUBLISHABLE_KEY! },
    secretKeys: { default: process.env.AGENT_SECRET_KEY! },
    jwks: new URL('/auth/v1/.well-known/jwks.json', url),
  },
  routes: [
    {
      method: 'POST',
      pattern: '/chat',
      auth: 'user',
      handler: async (request, context) => {
        const input = body.safeParse(await request.json().catch(() => null))
        if (!input.success)
          return Response.json(
            { message: 'Provide a message of 1–4,000 characters.' },
            { status: 400 }
          )

        const messages: UIMessage[] = [
          {
            id: crypto.randomUUID(),
            role: 'user',
            parts: [{ type: 'text', text: input.data.message }],
          },
        ]
        const session = await agent.prepare({
          context: { userId: context.userClaims!.id },
          abortSignal: request.signal,
        })
        try {
          const result = await session.stream({ model: openai('gpt-5.4-nano'), messages })
          return await createAgentStreamResponse(result, {
            originalMessages: messages,
            onFinish: () => {},
            onSettled: session.close,
          })
        } catch (error) {
          await session.close()
          throw error
        }
      },
    },
  ],
})

Add @ai-sdk/openai to the application and set Worker secrets AGENT_SUPABASE_URL, AGENT_PUBLISHABLE_KEY, AGENT_SECRET_KEY, and OPENAI_API_KEY. Call /chat with the user's Supabase access token in Authorization: Bearer <token> and Content-Type: application/json. Configure [workers.agent] as described below.

For more features, see the complete Worker example, which combines Supabase authentication, an agent, a tool, a skill, result-sharing permissions, and an HTTP streaming response. For persistent conversations and approval continuations, implement the persistence callbacks described below and follow the Assistant application.

Public API

Export Purpose
defineAgent Define instructions, tools, skills, permissions, and per-turn sessions.
composeTools Combine tool sources with explicit replacement rules.
withToolPolicy Apply application permissions to an AI SDK tool set.
sanitizeToolOutputForModel / sanitizeToolErrorForModel Apply current asynchronous execution and sharing policies to stored tool results/errors.
sanitizeToolOutput Apply synchronous output-sharing projection to stored tool results.
reconcileAgentMessages Validate browser history and authorize responses to stored approvals.
createMcpConnections Connect named MCP servers; import from @supabase/agent-runtime/mcp.
startAgentRun Manage a run through application-defined persistence callbacks.
createSkillCatalog Build a lazy skill loader and its prompt catalog.
createAgentStreamResponse Stream AI SDK UI messages with persistence and cleanup callbacks.
createAgentWorker Create authenticated Supabase Worker routes; import from @supabase/agent-runtime/workers.

The remaining exports above come from @supabase/agent-runtime.

Boundaries

Studio (or another integration)
    │ authenticated HTTP + AI SDK UI message stream
    ▼
Supabase Worker — createAgentWorker
    │ verified identity + request-scoped Supabase clients
    ▼
Application — authorization, consent, models, storage configuration
    │ trusted context + canonical messages
    ▼
Agent — defineAgent
    │ instructions + skills + tool policies + MCP connections
    ▼
AI SDK — model loop, tool validation, approvals, streaming

The framework provides execution mechanics. Applications define their own tools, permission vocabulary, prompts, skills, models, data access, and persistence. The optional AgentPersistence<Context, State> contract accepts application callbacks. The framework defines no tables, SQL, migrations, database drivers, or stored row shapes. No Studio types, environment variables, consent levels, or product policies live in this package. There is no filesystem discovery or global request context.

Define an agent

Use ordinary AI SDK tool() definitions with schemas and executors. defineAgent adds a typed application context, bounded loop, optional skill catalog, and optional permissions. See the complete, typechecked example.

import { defineAgent } from '@supabase/agent-runtime'
import { tool } from 'ai'
import { z } from 'zod'

const agent = defineAgent<{ userId: string }>({
  name: 'account-helper',
  instructions: 'Help the user identify their account.',
  maxSteps: 3,
  permissions: { get_identity: {} },
  tools: ({ userId }) => ({
    tools: {
      get_identity: tool({
        description: 'Get the authenticated user ID.',
        inputSchema: z.object({}),
        execute: async () => ({ userId }),
      }),
    },
  }),
})

Define the agent once and call agent.prepare({ context, abortSignal }) for each turn. Pass the verified application's context, never a browser-supplied context object. Context reaches tool factories and message hooks; the framework does not serialize it into the prompt. session.stream({ model, messages }) accepts canonical AI SDK UI messages and returns the AI SDK result. A session runs one turn.

prepareMessages filters or redacts history before conversion. contextMessages receives that prepared history and adds application context. Static instructions and skill descriptions remain separate from these messages.

Run on Supabase Workers

Import createAgentWorker from @supabase/agent-runtime/workers. It creates a Hono application with the Workers fetch entry point. Each route declares auth: 'user' or auth: 'none'; there is no implicit public route. User routes use @supabase/server JWT verification and receive its typed SupabaseContext, including the user-scoped supabase client. RLS applies when that client accesses the database. The optional authorize hook adds application authorization after authentication.

Export the worker from supabase/workers/agent/index.ts and configure the project:

[workers.agent]
runtime = "node"
size = "2gb"

Bundle workspace imports into supabase/workers/agent/index.mjs for deployment. Assistant's build configuration and supabase/config.toml are the working deployment example. Its local Node HTTP adapter propagates client disconnects as request cancellation. Model providers are selected by the application; this package does not assume a provider gateway or hosting platform other than Supabase Workers.

Pass Workers secrets explicitly as env to createAgentWorker, using url, publishableKeys, secretKeys, and optional jwks from SupabaseEnv. Hosted Workers do not inject the assistant project's connection settings. Assistant maps its ASSISTANT_* secrets into this environment; another application can choose its own prefix. Keep clients and secrets in server context, outside model messages.

The worker supports application middleware for CORS and body limits, custom error formatting, and normal HTTP routes for history, feedback, settings, or other integrations. Supabase Auth verification and HTTP errors retain their status codes.

Application-defined permissions

Permission decisions are independent:

Concern API Behavior
Tool exposure permissions keys and visible(context) With a policy map, only listed, visible tools are exposed, including the skill loader. Omit it to retain the provided tool set.
Execution canExecute(context, call) Evaluated before every execution; false or a thrown error denies the call.
Approval needsApproval: true or (context, call) Adds an AI SDK approval requirement. A policy cannot remove an intrinsic requirement.
Model visibility modelOutput(output, context, call) Projects results before model conversion, preserving full results for the authorized UI.
Historical visibility sanitizeToolOutput Applies the same projection to stored output; unknown tools are redacted by default.

call contains the tool name, input, and toolCallId. Visibility is synchronous and evaluated when preparing the tool set; execution and approval predicates may be asynchronous. Execution checks run again after an approval continuation. Execution or visibility errors deny access; approval-policy errors require approval. modelOutput is synchronous and runs before model conversion. Use modelError(error, context, call) to redact execution errors before the SDK uses their text in subsequent model steps. It also applies to historical errors through defineAgent. A failed error formatter produces a generic error; a formatted error has no original cause. Error projection affects the UI error text as well because the SDK shares the same tool error between model and UI. Its output does not replace the result stored for the authorized UI.

All of these policies belong to application code. For example, an application may allow a user to approve SQL execution while withholding rows from the model. Assistant defines that policy in tool-policies.ts. Execution permission and data sharing permission are separate; a policy using canExecute should also define modelOutput when historical results are sensitive.

Use withToolPolicy(tools, { context, policies }) for independently composed tool sources such as MCP discovery. This is the same enforcement used by agent-level permissions. Dynamic tool identity is preserved. Unknown remote tools never gain access just because the remote server adds them. Policy-denied results bypass the original output converter; permission errors fail closed.

composeTools rejects collisions among base tools and extensions. Replacements must be explicit in overrides, so a remote tool cannot silently replace an application's approval-gated executor. Provider-executed tools cannot use local execution guards; the framework rejects that combination. Executors returning async iterables are reduced to their final result under a permission wrapper.

Agent-level permissions also project historical results for removed tools and redact unknown ones, including asynchronous execution checks when a tool disappears. When using withToolPolicy independently, use sanitizeToolOutputForModel and sanitizeToolErrorForModel on stored parts before restoring history; AI SDK cannot apply a missing tool's converter. Assistant's tool-sanitizer.ts demonstrates consent downgrades for static and dynamic results.

AI SDK approval requests pause a turn; they do not authenticate the approval. Use reconcileAgentMessages inside the same transaction that claims the next run:

const messages = await reconcileAgentMessages(previous, incoming, {
  trigger: 'approval-response',
  context: { responderId, ownerId, allowedTools },
  canRespondToApproval: (context, decision) =>
    context.responderId === context.ownerId && context.allowedTools.includes(decision.toolName),
})

The callback receives the stored message ID, approval ID, tool name, call ID and input, together with the validated approved choice and optional reason. The helper rejects changed tool inputs, forged assistant messages, changed metadata, and replaced approval IDs before calling the policy. It passes a clone to the policy, so a callback cannot rewrite the stored call. A denied or failed policy throws AgentApprovalAuthorizationError. Without a responder callback, the caller must still enforce session ownership; Assistant does so in its storage callbacks.

MCP connections

Import createMcpConnections from @supabase/agent-runtime/mcp. A definition resolves its HTTP or SSE transport from trusted context each time it is opened. Keep OAuth acquisition, refresh and credential storage in your application:

const connections = await createMcpConnections(
  [
    {
      name: 'documents',
      transport: async ({ userId }) => ({
        type: 'http',
        url: 'https://documents.example/mcp',
        headers: { Authorization: `Bearer ${await getAccessToken(userId)}` },
      }),
      allowlist: ['search', 'read'],
      aliases: { search: 'search_documents' },
      failure: 'required',
    },
  ],
  { context: { userId }, abortSignal }
)

This exposes search_documents and documents__read. Unaliased tools receive <connection name>__<remote tool name>; namespace overrides that prefix. Compose connections.tools with local tools and return connections.close from the agent's tool factory. Apply the agent's permission map to the exposed names. Assistant uses aliases to preserve its Studio tool renderer contract.

Names and aliases must be unique. Unknown tools are excluded when allowlist is provided. Connections default to required; optional discovery failures omit that connection and call onError. Authentication failures always reject, allowing the application to request reconnection. McpConnectionError supplies stable codes and connection/tool names without upstream URLs, credentials, or response bodies. The framework recognizes authorization failures during discovery and execution. MCP tool results with isError: true remain ordinary tool results and also notify onError; applications may interpret their domain-specific contents.

Clients belong to one prepared turn, use fresh credentials, and close on abort, setup failure, or explicit cleanup. Partial startup closes already-open clients. HTTP requests have a bounded timeout (requestTimeoutMs, default 30 seconds). Execution is never automatically retried. HTTP and SSE are explicit choices; there is no stdio transport, OAuth UI, MCP resource/prompt adapter, or shared cross-user discovery cache.

Skills

Each skill has name, description, and a load() callback. Only names and descriptions enter system instructions. load_skill({ name }) loads the selected registered skill on demand; it cannot read arbitrary paths. The catalog validates names and duplicates and does not cache loaded content between requests.

skillToolName adapts the loader to an integration's contract. Assistant uses load_knowledge so its existing Studio tool renderer continues to work. Skills supply guidance; tool permissions remain authoritative.

Streaming and persistence

createAgentStreamResponse adapts the AI SDK result to its UI message SSE protocol. Pass onFinish to persist the final message and onSettled to release a claimed turn and close the session. Both callbacks receive status (completed, failed, or cancelled). Settlement runs once, including when persistence fails. It waits for those operations before sending the final event, and consumes the response stream after a browser disconnect so persistence can finish. The default error formatter withholds internal error details.

Tool resources close once on explicit session.close(), cancellation, or preparation failure. Factories must clean up partial resources if discovery itself throws. After a successful stream starts, the transport owns settlement; do not close in a route's unconditional finally before the response body has finished. Always close if response setup throws. Persistence operations should be idempotent so application cleanup paths may safely settle an already-finished run.

Application-owned persistence

AgentPersistence<Context, State> and startAgentRun are available from the main export and @supabase/agent-runtime/persistence. Persistence has three callbacks:

Callback Responsibility
startRun(context, input) Authorize and claim a run, reconcile incoming messages, and return canonical { messages, state }.
finishRun(context, state, outcome) Save the response and status and release the run claim.
executeTool(context, state, operation) Optionally deduplicate a tool operation before invoking operation.execute().

Context and State are application-defined types. You can use your own identity model, session identifiers, revisions, schema, and transaction implementation. Neither object is serialized into model messages. There is no required Postgres schema or user_id, session_id, or revision column in the framework.

import { startAgentRun, type AgentPersistence } from '@supabase/agent-runtime'

const persistence: AgentPersistence<RequestContext, SavedRun> = {
  startRun: (context, { messages, trigger }) =>
    conversations.claimAndLoad(context, messages, trigger),
  finishRun: (context, state, outcome) => conversations.saveAndRelease(context, state, outcome),
  executeTool: (context, state, operation) => operations.executeOnce(context, state, operation),
}

const run = await startAgentRun({ persistence, context, messages, trigger })
const result = await session.stream({ model, messages: run.messages })

return createAgentStreamResponse(result, {
  originalMessages: run.messages,
  onFinish: ({ responseMessage, status }) => run.finish({ responseMessage, status }),
  onSettled: async ({ status }) => {
    try {
      await run.finish({ status })
    } finally {
      await session.close()
    }
  },
})

The application methods in this example own persistence. startAgentRun awaits the start callback before returning canonical history. run.state holds the returned application state, such as a revision for an integration response header. Wire tool execution through run.executeTool({ name, toolCallId, input, execute }) when using the execution callback. Without that callback, run.executeTool rejects; the framework never silently executes an unclaimed operation.

Concurrent calls to run.finish share the first in-flight save. After a successful save, later calls do nothing. If saving the response fails, a later call may settle the run as failed. Tool execution is prohibited once finishing begins, including when a save failed. Close the prepared session if startup or stream setup throws; the complete application integration handles these failure paths.

These are awaited persistence operations. Implement startRun as one atomic claim/history transaction, and finishRun as a fenced, idempotent save/release transaction. Keep authorization and approval-response reconciliation within that boundary. A logging or analytics event listener does not provide these guarantees. The execution callback must persist its claim before calling an external service, validate cached tool name and input, and reject retries whose outcome is uncertain. Callbacks choose the storage system; the framework cannot supply cross-system exactly-once execution or automatic model resumption after a Worker restart.

Assistant's Supabase implementation

Assistant implements the callbacks in db/agent-persistence.ts. Its Postgres adapter, table configuration, and declarative schemas all belong to the application. Supabase Workers provide the authenticated runtime; Assistant supplies the database connection and transactions.

That adapter preserves session ownership, stale-revision rejection, canonical approval decisions, durable operation claims, and ordered run events. Assistant's HTTP API supplies session CRUD and cursor-based event replay. Another application can implement the same callbacks over different tables or a different storage service without adopting Assistant's schema or API routes.

Validation

pnpm --filter @supabase/agent-runtime typecheck
pnpm --filter @supabase/agent-runtime lint
pnpm --filter @supabase/agent-runtime test
pnpm --filter assistant test
pnpm --filter assistant build

The package tests exercise real AI SDK loops and approval requests, real Supabase JWT verification, tool policies, lazy skills, cancellation, and persistence ordering. Assistant maintains its independent Studio wire fixtures and database security tests.