Recipe 1 — RLS-aware agent context via PostgREST on ai.*
The pattern: you have an agent that needs to retrieve from a knowledge base, but you want each end user to see only their own sources within that KB. The naive approach (agent has unrestricted KB access; you filter results in your app) leaks data via the response. The right approach: queryai.chunks directly via PostgREST under the user’s JWT before invoking the agent, and pass the resulting context into the agent run as context_items.
Step 1 — RLS on ai.indexed_sources and ai.chunks (assuming a user_id column on your ai.sources table tracks ownership):
context_items array that bypasses the agent’s own retrieval:
ai.chunks itself (because we pre-fed the context). The data path is: user JWT → PostgREST → RLS-filtered chunks → context_items → agent. There’s no point in the request where the agent has access to chunks the user shouldn’t see.
Trade-offs. This pattern works for read-only retrieval. You’re doing the RAG retrieval yourself; you lose the platform’s hybrid scoring, reranking, and the chunks→pages reflow that the typed search endpoint does for you. For full retrieval pipelines, the right answer is “use the typed search endpoint but call it from your backend, with your backend enforcing ownership before the call” — at the cost of a service-role layer in the middle.
Recipe 2 — Build-your-own authenticated chatbot
A chat app where each user has their own agent (or shares an agent with others). The user sends a message, the agent runs, the response streams back to the user. RLS should ensure that user A’s chat history isn’t visible to user B. Important up-front: the obvious approach — “let the browser call/api/agents/{id}/run/stream directly with the user’s access token” — looks like it works but is unsafe.
Why the obvious approach is wrong
When you send a request to/api/agents/{id}/run/stream with Authorization: Bearer <user_jwt>:
- The user’s JWT is signed with the same
JWT_SECRETas the Service Role Key, just withrole: "authenticated"instead ofrole: "service_role". @require_authaccepts both — the JWT signature validates and the request is authorized.- But the platform does not propagate the user’s identity to the agent’s tools. The agent’s
database_query/database_writebuiltin tools run under a superuser Postgres role (supabase_admin) inside the project-service worker. Whatever the agent decides to query, it gets back unfiltered data — including other users’ rows.
/run/stream directly with their own JWT gets an answer, but the answer is computed with full project-wide DB access. If the agent has database tools, it can leak data from other users. Don’t expose /api/agents/{id}/run/stream to clients with their own JWTs.
The safe pattern
Run the agent run from a trusted backend with the Service Role key. Inject user-scoped context yourself — either viacontext_items (Recipe 1’s pattern) or by constructing a Custom HTTP Tool that calls a route on your own backend that’s auth-checked.
/api/chat (via requireUserAuth), and the agent run happens with full database access but only acts on context you injected.
Adding a Custom Tool that’s user-aware
For agents that need to query user-specific data dynamically (e.g., “search through my saved articles”), register a Custom HTTP Tool that points at your own backend:/internal/tools/search-articles handler:
session_token through the tool’s arguments at call time. Your backend resolves it to the user identity and gates the query. The agent doesn’t know who the user is; your backend does.
The system prompt for the agent includes “always call search_user_articles with session_token = <token>” — you inject the current user’s token into the system prompt at agent-run-time, server-side. From the agent’s perspective, the token is opaque data it passes through.
This pattern is more work than “the platform forwards my JWT” would be. It’s the safe alternative given current platform behavior.
Recipe 3 — Storage vs ai.sources
You have a multi-format app: users upload PDFs that need to be indexed for RAG, plus they upload images for display. Two distinct flows. For files that need to enter the RAG pipeline (PDFs, docs, anything you want chunked + embedded):- Upload via
POST /api/sources/upload(the typed Sources endpoint). - The platform creates a row in
ai.sources, stores the file in a platform-managed Storage bucket, dispatches the extraction Celery task, and returns the source id. - Add the source to a KB via
POST /api/knowledge-bases/{id}/sourcesto trigger indexing.
- Upload via
POST /storage/v1/object/{bucket}/{path}(the typed Storage endpoint). - The file lands in
storage.objectsunder your own bucket. Your application code reads/displays it.
ai.sources vs storage.objects), and have different RLS postures. Don’t try to share buckets between them. The platform assumes ownership of its Sources bucket layout and will overwrite paths.
For files that need to be both (e.g., a PDF the user uploaded that they should be able to download AND that’s indexed for RAG): upload twice, once through each path. They’re independent — one row in storage.objects (for download) and one row in ai.sources (for indexing). Or pick one path: upload to ai.sources and use the platform’s pre-signed download URL for the user-facing download.
A worked end-to-end flow for an “upload a PDF, then chat with it” pattern:
public.documents is your own table for “who owns which source.” ai.sources is the platform’s table; public.documents.source_id references it for the ownership graph.
Recipe 4 — Realtime + agent runs
For showing live progress during agent runs: the agent SSE stream is one event source, but if you want UI updates to also propagate to other devices (the user’s phone watching while their laptop runs the chat), Realtime is the right second channel. Two patterns: Pattern 4a: Mirror the SSE stream to a Realtime channel. Your backend receives the SSE stream from/api/agents/{id}/run/stream, persists each event to a row in a public.chat_messages table, AND re-broadcasts each event to a per-user Realtime channel. Clients subscribe to the channel for live updates.
public.chat_messages; a trigger function calls realtime.send() to broadcast it. The application code doesn’t need to call Realtime explicitly — the database side-effect drives the broadcast.
realtime.messages (so only the user themselves can subscribe to their channel) and you have device-syncing chat for free, gated by the same auth model as the rest of the app. See Realtime model for the function reference.
Why Recipe 2 is shaped this way
A platform investigation while drafting the audit found that the agent surface does not forward end-user JWTs to its tools. Concretely:@require_authaccepts user JWTs and setsg.user_idfrom thesubclaimg.user_idis consumed only by session-ownership checks; never propagated to tool dispatch- The
database_query/database_writebuiltin tools execute SQL via the project service’s SQLAlchemy session, configured fromDATABASE_URL=postgres://supabase_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}(a superuser connection) with noSET ROLEand noset_config('request.jwt.claims', ...) - Custom HTTP tools forward only the
config.headersset at tool-create time — no request-time auth injection
/api/agents/{id}/run/stream directly with a user JWT gets full project-wide database access from any tool the agent calls. This is the footgun Recipe 2’s “safe pattern” works around. If/when the platform adds JWT forwarding (some kind of act_as_user parameter or automatic propagation), the safe pattern simplifies considerably.
For now: only call agent endpoints from trusted backends, with the Service Role key, with user-scoped context pre-injected.
Next steps
ai schema recipes
PostgREST patterns on ai.* that complement these higher-level cookbooks.
RLS Cookbook
The lower-level RLS patterns these recipes build on.
Billing model
What every agent run and workflow execution costs in credits.
Realtime subscriptions
Detailed Realtime patterns the recipe-4 broadcast is one application of.