Borrowing it
Nothing to install: this file belongs to alnutile/supabase-as-a-service. Take a copy, put it at the same path in your own repository, and replace the rules that are about this project with yours.
curl -O https://raw.githubusercontent.com/alnutile/supabase-as-a-service/main/AGENTS.mdgit clone --depth 1 https://github.com/alnutile/supabase-as-a-serviceWrote this? Show the measurements
A badge with what this costs and how it scanned, read live from this page, so it follows the numbers instead of freezing them. Markdown for a README, HTML for a documentation site or a project page.
[](https://agentmods.dev/instructions/alnutile/supabase-as-a-service/agents-md)<a href="https://agentmods.dev/instructions/alnutile/supabase-as-a-service/agents-md"><img src="https://agentmods.dev/badge/instructions/alnutile/supabase-as-a-service/agents-md/github.svg" alt="Measured on agentmods" height="20"></a>Or the 80×15 button, for a site that already has a row of RSS and ATOM ones. Only the verdict fits; the numbers stay here.
<a href="https://agentmods.dev/instructions/alnutile/supabase-as-a-service/agents-md"><img src="https://agentmods.dev/badge/instructions/alnutile/supabase-as-a-service/agents-md.svg" alt="Reviewed on agentmods" width="80" height="20"></a>What it costs to keep this loaded
Counted locally with the o200k_base tokenizer, which is exact for GPT models; Claude uses its own tokenizer and its counts differ. Treat this as one consistent yardstick across the catalogue rather than a bill. Prices are per million input tokens.
| Model | Per session | Once invoked |
|---|---|---|
| Fable 5.1 | $0.29811 | $0.29811 |
| Opus 5.5 | $0.11924 | $0.11924 |
| Sonnet 5.5 | $0.05962 | $0.05962 |
| Haiku 4.5 | $0.02981 | $0.02981 |
Grade A, and why
supabase-as-a-service AGENTS.md scanned grade A with 1 finding against 26 rules in 11 categories — prompt injection, anti-refusal, data exfiltration, privilege escalation, supply chain, agent snooping, system-prompt leakage, SSRF and excessive agency — measured 23d ago.
A static scan of the body, not an audit. Every finding is printed with the line that produced it so you can judge whether it matters here. A mod is markdown that instructs an agent; that is exactly why what it instructs is worth reading.
Makes network callslowCapability
Not a fault in itself. Listed so you know the mod talks to something, and to what.
non‑Codex systems (scripts, Zaps, cron) can push/sync artifacts with a `curl` instead of This is a copy
92% identical to supabase-as-a-service CLAUDE.md — 214 lines differ, which has more behind it and is treated as the original. This page carries a canonical link to it rather than competing with it.
How it starts
The opening of the file, as written. The whole thing — 1,310 lines — stays where its author put it; the contents beside it link to each section on GitHub.
AGENTS.md
Guidance for AI agents (and humans) working in this repository.
What this is
A React intranet layer on top of Supabase: auth, an AI chat assistant, shareable "artifacts" (docs/code/HTML), file storage, and live realtime updates. The OpenRouter key is server-side only (a Supabase Edge Function); the browser holds just the Supabase anon key and is protected by Postgres row-level security.
Commands
npm install # install deps
npm run dev # Vite dev server on http://localhost:5173
npm run build # tsc -b (typecheck) + vite build — run before pushing
npm run lint # eslint
npm run typecheck # tsc -b --noEmit
npm test # vitest (frontend unit tests: src/**/*.test.ts(x))
npm run test:deno # deno test supabase/functions/tests/ (edge-function units)
npm run gen:types # regenerate src/lib/database.types.ts from the linked project
npm run start # serve dist/ with SPA fallback (production / Railway)
Always run npm run build before committing UI/logic changes — it typechecks the whole app.
Run npm test (and npm run test:deno if you touched supabase/functions/) too; add tests
when you add or change logic (parsing, validation, calculations). Testable logic is kept out
of components/handlers on purpose — the :::artifact protocol parser lives in
src/lib/artifacts.ts (not ChatPage), webhook payload validation in
supabase/functions/_shared/validate.ts, the p page's HTML/state injection in
supabase/functions/p/meta.ts — follow that pattern: extract pure logic, test the module.
CI (.github/workflows/test.yml) runs lint + build + both suites on PRs and pushes to main;
Codex-feature.yml runs the same checks against the bot-built branch (its PRs can't trigger
PR workflows — GITHUB_TOKEN anti-recursion).
Architecture & data flow
- Auth:
src/contexts/AuthContext.tsxwraps Supabase Auth (session, sign in/up, magic link, sign out).ProtectedRoutegates the authenticated app; routing is insrc/App.tsx. - Home dashboard (
src/pages/HomePage.tsx, route/home): a two-tab landing page. Overview is a live "what's going on" dashboard — quick actions, stat tiles (open to-dos with a completion bar, artifacts, files, events this week), a dependency-free 14-day activity trend chart, a realtime recent-activity feed, and your open to-dos with inline complete. Explore keeps the original feature-card index + ⌘K search. Pure logic (day-bucketing, completion %, relative time, activity→dot mapping) is insrc/lib/dashboard.ts(unit-tested). Custom widgets (migration 0078): users compose their own tiles — adashboard_widgetsrow is{kind: stat|list|chart, source, spec}(owner-only, realtime). The "Add widget" prompt (AddWidgetPanel) drives the AI to call the seededcreate_widgetbuiltin from a natural-language description; the dashboard (DashboardWidget) renders each by querying itssource(a fixed allow-list: todos/artifacts/files/links/collections/activity) under the caller's RLS — so a stored spec can never read another user's rows, and a customer adds widgets without forking the code (configuration-as-data, like tools/agents/forge). The allow-list + spec sanitizing lives insrc/lib/widgets.ts(browser) and_shared/widgets.ts(the create_widget validator), both unit-tested. (Planned: drag-reorder, workspace-shared widgets, more sources, a REST/MCP surface for widgets.) - Chat:
src/pages/ChatPage.tsxwrites user/assistant messages to Postgres and subscribes tomessagesvia Supabase Realtime (websockets) for cross-device sync. The composer can attach files (📎): they upload to thefilesbucket (and show in Files), are stored on the message'sattachmentsjsonb, and the chat function reads them — images/PDFs as content blocks, text inlined — so the assistant can parse them. Streaming comes fromsrc/lib/chat.ts→POSTto thechatedge function, which returns SSE linesdata: {"delta": "..."}and ends withdata: [DONE]. The function calls OpenRouter; the client only sends the message history + the user's access token (+ the anon key as theapikeyheader). Background persistence (migration 0079): the main composer sends{conversationId, persist:true, runId}, and thechatfunction finishes the model run and writes the assistant message itself (service role, as the caller) inside anEdgeRuntime.waitUntilbackground task — the same off-request pattern as slack-events/loop/evals. So reloading the page or navigating away mid-reply no longer loses it: the run completes server-side,:::artifactblocks are materialized there (shared_shared/artifacts.tsparseArtifactBlocks, unit-tested — a mirror of the frontend parser), and the saved artifact/message rows are handed back over SSE (data:{"artifact":…}/data:{"message":…}) for an instant in-place update while connected; a disconnected client picks them up via the existingmessagesRealtime subscription (or a remount). Because a dropped SSE looks identical whether the user navigated away (→ keep going) or hit Stop (→ halt), Stop writesconversations.cancel_requested_run = runId; the task checks that marker between tool turns and again right before it persists, and if it matches the in-flight run it stops and saves nothing (scoped by runId so a stale marker can't cancel the next send). Skill runs, the Cards board chat panel, and the Collections bubble omitpersist, so they keep the classic client-side insert path (server streams and aborts on disconnect, unchanged). Group chat (humans first, AI on @ai): a thread can be shared with other workspace members — the "People" button on an open conversation adds them viaconversation_members(migration 0053). Access = owner OR member, enforced by RLS through thesecurity definerhelpercan_access_conversation()(avoids policy recursion); members read the whole thread and post as themselves (owner_id = auth.uid()is enforced on insert, so the sender can't be spoofed), and only the owner deletes the thread. In a shared thread ("Team thread" badge) humans talk to each other over the existing Realtime channel and the AI only replies when a message contains@ai— the gating is inChatPage.submit()(a plain message just inserts + touches the conversation; no model call, so no cost). When summoned, each human message in the model history is prefixed with the sender's name (from thelist_workspace_members()security-definer directory RPC — profiles RLS stays owner-only) plus a system note explaining the group context. Other people's bubbles render left/neutral with a name label; yours stay right/purple. Solo threads keep the classic always-reply behavior. - Artifacts:
ArtifactsPage(list/create) andArtifactEditorPage(edit, preview, set visibility, delete). Public/unlisted artifacts are read anonymously by slug inPublicArtifactPageat route/share/a/:slug. Soft delete / archive + recovery (migration 0101): artifacts (and skills) carry a nullabledeleted_at. Deleting ARCHIVES by default (setsdeleted_at) — the row is hidden from every normal view (grids, search,p/share pages, collections context) but stays recoverable; a real row DELETE is the permanent removal. The owner still sees their own archived rows over RLS (that's the recovery area — the SELECT policy hides archived only from non-owner branches, and the security-definer share RPCs got the same guard); service-role edge reads (chat/orchestratoralways-on prompts,collections.ts,security.ts, the REST list) filterdeleted_at is nullin code since RLS is bypassed. TheTrashpanel onArtifactsPage/SkillsPagerestores or deletes-for-good; the editor/bulk "delete" buttons archive. Both the internal assistant and the external MCP server get the lifecycle:delete_artifact/delete_skill(archive by default,permanent:trueto destroy),restore_artifact/restore_skill, and anarchivedfilter onlist_artifacts/list_skills(the recovery area). Handlers in_shared/builtins.ts; the MCP server delegates viarunBuiltin. Optional share password (migration 0062): when an artifact is unlisted/public the editor's Sharing panel (VisibilityControl) can require a password before a viewer sees it — for handing a customer a link + password. This is NOT client-side theater: setting a password (owner-onlyset_artifact_password()RPC, bcrypt via pgcrypto) makes the read RLS policy hide the whole row from non-owners (visibility <> 'private' AND share_password_hash IS NULL), so the content never reaches anon. The viewer pages (PublicArtifactPage,StandaloneArtifactPage) shareuseSharedArtifact()(src/components/ArtifactPasswordGate.tsx): a normal RLS read still fast-paths non-password shares; when it comes back empty,artifact_share_meta(slug)says whether a password is needed andget_shared_artifact(slug, password)(security-definer, returns the row WITHOUT the hash only when the bcrypt check passes) unlocks it. Pure branching logic (password validation, gate decision) is insrc/lib/artifactShare.tsand unit-tested. The rawpedge function has no gate, so password-protected artifacts simply 404 there. Inline images (GitHub-style): inArtifactEditorPageyou can paste, drag-drop, or attach (📎 "Image") an image into the body; it uploads to the publicartifact-imagesbucket (migration 0059) and a markdownlink is spliced in at the caret. The bucket is public (not owner-private likefiles) on purpose: the URL is baked into stored markdown and re-rendered forever — a signed URL (7-day max) would break — and it must load for anonymous visitors once the artifact is shared. Objects live under‹owner›/‹uuid›/‹name›(unguessable "secret-URL" privacy, same trust model as public slugs); writes are folder-scoped, reads are public. Pure logic (image detection, filename sanitizing, cursor-aware markdown insertion) lives insrc/lib/artifactImages.tsand is unit-tested; the resilient upload reusesuploadPickedFile(path, file, bucket). Standalone hosting: anhtmlartifact can also be viewed as a clean, chrome-free full-viewport page at the app's own public/p/:slugroute (StandaloneArtifactPage) — for sharing "a great diagram in HTML" with the public without deploying a whole app. It renders insideArtifactFrame's opaque-origin sandbox (user HTML never runs raw on the app origin, where visitors hold a session). The editor's Sharing panel andPublicArtifactPagelink to it (standalonePageUrl(slug)insrc/lib/supabase.ts). There is also a publicpedge function (verify_jwt: false,GET /functions/v1/p/‹slug›) that returns the artifact's rawcontentastext/html(injecting<title>/OpenGraph tags + a permissive CSP +nosniff; anon-key query so RLS hides private rows; onlytype='html'renders, anything else 404s) — but Supabase rewritestext/html→text/plain(+ a sandbox CSP) on*.supabase.cofunction URLs (anti-phishing), so browsers show raw source there. The function only truly renders behind a Pro-plan custom functions domain, which is why the UI links to/p/:sluginstead. Because both serve straight fromartifacts.content, editing the artifact updates the page live; an external/local AI app can also push HTML up (via MCP) to the same table and get the same URL. (Planned: multi-file/bundled SPAs behind the samep/‹slug›URL via a public storage bucket; an MCPpublish_htmlone-call helper.) Interactive artifacts (live trackers, migration 0054): anhtmlartifact can be a Codex.ai-style stateful mini-app (tracker/kanban/checklist) — the user clicks and the changes persist. The sandboxed iframe (allow-scripts, opaque origin — deliberately no localStorage/session access) talks a tiny postMessage protocol implemented bysrc/components/ArtifactFrame.tsx: iframe postsartifact:ready→ parent repliesartifact:statewith the savedartifacts.datajsonb; iframe postsartifact:saveon each user change → the PARENT (authed client, normal RLS) persists it debounced. The artifact HTML never sees credentials.ArtifactFrameis used by the editor preview, the public share page (saves are RLS no-ops for non-owners), and ChatPage's live artifact panel: creating an artifact in chat (or clicking an artifact link in an assistant message) opens it beside the thread; the panel follows the row over Realtime (artifactsjoined thesupabase_realtimepublication in 0054), so assistant edits render live. Thepstandalone page injects the saved state aswindow.__ARTIFACT_DATA__(read-only render for anon visitors). Seededis_builtintoolsget_artifact/update_artifact(handlers in_shared/builtins.ts; update is owner-only, logsartifact.updated) let the assistant read the current version and evolve it in place instead of duplicating; a seeded always-on "Interactive artifacts" prompt (its ownauto_applyskill row) teaches the bridge snippet + the get-then-update revision flow. The editor's Chat button deep-links/chat?artifact=:id: the artifact opens in the live panel and, whenever the panel is open,ChatPage.submit()appends a system note naming it — so "update it / check off X" resolves to get_artifact→update_artifact on THAT artifact instead of minting a new one. Both side panels (chat + editor preview) are drag-resizable on md+ viasrc/components/ResizeHandle.tsx(usePanelResize: pointer-captured drag on the left edge — capture keeps the drag alive over the iframe — width persisted per-panel in localStorage, exposed as a--panel-wCSS var consumed only atmd:). REST CRUD API (artifactsedge function,verify_jwt: false): a plain‑REST API so non‑Codex systems (scripts, Zaps, cron) can push/sync artifacts with acurlinstead of MCP. Auth is a per‑user bearer token — the samemcp_tokensfrom Settings → Connect Codex; the function runs as the token's owner (service role) and re‑enforces ownership in code.GET/POST /functions/v1/artifacts,GET/PATCH/PUT/DELETE /functions/v1/artifacts/:id; create/update acceptcollection/collections(name or id, created if missing) to tag artifacts into collections (additive), plusvisibility(non‑private mints apublic_slugshare_url). A bareGETwith noAuthorization(or/artifacts/docs) returns plain‑text docs. Full reference:docs/artifacts-api.md. In‑app docs:ApiPage(route/api, sidebar "API") is a tabbed area (first tab Artifacts; more later) that renders the base URL, endpoints, body reference, and copy‑readycurlexamples — and lets you pick/create a bearer token (mcp_tokens) so the examples are paste‑ready.
- Whiteboards (Planner — Excalidraw canvases, migration 0074): a
whiteboardsrow is a planning canvas — atitle+ ascenejsonb (the Excalidraw scene:{elements, appState, files}), a structured "file" the AI can read.WhiteboardsPage(route/whiteboards, sidebar Planner) is the list/create + collection filter (the sharedAddToCollectionBargained awhiteboardkind);WhiteboardEditorPage(/whiteboards/:id, lazy-loaded inApp.tsxbecause@excalidraw/excalidrawis large — the firstReact.lazyin the app; Vite needsdefine: {'process.env.IS_PREACT'}or the bundle throws) embeds the Excalidraw canvas. Visibility mirrors links/todos:private(owner + admins) orworkspace(every member can see and collaborate), enforced by RLS;collection_whiteboardsis the many-to-many join (mirror ofcollection_links), so a board files into collections and the genericcollection.item_addedtrigger learns it. Real-time multiplayer: the editor opens the app's first Realtime broadcast + presence channel (whiteboard:‹id›) — localonChangebroadcasts the scene (debounced) andonPointerUpdatebroadcasts cursors (throttled), remote peers apply them viaexcalidrawAPI.updateScene({elements})/{collaborators}; presence drives the live peer count. Echo is suppressed with a per-element version signature (sceneSig) so applying a remote scene doesn't re-broadcast. The scene is autosaved (debounced) towhiteboards.sceneas the durable store, and the SAME channel carries apostgres_changesUPDATE subscription so the assistant'supdate_whiteboardwrites (which don't broadcast) — and other devices — render live; if broadcast ever drops, save + postgres_changes keeps everyone eventually-consistent. AI reads AND draws: the pure, unit-testedsupabase/functions/_shared/whiteboard_scene.ts(sceneToTextrenders a board's text/shapes/arrows to a readable summary;normalizeElements/buildSceneturn the loose skeleton elements the model writes — with alabel:{text}convenience → a bound text child — into renderable Excalidraw elements).loadCollectionsContextinjects each collection's whiteboards as text (budgeted like artifacts), and seededis_builtintoolscreate_whiteboard/list_whiteboards/get_whiteboard/update_whiteboard(replace or append elements) /add_whiteboard_to_collection(in_shared/builtins.ts; same names on the MCP server) let chat/scheduler/webhook agents and an external Codex read a board and draw on it — "draw me a flowchart", "check what's on the board". Emitswhiteboard.created/updatedevents. (Planned: public read-only/share/w/:slugsnapshot pages like artifacts; a live board panel in Chat; whiteboards counted in the collection token meter; self-hosted Excalidraw fonts instead of the default CDN.) - Card boards (Planner — free-form card walls, migration 0075): the second Planner
surface (sidebar Cards, under Assets next to Whiteboards). A
card_boardsrow is atitle+ acardsjsonb array ([{id, text, color, x, y}]) — deliberately NOT a Kanban: you dump cards and drag them anywhere, position IS the priority ranking. Low-friction on purpose —CardBoardEditorPage(/cards/:id) is a dependency-free drag canvas (no Excalidraw): double-click empty space to add a card in edit mode, drag its top bar to move (pointer-events +cardsSigecho suppression), × to delete, a swatch to recolor. Sticky pastel colors (solid, dark text) read the same in light/dark.CardBoardsPage(/cards) is the list/create + collection filter (the sharedAddToCollectionBargained acard_boardkind). Visibility mirrors whiteboards (private/workspace, RLS-enforced);collection_card_boardsfiles a board into collections and the genericcollection.item_addedtrigger learns it. Real-time multiplayer reuses the whiteboard pattern: a broadcast+presence channel (card_board:‹id›) syncs card moves live + drives the peer count, thecardsjsonb is debounced-autosaved as the durable store, and apostgres_changesUPDATE subscription on the same channel renders the assistant's writes (and other devices) live. AI reads AND fills: the pure, unit-testedsupabase/functions/_shared/card_board.ts(cardsToTextrenders the cards top-to-bottom as a prioritized list;normalizeCards/buildCardsauto-position the loose{text,color?}cards the model writes).loadCollectionsContextinjects each collection's boards as text, and seededis_builtintoolscreate_card_board/list_card_boards/get_card_board/add_cards/add_card_board_to_collection(in_shared/builtins.ts; same names on the MCP server) let chat/agents and an external Codex dump ideas as cards and read a board ("brain-dump these 10 ideas as cards", "what's the top priority on the board"). Emitscard_board.created/updatedevents. Chat panel (persistent): the editor's Chat button opensBoardChatPanel(src/components/BoardChatPanel.tsx) — unlike the ephemeral CollectionsPage bubble, it's backed by a REAL conversation found-or-created for (owner,conversations.card_board_id, migration 0076) in the same conversations/messages tables the main Chat uses, so history persists, syncs over Realtime, and shows up in the chat list. It streams viastreamChat({ cardBoardId }); the chat function injects THAT board'scardsToTextas primary context (access re-enforced: own or workspace) and tells the assistant toadd_cards/get_card_boardby id — so "add cards for X" writes to the board and the canvas re-renders live over the existingpostgres_changessubscription. (Planned: card-boards in the collection token meter; public share snapshots; card size/richer content; the same persistent chat panel on whiteboards + the collections bubble.) - Knowledge compiler (compiled knowledge, migration 0112): the shift from a
workspace with search to a knowledge compilation system. The default flow used to be
"add information → search for it later → generate an answer", which makes the model
re-interpret raw documents on every question; the compiler makes it "add information →
interpret it → link it → update existing knowledge → flag conflicts → produce a brief".
The distinction everything follows from: a raw file is no longer an answer, it is
EVIDENCE — the answer lives on a compiled page, and every compiled claim keeps a pointer
back to the source it came from. A
knowledge_pagesrow is a maintained page (concept/decision/process/person/project/terminology/principle/question/profile) with a stablekey(a slug, so a refined page overwrites IN PLACE rather than minting a near-duplicate — the failure mode the whole feature exists to prevent), a lifecyclestatus(compiled/needs-review/contradicted/stale/confirmed/archived) and ahuman_confirmedflag.knowledge_claimsis the provenance layer (statement + source kind/id/label + captured_at + confidence + a normalizedfingerprintso re-running a pass doesn't restate the same claim);knowledge_linksrecords relationships;knowledge_conflictsis the review queue;compile_runsholds a pass + its change brief (+ a liveprogresschecklist, same pattern assecurity_scans). Collections become compilation domains: acompile_policiesrow is the per-collection TRUST BOUNDARY —autonomy(suggest= nothing unattended |guarded(default) = creates + additive appends apply, rewrites go to review |auto= rewrites apply too), which source kinds feed it, which page kinds it maintains,never_autoguards matched against a page's kind, labels or title ("financial commitments", "client-facing"), a confidence floor and a staleness horizon. Compilation is not unrestricted autonomous editing:supersede(wholesale replacement) is never unattended at ANY level, a human-confirmed page is only ever appended to, and anything that contradicts compiled knowledge goes to review. All of that judgment is the pure, unit-testedsupabase/functions/_shared/compiler.ts(classifyUpdateis the trust boundary; also policy normalization — an allow-list, so an unknown kind is DROPPED not trusted — the prompt builder, strict-JSON parsing that fails closed, page matching, claim fingerprinting, staleness,formatChangeBrief,compiledContextBlock), with the I/O in thecompileedge function (verify_jwt:false, session JWT ormcp_tokensbearer like run-tool, plus a service-role internal path that honorstriggered_by— mirroring loops/evals). The compiler never resolves a contradiction: it detects one, leaves the page alone, marks itcontradictedand writes a review item; an update the boundary declined is parked as ahelditem WITH the body it wanted to write, so approving it later is a click not a re-run, and the page it targets is flaggedneeds-review(shouldFlagPendingReview) socompiledContextBlockwarns inline — without that the page reads as settled truth while the revision waits in the queue and the review gate becomes a silent staleness bug (found by A/B-testing the feature against itself). Resolving marks the page human-confirmed (a person just read it) and restarts its freshness clock. Center of gravity:loadCollectionsContextnow leads with a collection's compiled pages and follows with the raw material labelled as the evidence behind them (stale/contradicted pages flagged inline so the assistant says what's disputed instead of asserting it) —search_documentsbecomes the fallback for "nothing compiled yet" / "I need the exact wording" / "this page is disputed", not the primary intelligence layer.KnowledgePage(route/knowledge, Assets) is Review (conflicts first, on purpose) / Compiled / Briefs (live checklist while a pass runs) / Policy, realtime-subscribed. Eight seededis_builtintools in_shared/compiler_tools.ts(compile_collection/list_knowledge_pages/get_knowledge_page/update_knowledge_page/list_conflicts/resolve_conflict/get_change_brief/set_compile_policy) give every agent loop and an external Codex the same surface, delegated from the MCP server viarunBuiltinso the two never drift — andupdate_knowledge_pageis deliberately narrower than the compiler (append/revise only, human-confirmed pages append-only) so a tool call can't route around the boundary. A seeded always-on "Knowledge compiler" prompt teaches the discipline (compiled first, raw as evidence, never pick a winner). Emitsknowledge.page_created/page_updated,knowledge.conflict_detectedandknowledge.compiledevents, so a listener can route a brief to Slack or react to a contradiction; a nightly pass is just an agent scoped tocompile_collectionon aschedulesrow. Full reference:docs/knowledge-compiler.md. (Planned: retrieval over compiled pages instead of whole-page injection; a relationship graph view; publishing a compiled page to a durable artifact URL from the UI; a compiled workspace/personal profile page; compiling straight off aneventas sources land.) - Collections (tag artifacts → chat with a focused set): a
collectionsrow is a named group ("tag") of artifacts;collection_artifactsis the many-to-many join (migration 0033). OnArtifactsPageyou multi-select artifacts (checkboxes) and file them into one or more collections via the floating "Add to collection" bar (creating a collection inline), and a filter bar scopes the grid to one collection. In Chat the 📚 picker selects one or more collections (multi-select; "Chat with this" deep-links/chat?collection=:id, or?collections=a,b);streamChat({ collectionIds })passes them to the chat function, whichloadCollectionsContextgathers the deduped union of their multi-content items and injects them as a primary-context block in the system prompt: artifacts (text), files (collection_files, migration 0042 — text files inlined, PDFs via their indexed knowledge chunks, viafileToText; images/binaries skipped), to-dos, and tables (collection_tables, migration 0045 — a preview of theut_*rows as JSON). The sharedAddToCollectionBarcomponent drives "select items → Add to collection" on the Artifacts and Files pages;_file_charsfolds files into the size meter RPCs.CollectionsPage(route/collections, Assets) is a per-collection dashboard: a card per content type (to-dos/artifacts/files/tables) with add/remove, the context-window meter, and a floating chat bubble (streamChat({ collectionIds:[id] })) that answers about the collection and files new to-dos/notes/artifacts back into it via the builtin authoring tools. Access mirrorsuser_tables: a collection isprivate(owner + admins) orworkspace(every member can read and collaborate — add/remove members); the join table's RLS inherits the collection's visibility. The chat function runs as the service role so it re-enforces access in code (collection visible to caller; only own/non-private artifacts injected). Collections are also an ingestion target: MCP exposescreate_collection/add_to_collection/list_collectionsandcreate_artifacttakes an optionalcollection(name, created if missing), so an external Codex can push blog posts / video transcripts / notes from other systems into a named collection the team can chat with. The internal assistant has the same authoring power asis_builtintools (create_artifact/list_artifacts/get_artifact/create_collection/add_to_collection/list_collections/add_notein_shared/builtins.ts, migration 0040), so chat + scheduled/webhook agents can ingest content into artifacts + collections + the knowledge base too (e.g. an agent that files fetched articles into a collection on a schedule or a GitHub webhook). Reliable artifact→collection filing (migration 0067): so "put this in collection X" never gets stuck,create_artifactreturns the new id and accepts acollectionsarray (file into several at once, each created if missing);list_artifactslists your artifacts newest-first with ids (filter by collection/title_contains/type) so a freshly-created (or:::artifactauto-saved) row is retrievable in the same turn;get_artifact(id or exact title) andadd_to_collection(now takesartifact_titleas an id alternative) let the assistant file without ever seeing an id. All read the primary via the service role — no read-after-write lag. Pure branching (id detection, collection-ref parsing, limit clamping) lives insupabase/functions/_shared/artifacts.tsand is unit-tested (tests/artifacts_test.ts); the same handlers back the MCP server (mcp/index.ts) so external + internal Codex stay in lockstep. Context-window awareness: each collection shows an estimated token count (≈chars/4 — there's no single correct tokenizer) and, viauseOrchestratorContext→ the live OpenRouter model'scontext_length(fetched from/api/v1/models), what % of that window it would fill (ContextMeteron the Artifacts collection header + filter chips;ContextUsagein the chat picker rows + active chips), so the user can judge fit and pick a different model if needed. When several collections are scoped together, the chat picker shows the combined, deduped size (thecollections_combined_chars(uuid[])RPC, migration 0035) so overlapping artifacts aren't double-counted. Single-collection sizes come from the RLS-awarecollection_token_stats()RPC (migration 0034,security invoker) on Chat, and a client-side memo on the Artifacts page (which already holds the content). The chat function budgets the total injected content to the model's real window minus a reserve for the conversation + reply (so the meter matches what's sent). (Planned: collection-scoped retrieval/RAG + hybrid search as a core function instead of full-content injection; collection visibility on the public share pages.) - To-dos:
TodosPage(route/todos, sidebar under Assets, any member) is a task list that plugs into the collections concept. Atodosrow (migration 0041) istitle+ optionalnotes+ optionaldue_date+ adoneflag + apositionfor manual drag-to-reorder (dnd-kit; order byposition asc, created_at desc, with a "Due date" sort toggle). Visibility mirrors collections/user_tables:private(owner + admins) orworkspace(every member can see and collaborate — check off / edit / reorder), enforced by RLS. To-dos are filed into collections viacollection_todos(the exact mirror ofcollection_artifacts, same visibility-inherited RLS), so the same multi-select "Add to collection" bar + filter asArtifactsPage, and a collection you chat with carries its tasks alongside its docs (the chat function'sloadCollectionsContextinjects each collection's to-dos as a checklist block). Like artifacts, to-dos have a REST API (todosedge function,verify_jwt:false, bearer-tokenmcp_tokens, full CRUD +collection/collectionstagging; docs atdocs/todos-api.mdand the "To-dos" tab inApiPage) and are exposed to the assistant + MCP: seededis_builtintoolscreate_todo/list_todos/complete_todo/update_todo/add_todo_to_collection(in_shared/builtins.ts; the MCP server delegates these four torunBuiltinso the external and internal paths never drift) so chat/scheduler/webhook agents and an external Codex can manage tasks. Lifecycle lanes + five views (migration 0116): binarydoneis enough for a checklist and not enough for a queue you actually work — "committed to but not started" and "waiting on someone else" both read as an unticked box.todos.statusadds the lane (triage→next→doing→blocked→done) andtodos.sourcerecords who filed it (agent/api/null-for-a-person).donestays the source of truth for "closed" — every older surface reads it (REST, the builtins, the Home dashboard tiles + thedashboard_widgetsstatusspec, which means open/done and is untouched, the collections context block, thetodos_due_idxpartial index) — and thetodos_sync_statusBEFORE trigger reconciles the pair whichever half a writer sets, so a pre-0116 client can never leave a row contradicting itself. The rule is mirrored in the purereconcileStatus()(src/lib/todos.ts) so the optimistic UI row equals the persisted one; the one asymmetry is deliberate: un-ticking lands innext, nottriage(it reads as a correction). Every writer sends only the half it names. The page is now five views over the same filtered set — List (the original sortable list, still the default), Board (status lanes, drag to change state), Time (overdue/today/this week/later/no date, drag to reschedule — Overdue takes no drops, you can't schedule into the past), Calendar (month grid + an undated pile you drag onto a day) and Focus (one at a time, overdue → due today → blocked → agent-filed triage). All the grouping/ranking/date maths is pure and unit-tested insrc/lib/todos.ts; the views themselves aresrc/components/TodoBoards.tsx. Drag targets + live boards (migration 0120): the WHOLE card is the drag surface — the first cut used a 16px handle that looked exactly like the checkbox beside it, so the boards read as having no drag at all. The pointer sensor's 4px activation constraint keeps a plain click a click, and the checkbox/titlestopPropagationon pointerdown so ticking or opening a to-do never starts a drag. ADragOverlayrenders the moving copy in a portal — without it the card is clipped by its own lane'soverflow-y-autoand a cross-lane drag looks broken even when it works. Calendar day chips are draggable in their own right (DayChip), so a date changes by moving a chip day→day or back onto the undated pile; before, only the undated pile could start a drag and the calendar was a one-way trip. Realtime:todos+collection_todosjoined thesupabase_realtimepublication (bothreplica identity full, so a DELETE carries enough row for Realtime to evaluate the SELECT policy and actually forward it), andTodosPagesubscribes topostgres_changes— the server row is merged by id, so state converges regardless of who wrote it or what order events land in. RLS applies to the stream, so publishing widens nothing: a private to-do is only ever streamed to its owner. A row another session changed flashes an info ring for ~2.5s; the page tracks its own recent writes (ownWrites) so your own echo never flashes at you. (Planned: per-lane WIP limits; a recurring-to-do rule; lanes on the Home dashboard tiles; presence on the board — who else is looking — reusing the whiteboard/card-board channel pattern.) - Collection picker (one control instead of a chip wall): every collection-aware page
used to render one pill per collection in a wrapping row. That reads fine with six
collections and falls apart at fifty — five lines of chips above the content, no way to
find one by name, and the empty ones (a workspace accretes them) taking as much room as
the ones you use.
src/components/CollectionPicker.tsxis one button that opens a searchable, counted list, with aCollectionTokensrow underneath showing what's picked; empty collections are hidden behind a "Show empty (n)" toggle, and a selected one stays visible even when a filter empties it.mode="single"replaces rather than accumulates, so a single-select page adopts it without changing its semantics. In use onTodosPage(multi),ArtifactsPage,LinksPage,TerminologyPageandAgentsPage(single). Pure logic (filtering, the empty rule, the trigger label) issrc/lib/collectionPicker.ts, unit-tested. Note each page passes a stableEMPTY_SELECTIONset rather than a freshnew Set()— the picker memoizes onselected. (Planned: rolling it onto the Files/Whiteboards/Cards grids and the Chat collection picker, which has its own context-meter rows.) - User memory (per-user, so new chats start warm): a durable personal profile the
assistant carries across conversations — the user's name, defaults, tone, stack, ongoing
projects, standing preferences — so a fresh chat isn't a blank slate. A
user_memoriesrow (migration 0069) iscontent+ optional stablekey(for upsert-in-place, so a refined fact overwrites instead of duplicating) +category+pinned+sourceprovenance. Access is owner-only (likeconversations/files, deliberately NOT the private/workspace model — one user's memories never leak into another's context), enforced by RLS.MemoryPage(route/memory, Assets) lists/edits/pins/forgets them and is realtime-subscribed (the AI writes here too). The assistant reads/writes memory through seededis_builtintoolsremember/list_memories/update_memory/forget(handlers in_shared/memory.ts, run by all three agent loops viarunBuiltin; the MCP server exposes the same names, so an external Codex uses one code path). The chat and scheduler loops inject the caller's memories into the system prompt vialoadUserMemories— that's what makes a new chat / scheduled run pre-warmed. The webhook and Slack loops deliberately do NOT auto-inject (they face external callers; personal memory must not bleed into a reply to an untrusted source — those agents can still read it vialist_memoriesif scoped in). Every write emits amemory.created/memory.updatedevent (0063, private visibility), so the automation layer works in both directions: a listener can react to a saved memory, and a listener'srun_tool → remembercan write memory in response to any event. A seeded always-on "User memory" prompt teaches the save/skip discipline (durable facts yes; secrets and one-offs no). The pure block formatter (formatMemoriesBlock, budget + truncation) is unit-tested (tests/memory_test.ts). (Planned: a REST API + collection filing like todos/links; semantic recall instead of full-profile injection when a user accrues many memories.) - Links (shared bookmarks):
LinksPage(route/links, sidebar under Assets, any member) is a bookmarks area that plugs into the collections concept. Paste a URL and the metadata fills in automatically: the page inserts the row immediately (hostname as placeholder title), then thelink-metaedge function (verify_jwt: true, any member) fetches the page server-side (browser can't cross-origin) and the row is patched with the parsed<title>/OpenGraph title, meta/og description,og:imagepreview and favicon — parsing lives in_shared/linkmeta.ts(capped read of ~512KB, 10s timeout, never throws, falls back to the hostname) so thesave_linkbuiltin fills rows identically. Alinksrow (migration 0049) isurl+ fetchedtitle/description/image_url/favicon_url+ optionalnotes+screenshot_path— a captured screenshot stored in the privatelink-screenshotsbucket (migration 0052; member-readable, owner-folder writes, service role for pipelines). The card prefers a signed screenshot URL over og:image. The seededset_link_screenshotbuiltin attaches one from an image URL (download → bucket → path), so a browse-the-web capture, agent, orrun-toolcall can fill it; only the capture itself is external. Visibility mirrors todos/collections:private(owner + admins) orworkspace(every member can see and edit), enforced by RLS. Links file into collections viacollection_links(exact mirror ofcollection_todos, visibility-inherited RLS) — the card grid has the same select → sharedAddToCollectionBar(kindlink) + collection filter chips,CollectionsPageshows a Links card (create-by-pasting-a-URL included), andloadCollectionsContextinjects each collection's links as a compact title/url/description list block so chatting with a collection carries its links. Seededis_builtintoolssave_link/list_links/add_link_to_collection(in_shared/builtins.ts) let chat/scheduler/webhook agents capture links (metadata auto-fetched;link.createdactivity-logged). (Planned: screenshot capture via the browse-the-web tooling fillingscreenshot_path; a REST API + MCP tools like artifacts/to-dos.) - Files:
FilesPageuploads to the privatefilesstorage bucket under‹user-id›/…and creates 7-day signed URLs for sharing. Bulk sharing + public files (migration 0106): select any number of files and share them in one action from the selection toolbar — a permanent public link or a signed link scoped to 1 hour / 1 day / 1 week (the pure mode→expiry mapping lives insrc/lib/fileShare.ts, unit-tested). A results modal lists each file's link with per-file copy + "Copy all". "Public" publishes the file: because thefilesbucket is private (signed URLs expire, max 7 days), publishing COPIES the bytes into a separate publicpublic-filesbucket (public read, owner-folder writes — same model asartifact-images) under the mirrored‹owner›/‹uuid›/‹name›path and records it on the nullablefiles.public_pathcolumn; the resulting stable, non-expiring URL (publicFileUrl()insrc/lib/supabase.ts) is safe to bake into a public artifact / static HTML page whose images must load for anonymous visitors forever. A "Public" badge on a published file copies the URL (click) or unpublishes it (×, which drops the public copy — the private original is untouched); deleting a file removes both copies. Signed-link sharing still only flipsfiles.visibilitytounlisted; the private raw blob never leaves thefilesbucket unless explicitly published. AI-writable Files (CRUD): seededis_builtintoolscreate_file/list_files/get_file/delete_file/add_file_to_collection(shared executor in_shared/files.ts, run by all three agent loops viarunBuiltin; the MCP server exposes the same names) let the assistant + an external Codex persist and manage files — most importantly binary output it generates (e.g. a base64 PNG from an image model, which returns b64 only, no hosted URL).create_filetakescontent_base64orcontent_text(+filename, inferredmime_type,visibilityprivate|workspace,collection,tags,sourceprovenance jsonb), decodes server-side, enforces a 10 MB cap and a MIME allow-list (png/jpeg/webp/gif, pdf, text/plain, text/markdown, csv, json), uploads to the samefilesbucket at‹owner›/‹uuid›/‹name›, inserts afilesrow (so it shows in the Files UI, obeys the owner-only RLS, and PDFs auto-index via theenqueue_documenttrigger), and returns a 7-day signed URL + file id. Everything is re-scoped to the caller in code.files.tags/files.sourcecolumns added in migration 0055 (both nullable — the manual upload path is unaffected). The older MCP-onlyupload_filestill works;create_fileis the richer variant (text payloads, returns a URL, tags/collection/source). - Tables (Airtable-but-real-Postgres):
TablesPage(route/tables, sidebar, any member) lets a user create a data table — by hand or by describing it to AI — and edit rows in a spreadsheet-style grid. Each table is a real Postgres table (not EAV/JSONB) created in thepublicschema asut_‹uuid›, so it keeps full SQL power and PostgREST exposes it for ordinary CRUD. Browsers never run DDL: all structural changes go through security-definer RPCs in migration 0029 (create_user_table,add_user_column,drop_user_column,update_user_table,drop_user_table) that validate every identifier withformat(%I)and an allow-list of column types (text/longtext/number/integer/boolean/date/datetime/json), so there's no injection surface even though any signed-in member can create tables. A registry tablepublic.user_tablesholds each table's metadata (display name,physical_name,columnsspec, owner,visibility). Access lives in one place:user_tablesRLS makes a row visible to its owner, to everyone whenvisibility='workspace'(collaborative read+write, like a shared base), or to admins; each physicalut_*table's singlefor allpolicy just checks "is the matchinguser_tablesrow visible?", so the physical tables inherit the registry's access logic. The RPCs honor an explicit owner only for the service role (auth.uid() wins for authenticated callers, so owner can't be spoofed) andnotify pgrst, 'reload schema'after DDL so new tables/columns are queryable immediately. The assistant can use tables too: seededis_builtintoolslist_tables/query_table/add_table_row/update_table_row/create_table(in_shared/builtins.ts, so chat/agents/scheduler all get them;update_table_rowseeded in migration 0044) run with the service role and re-enforce the private/workspace rule in code — andupdate_table_rowrequires amatchfilter so it can't rewrite a whole table; MCP exposeslist_tables/create_table/add_table_row/update_table_rowfor an external Codex. Public write-forms (migration 0110): a table can accept ANONYMOUS submissions (a contact form / signup sheet baked into a shared artifact) WITHOUT opening theut_*tables toanon. Atable_formsrow is owner opt-in, per table:{table_id, owner_id, token, fields}wherefieldsis a column ALLOW-LIST ([{key, required}]). The browser never touches the anon key — the sandboxed artifact iframe (opaque origin, no session) POSTs{token, values}to the publicform-submitedge function (verify_jwt:false), which runs as the service role and enforces everything in code: resolve the opaque token → form (secret-URL capability, likewebhooks.token), re-verify the form's owner can still WRITE the table (no privilege escalation), drop everything not on the allow-list (insert-only), forceowner_idto the form's owner (no spoofing), validate required fields- coerce types, and cap submissions per rolling hour (abuse control via
activity_logform.submission). RLS ontable_formsis owner/admin-manage with awith checkthat also requires the owner can write the referenced table. Pure logic (allow-list intersection + type coercion in_shared/forms.ts; the paste-able HTML snippet builder insrc/lib/forms.ts) is unit-tested (tests/forms_test.ts,src/lib/forms.test.ts). Owners manage forms from the Tables UI ("Forms" button → pick columns/required, copy the HTML snippet + token). This is the write half only — public reads are a deliberate follow-up (a public read would expose everyone's rows, so it needs a curated surface, not this table). (Planned: public read via a curated/published view or aggregate; an assistantcreate_table_formbuiltin; auto-generate the form artifact; optional shared secret + CAPTCHA/honeypot; a fuller query surface — sorting, richer filters/joins — and column reordering/rename.)
- coerce types, and cap submissions per rolling hour (abuse control via
- PDF knowledge (RAG): uploading a PDF enqueues a
documentsrow (trigger onfiles). Apg_crontick calls theingestedge function, which extracts the text layer (unpdf), chunks it, embeds each chunk free with the in-edgegte-smallmodel, and storesdocument_chunksin pgvector. Documents are workspace-shared by default (documents.scope = 'workspace'): any member's chat can search their chunks. The owner can flip a document to'private'in Files ("Only me"). RLS enforces this — members readscope = 'workspace'rows, chunks are readable by the owner or when the parent doc is shared, and only the owner can change scope. The seeded built-insearch_documentstool runsmatch_document_chunks(cosine;scope = 'workspace' OR owner = caller, returns the document name for citations). Scope is separate fromfiles.visibility— the raw PDF blob stays owner-private; only the text chunks are shared. Text-layer PDFs only for now (scanned → Stage 2 vision). - Secrets vault (Governance):
VaultPage(route/vault, sidebar "Secrets" under Governance, admin-only) lets admins store named secrets (API keys, tokens, passwords) that the team — and the assistant — can use. Same Vault-backed pattern as the email key and MCP tokens: the value lives only in Supabase Vault (vault_secrets.secret_idpointer; migration 0037), never a table column, client payload, or log. Each secret isworkspace(shared with everyone) orprivate(owner + admins), mirroring collections / user_tables. Writes go through admin-gated security-definer RPCs (set_vault_secretupsert with write-only value — empty keeps the current one;delete_vault_secret); the decrypted value is read only by the service role viaread_vault_secret(name, user_id), which re-enforces the share rule in code (workspace → anyone; private → owner). Two seededis_builtintools in_shared/builtins.tsexpose it to all three agent loops:list_secrets(names + descriptions, never values, for discovery) andget_secret(one value by name).get_secretreturns a raw credential into the conversation, so it's exfiltration-capable likesend_email— it's an ordinarytoolsrow (admin activation, agenttool_idsscoping, and thewebhooks.allow_toolsgate all apply) and logs every read asactivity_logtypesecret.read. - Activity:
ActivityPageis a live feed ofactivity_log. Rows are written by DB triggers (webhook_events,artifacts,files) and by the chat function (tool calls). RLS: you see your own rows, admins see all. Realtime-subscribed. - Settings & feature flags (migration 0065): Settings is a sidebar SECTION
(like Assets/Automation), one route/page per area under
/settings/*(src/pages/settings/): Profile + Connect Codex (all users), Models, Timezone, Email, Slack, External MCP, Invite people, and Feature flags (admin). The sidebar is now driven by ONE config,src/lib/nav.ts— Layout renders it, the ⌘K palette (GlobalSearch) jumps to its pages, and the Feature flags page lists its flaggable items, so the three never drift. Feature flags are feature HIDING, not permissions: afeature_flagsrow{key, enabled:false}(admin-writable, everyone-readable, realtime) removes a sidebar area (and its palette entry) workspace-wide — because the company doesn't use it or you're trying something out. RLS still protects the data; hiding a link doesn't gate the route. Absence of a row = enabled (fresh workspaces show everything). Core areas (Home, Chat, and every Settings item) arealwaysOnso a flag can never hide the page that manages the flags. Layout subscribes tofeature_flagsso toggles apply live. The pure filtering logic (isFeatureEnabled/visibleGroups/flaggableGroups) lives innav.tsand is unit-tested (src/lib/nav.test.ts). - Workspace timezone (migration 0092): the IANA clock the agentic automations treat
as "local" so unattended runs stop assuming UTC. A single
workspace_settingsrow ({key:'timezone', value}, a tiny admin-write/member-read/realtime KV mirroringfeature_flags) is edited in Settings → Timezone (TimezoneSettings, route/settings/timezone, admin-only): it shows the saved zone, the admin's browser zone (one-click "Use this"), a full IANA picker (Intl.supportedValuesOf) with a live current-time + UTC-offset preview. Absence of a row / invalid value ='UTC'. The server helpersupabase/functions/_shared/timezone.ts(resolveWorkspaceTimezone(db)reads the row; purecurrentTimeSection(tz, now)/formatInZone/isValidTimezoneare unit-tested intests/timezone_test.ts) is called by all six agent loops — chat, scheduler, event-dispatch, webhook, slack-events, loop — which prependcurrentTimeSectionto their system prompt, so every automation is told the real local date/time (before this, NONE injected "now" into the model). The scheduler uses a schedule's owntimezonewhen set and falls back to the workspace zone; new schedules in the agent editor default their timezone to the workspace zone (existing schedules keep theirs). Browser-side pure helpers live insrc/lib/timezone.ts(detectBrowserTimezone/allTimezones/formatCurrentTime/utcOffsetLabel, unit-testedsrc/lib/timezone.test.ts). (Planned: per-user display timezone; a genericworkspace_settingssurface for other workspace-wide config.) - Events & listeners (automation substrate, migration 0063): a workspace
pub/sub layer, deliberately SEPARATE from
activity_log(which stays the human feed). Every meaningful record change emits aneventsrow via SECURITY DEFINER DB triggers through theemit_event(...)helper —artifact.created/updated,file.created/deleted,todo.created/completed,link.created,chat.created,message.received, and the headlinecollection.item_added(one generic trigger over everycollection_*join table, carrying{collection_id, item_type, item_id}). Each event carries a stableentity_type/entity_id, workspacevisibility(so listeners react safely to shared activity), and aprocessed_atdispatch cursor.EventsPage(route/events, Automation) is the live feed. Anevent_listenersrow is a rule —event_type(exact,file.*prefix wildcard, or*) + amatchjsonb (entity_type/collection_id/sourcefilters) + anaction_type(run_agent|run_tool|add_to_collection|log) +action_config.ListenersPage(route/listeners, Automation) is the "when this / do this" CRUD, with recentevent_listener_runs. Theevent-dispatchedge function (verify_jwt: false, cron-secret gated like the scheduler; ticked by pg_cron via pg_net — thecron.scheduleis applied out-of-band after deploy, same convention as 0010) claims unprocessed events (one-timeprocessed_atclaim = idempotent), matches them with the pure_shared/events.tsmatchListener(unit-tested), and runs each action AS the listener's owner (agent loop mirrors the scheduler;run_toolmirrors run-tool's dispatch;{{event}}in a tool input is replaced with the event JSON). Per-tick action cap + the one-time claim bound runaway chains. RLS mirrors links (own/workspace/admin). Each dispatch logsactivity_loglistener.run/listener.error. - Unified inbox (messages from any source, migration 0064): one place to push every
message — email, Slack, WhatsApp, SMS, generic webhook/manual pushes — so a person (and
the team, via collections) can read them, chat over them, and automate on them.
Generalizes the old email-only
inbox_messages(0016) in place rather than adding a collidingmessagestable (that name is chat's): addsowner_id/visibility(the private-or-workspace model of links/todos, replacing admin-only RLS),source,from_name/url/external_id, realtime, and acollection_inbox_messagesjoin (mirror ofcollection_links). Each incoming row emitsmessage.received(0063), so listeners can react ("when a Slack message arrives, run this agent").InboxPage(route/inbox, top nav) lists/filters by source, marks read, files into collections, and composes manual messages. Ingestion:email-inboundnow writes email rows here (source='email', workspace-shared); the token-gated publicmessage-inboundedge function (verify_jwt: false,mcp_tokensbearer like run-tool) accepts arbitrary pushes (Slack/WhatsApp/Zapier/scripts); seededis_builtintoolssave_message/list_messages/add_message_to_collectionlet the assistant + agents capture messages.check_emailstill works, now scoped tosource='email'rows. (Planned: IMAP/Google/Microsoft account connectors that funnel into this table; injecting a collection's messages intoloadCollectionsContext; a REST API + MCP tools.) - Webhooks:
WebhooksPage(master–detail) createswebhooksrows, each with an opaquetokenand an attachedprompt. External systems POST to the publicwebhookedge function at/functions/v1/webhook/‹token›(verify_jwt: false); it resolves the webhook by token (service role), runs the prompt against the payload via Codex, and logs awebhook_eventsrow (received→ok/error/blocked) with the result. The page subscribes towebhook_eventsover Realtime for a live log. Routing the result somewhere (artifact/chat/outbound) is a later step. A webhook-targeted agent runs read-only by default — its tools are loaded only whenwebhooks.allow_tools = true(a deterministic rule, not a model decision), so an untrusted source can't make the agent act. Deterministic mode (n8n "function node"): a webhook can instead setwebhooks.tool_id(migration 0021) to call anhttptool directly — no model. The webhook function validates the inbound payload against the tool'sinput_schema(required fields + top-level types viavalidatePayload), then POSTs it straight to the tool'sconfig.urland logs the result; a bad payload returns a 400 listing the offending fields (webhook_eventsstatuserror). No guardrail runs on this path because nothing reaches an LLM — schema validation is the gate. Mode precedence in the webhook function:tool_id(direct) →agent_id(agent loop) →prompt. This pairs with Forge: forged functions arehttptools, so the editor's "Call a function directly" picker lists them, shows their fields/types, and renders a sample payload. Outcome logs asactivity_logtypewebhook.function. Optional shared secret (webhooks.secret, migration 0022): the URL token is "secret-URL" security; setting a secret adds real auth — callers must present it asAuthorization: Bearer <secret>orX-Webhook-Secret: <secret>, else the function returns 401 before logging an event (so a wrong/missing secret can't spam the event log). Null = no secret (unchanged). Set it per-webhook in the editor ("Require a secret"); it's a plaintext shared secret on the row, same trust model astoken. - Slack bot (rooms bound to collections): the workspace bot joins Slack channels and
answers
@mentionswith that room's context — Codex-Tag style. An admin connects the Slack app once in Settings → Slack (bot token + signing secret, Vault-backed like email/MCP:set_slack_integration/ service-role-onlyread_slack_secrets, migration 0057), then binds channels: aslack_channel_bindingsrow maps a Slackchannel_idtocollection_ids+ an optionalagent_id; the binding's creator is the identity the bot runs as (likewebhooks.owner_id), so bind workspace-visibility collections for team rooms. The publicslack-eventsedge function (verify_jwt: false; gated by the HMACX-Slack-Signatureon every request, not a URL token) handles the Events API:url_verificationchallenge, signature + replay-window check, dedupe onevent_id(unique insert into the admin-readableslack_eventsaudit log), then acks within Slack's 3-second window and does the model work in the background (EdgeRuntime.waitUntil) — the reply goes back viachat.postMessageinto the thread. The background run is the same loop as webhooks: agent instructions (or a default Slack-flavored prompt) +loadCollectionsContext(binding ∪ agent collections) +runGuardrailsin thewebhookcontext (fail closed) + tools only when the binding setsallow_tools(deterministic gate, mirroringwebhooks.allow_tools). Thread/channel transcript (conversations.replies/.history) and display names are fetched for conversational context; bot/self messages and edit subtypes are skipped so it can't loop. Pure logic (signature verify, mention stripping, Slack-text decode, markdown→mrkdwn, skip rules, transcript formatting) lives in_shared/slack.tsand is unit-tested (tests/slack_test.ts). Usage logs withcontext='slack'; replies logslack.replyto the activity feed. Setup guide + app manifest:docs/slack.md. Ambient participation (Codex-Tag style, migration 0066): a binding can setmode='ambient'to have the bot listen to every message (not just @mentions) and a cheap model decide whether to chime in. The manifest then subscribes tomessage.channels/message.groups; the function handlesmessageevents for ambient channels only (plain channels stay mention-only — zero regression). Flow per message: capture it into the unified inbox (inbox_messages,source='slack', whencapture_messages) → skip if it @mentions the bot (theapp_mentionevent handles those, no double reply) → a purepassesAmbientPrefilterheuristic drops trivial/reaction messages before any model call →decideParticipationruns ONEorCompleteon the binding'sgate_model(any OpenRouter slug, else theutilityprofile) withbuildParticipationSystem(participation_prompt)and returns a strict-JSON verdict viaparseParticipationVerdict(fails silent — a false positive/spam is worse than a miss). Onrespond:trueit runs the same reply path as an @mention. The new pure helpers live in_shared/slack.tsand are unit-tested. (Planned: DMs, asend_slack_messagebuiltin for scheduled/ambient posts, in-channel binding commands, a per-thread cooldown, name-resolving captured messages.) - Prompts & skills: one
skillstable, two modes.auto_apply = true→ always-on prompts (admin-managed, workspace-wide). The seededis_builtin"How this workspace works" prompt teaches the assistant the system + the artifact protocol. The chat edge function loads allauto_applyrows (via service role) and concatenates them into the system prompt on every call.auto_apply = false→ on-demand skills (personal). InChatPage, typing/(or the ⚡ button) lists them. Picking one arms it rather than firing a model call on the click:chooseSkill()prefills the composer withuse the skill "…"(skillInvocationSentence()insrc/lib/util.ts) and shows a cancelable chip, so the user can add context and decide when to run. Sending then routes throughhandleSend→runSkill(), which sends the skill as thesystem(artifact mode usesreplaceSystem: truefor clean output; reply mode appends to context).
- Tools (tools-as-data): the
toolstable defines capabilities the chat loop exposes to the model.kind = 'http'→ a custom tool; the model calls it and the chat function POSTs the inputs toconfig.urland feeds the response back.kind = 'web'→ switches on OpenRouter's web search server tool ({type:'openrouter:web_search'}in thetoolsarray — NOT the deprecatedplugins:[{id:'web'}], which injected results as a mid-conversation system message that Anthropic 400s on multi-turn threads), the portable replacement for Anthropic's server-side web tools (works with any OpenRouter model). Admin-managed (ToolsPage); a seededis_builtin"web_browsing" row is on by default. The chat function runs an agentic loop (model → tool_calls → execute → tool result messages → … → stop), pushing the assistant turn (content +tool_calls) back before each batch of{role:'tool'}results (OpenAI/OpenRouter shape). - Direct tool runs (
run-tool): the universal tool runner closes the "tools only run inside the agent loops" gap.POST /functions/v1/run-toolwith{tool, input}invokes any active tool with no model in the loop — same dispatch as the loops (builtin→runBuiltin,http→runHttpToolwith vault refs, MCP remote tools by namespaced name viarunMcpTool;weberrors, it's a model plugin).{steps:[{tool,input},…]}(max 10) chains tools deterministically —{{prev}}inside any string input is replaced with the previous step's result text. Auth (verify_jwt:false, checked in code): a personalmcp_tokensbearer or a Supabase session JWT (verified viaauth.getUser, never trusted from its payload) — so the UI, scripts, cron, and Zaps can all call it; every call runs AS the resolved user (builtins re-enforce private/workspace access) and logsactivity_logtypetool.run.GET /run-tool/listreturns runnable tools + schemas;GET /run-tool/docsis plain-text docs; the "Run tools" tab inApiPagerenders copy-ready curl. No guardrail runs (nothing reaches an LLM — same reasoning as the webhook direct mode); activation + per-user access rules are the gate. Note thehttp_requestbuiltin returns HTML responses converted to markdown by default (_shared/html_markdown.ts;format:"raw"opts out — migration 0051 documents it in the tool schema), so fetch→ingest chains stay inside the 50k-char result budget. - Guardrails: the
guardrailstable holds admin-managed pre-flight checks evaluated by the cheaputilitymodel profile before the orchestrator runs.GuardrailsPage(admin-only) manages them; each hasinstructions(what to check for),applies_to_webhooks/applies_to_chat, andaction(block|flag).runGuardrails()insupabase/functions/_shared/guardrails.tsloads the active checks for the context, makes oneutility-model call (plainmessages.create, no thinking; content passed as untrusted data inside delimiters), and parses a strict-JSON verdict. Enforcement is in code acting on the parsed JSON — the verdict is never inserted into the orchestrator's prompt. Webhooks fail closed (an evaluator error blocks the run →webhook_eventsstatusblocked, 403); chat fails open (errors let the message through). Ablockverdict stops the run;flagonly logs. Outcomes are written toactivity_log(guardrail.blocked/.flagged/.error). A seededis_builtin"Prompt injection screen" applies to webhooks and blocks. RLS mirrorstools(authenticated SELECT, admin write, builtin not deletable). - Security dashboard (Governance):
SecurityPage(route/security, sidebar "Security" under Governance, admin-only) is the workspace posture scan as a repeatable feature. The seededrun_security_scanbuiltin (handler_shared/security.ts, migration 0058 — first shipped as a second0056_*.sql, which collided with0056_conversation_pinned.sqland silently never deployed; renumbered + made idempotent) runs deterministic checks over configuration — active webhooks without shared secrets, tool-enabled (allow_tools) webhooks, missing blocking webhook guardrails,send_emailwithout a recipient allowlist,get_secret+ workspace-scoped vault secrets, active external MCP handles, MCP/API tokens unused ≥90 days, public-artifact inventory, single-admin bus factor — and writessecurity_scans/security_findingsrows (admin-only RLS; inserts are service-role). Findings are config facts, not model opinions: the only model call is one best-effortutility-profile summary (fails open to a computed one; usage contextsecurity). The pure evaluator (evaluatePosture(snapshot)) is deliberately import-side-effect-free (lazy model imports) and unit-tested insupabase/functions/tests/security_test.ts. Findings carry a stablekey, so a dismissed (accepted-risk) or promoted status carries over across re-runs instead of re-nagging. Promote to feature files the finding onto the Features board in theidealane — the existing human-approval drags spend the AI effort and ship the fix; the button itself never touches GitHub. Trigger surfaces (zero new plumbing, it's an ordinary builtin): the dashboard's "Run scan" button calls it viarun-tool; a daily run is an agent scoped to this tool on aschedulesrow; chat works too. The builtin re-checksprofiles.is_adminin code, since the loops run builtins with the service role. Live progress: the scan row carries aprogressjsonb checklist (SCAN_STEPS/scanProgressin_shared/security.ts, written step-by-step byrunSecurityScan), andsecurity_scansis in thesupabase_realtimepublication — the dashboard subscribes and renders the running scan's steps ticking live, then refetches findings when the row flips took/error. - AI-created artifacts: the assistant emits a
:::artifact {"title","type"}\n…\n:::block;materializeArtifacts()inChatPageparses it after streaming, inserts anartifactsrow, and replaces the block with an/artifacts/:idshare link.SkillsPagemanages all of the above (always-on editing is admin-gated). - Agents: an
agentsrow is a deployable unit — a name + system prompt (instructions) +tool_idsit may use +collection_idsit can use (migration 0043).AgentsPageis the dashboard (CRUD, workspace-visible). "Chat" opens/chat?agent=:id, whereChatPagelayers the agent's prompt on the conversation and scopes the toolset to the agent's tools (streamChat({ system, toolIds })→ chat function'sloadTools(restrictIds)). Collections an agent uses: the agent's boundcollection_idsare injected as primary context (artifacts/files/to-dos) whenever it runs — chat (merged with the user's picked collections), webhook, and scheduler all call the shared_shared/collections.tsloadCollectionsContext(moved out of the chat function so every loop shares it). Agents add into collections via the existingis_builtinauthoring tools (create_collection/add_to_collection/create_artifact/add_todo_to_collection/add_table_to_collection), scoped bytool_idslike any tool. A webhook can also target an agent (webhooks.agent_id): the webhook function then runs the agent (its prompt + tools) over the payload via its own tool loop instead of the bare prompt. Scheduled agents: aschedulesrow (agent + input + cadence) is run by thescheduleredge function, which apg_cronjob ticks every minute (viapg_net, authed by acron_configsecret). Cadence is EITHER a fixedinterval_minutesOR — for exact times the interval can't express (the 15th of the month, the last day of the month, weekdays at 9am) — a standard 5-field cron expression (schedules.cron_expr+schedules.timezone, migration 0091). Whencron_expris set it owns the cadence: the scheduler computesnext_run_atfrom it in the schedule's timezone instead ofnow + interval_minutes(an invalid expression pauses the row + logsschedule.error, so it can't spin). The next-run math is the pure, unit-testedsupabase/functions/_shared/cron.ts(nextCronRun/isValidCronvia croner, which supportsL= last day of month); the browser mirrorsrc/lib/cron.ts(croner + cronstrue) drives the agent editor's cron field — a live plain-English description, a next-3-runs preview, and a* * * * *syntax helper — beside the classic interval presets. Manage schedules inside the agent editor. (Both cron precision and the old interval are floored to the 1-minute pg_cron tick.) - Capability workers (agent jobs, migration 0080): turn heavy open-source
libraries into specialized Docker workers that do focused jobs for the main
AI, so the app never shells out to big binaries in-process. First two: office
(Word/Excel/PowerPoint via LibreOffice) and media (audio/video via ffmpeg).
Deliberately no
agentstable — the capability contract lives in skill files (skills/capability-workers.md+officecli.md/ffmpeg.md, also seeded into theskillstable), coordination is theagent_jobsqueue, and execution is a container. Flow: the AI'screate_agent_jobbuiltin inserts aqueuedrow → a worker atomically claims it (claim_agent_jobRPC,FOR UPDATE SKIP LOCKED) → downloads inputs from Storage (ownership re-verified per file) → runs the allow-listed operation in a temp dir → uploads outputs + writesresult_manifest→ marks itcompleted; the AI pollsget_agent_job. Statuses queued/claimed/ running/completed/failed/retrying/cancelled/dead_letter; lease + heartbeat (lease_expires_at,recover_stale_agent_jobs) recover a crashed worker's job; transient failures retry with 30s→2m→10m backoff; anidempotency_keyprevents duplicate outputs (one open job per owner+key). Pure logic (validation, manifest, idempotency, backoff, failure policy) is insupabase/functions/_shared/agent_jobs.ts(unit-tested); builtinscreate_agent_job/get_agent_job/list_agent_jobs/cancel_agent_jobin_shared/builtins.ts; anagent_job_eventstable logs each job's timeline (realtime). The workers are a separate npm workspace (workers/, NOT in the main app build/CI) sharing@supanet/worker-shared(claim loop, storage I/O, lease/health/events). Railway is the first deploy target, not the architecture: the worker runtime touches only Postgres + Storage + env vars (no Railway SDK/hostnames insrc/), so the same image runs on Docker Compose/ Railway/Fly/Render/K8s — local dev isinfra/docker-compose.yml, Railway config is isolated underinfra/railway/, and.github/workflows/deploy-workers.ymlbuilds both images on change (deploys whenRAILWAY_TOKENis set). Full reference:docs/capability-workers.md. (Planned: PDF/OCR/browser/image workers on the same protocol; a jobs UI; pg_cron lease-recovery tick.) - MCP server:
supabase/functions/mcp/index.tsis a JSON-RPC-over-HTTP MCP server (verify_jwt: false) an external Codex connects to. Auth is a per-usermcp_tokenstoken (Settings → Connect Codex); every action runs as the token's owner. Codex connects directly (Codex mcp add --transport http …); Codex Desktop launches MCP servers as local processes, so it connects through themcp-remotebridge inclaude_desktop_config.json(Settings → Connect Codex emits both snippets; theAuthorization:${AUTH_HEADER}env split avoids mcp-remote's space-in-header bug). It exposes build tools (create_agent,create_http_tool,create_skill,create_webhook,create_artifact,list_*) so an outside Codex can push agents/tools into the workspace, where they appear in the dashboard. Skills and always-on prompts (the sameskillstable) get full CRUD —create_skill/list_skills/get_skill/update_skill/delete_skill— with the DB's access rule re-enforced in code (personal on-demand skills owner-managed; always-on prompts admin-only; built-in prompts editable but not deletable). These build tools are alsois_builtintools (migration 0084), so the INTERNAL assistant + the scheduler/webhook/Slack agent loops can build agents/tools/webhooks/skills too — not just an external Codex. The handlers moved into_shared/builtins.ts(runBuiltin) and the MCP server now delegates to them (like the files/memory/whiteboard tools), so both paths share one implementation and never drift; the same admin gates re-check in code (create_http_tool+ always-on prompts are admin-only), since the loops run as the service role. It also drives the looping system (create_loop/run_loop/get_loop_run/list_loops): an external Codex hands a loop a goal (prompt) + rubric + budget + iteration cap, kicks off a run, then pollsget_loop_runto "check in" until it's done — same machinery as the Loops dashboard. The shared_shared/loops.tshelpers (create the loop, trigger theloopedge function, format a run) back both this and the in-app builtins so they never drift; an internal trigger passestriggered_by(honored only for the service-role caller, which has no JWTsub) so the run is attributed to the right user. It also drives the evals system (list_evals/create_eval_suite/add_eval_cases/run_eval/get_eval_run, all admin-gated in code): an external Codex builds a Promptfoo-style suite, bulk-inserts cases it generated (add_eval_casestakes an array — the answer to "making cases is a lot of work"), then runs it and polls — the same trigger→poll shape as loops.run_evaltakes amodelslist to compare several models on the same suite in one call (one backgroundeval_runper model — themodelcolumn already existed — andget_eval_runrenders a best-first side-by-side scorecard viaformatMatrix). The shared_shared/evals.ts(triggerEvalRunposts to theevalsfunction with the service-role key +triggered_by;executeSuiteRundoes the per-case work) backs both this and the in-app UI so they never drift; the pure helpers (evalAssertion/parseModels/formatMatrix) live in the import-side-effect-free_shared/evals_pure.tsand are unit-tested (tests/evals_test.ts). Theevalsfunction now runs in the background (EdgeRuntime.waitUntil) when called withbackground:true, returning run ids immediately; the admin UI's single synchronous run is unchanged. Collection as a grounding set (migration 0072): aneval_suites.collection_idsbinds a chat/agent suite to one or more collections — every case is answered with ONLY that collection's content injected as primary context (runOrchestratornow takescollectionIds→loadCollectionsContext, layered after the system prompt, not replacing it) and the judge grades against the case's reference, so you can test "does the assistant answer correctly out of THIS knowledge set". Cases stay hand-authored; the collection is the knowledge, not the cases. Tool-usage evals (migration 0083): a chat/agent suite can also assert what the assistant does, not just what it says —runOrchestratorreturns the full tool-call trace (name + args + result), and new assertion types grade it:calls_tool/not_calls_tool(the safety/prompt-injection guard — "an untrusted input must NOT call send_email") /calls_tool_with(arg match viaarg_contains/arg_regex) /tool_call_count(min/max/equals). Runs are sandboxed by default (eval_suites.sandbox_tools): only read-only builtins execute (isReadOnlyBuiltin—list_/get_/query_/search_+ check_email, minus get_secret); every side-effecting/external tool (create_*, send_email, http, MCP) is captured but NOT run, so a tool-usage suite is safe to re-run across a model matrix on a schedule without mutating the workspace — setsandbox_tools:falsefor an opt-in real-side-effects integration test (where the Activity/Events feeds become the verification layer). A case with only tool assertions and no reference/rubric passes on its assertions alone (judge skipped). A fourthtooltarget runs a tool directly (no model, likerag) — case input is JSON{"tool","input"}— to test a tool in isolation.get_eval_runby run_id now returns a per-case breakdown (tools called + failed assertions + judge reason) so tool usage is fully measurable over MCP, not just a headline score. All the pure logic (the tool assertions,isReadOnlyBuiltin,summarizeTrace,formatRunDetail) is in_shared/evals_pure.tsand unit-tested. Collection as a two-way hub:get_collectionpulls a whole named collection in ONE call — meta + artifacts (full content), files (signed urls), links, to-dos (full notes), tables (schema + row count), and inbox messages — with an optionalsince(ISO 8601) for a cheap daily diff and anincludesubset; it assembles the bundle inline (mirroring_shared/collections.ts's owner/visibility rules, since this server runs as the service role), whilesince/includeparsing is the pure, unit-tested_shared/collection_hub.ts. The push side has parity:create_artifact/update_artifact(edit a running-memory doc in place) /add_note(also files a text artifact into the collection so it shows up inget_collection) /save_link/save_message/create_todo/upload_fileall take acollection, and the read gaps are closed too (get_artifact,list_links,list_messages,search_documents,get_activity, plus asincefilter onlist_artifacts/query_table). The write/read helpers that already exist asis_builtintools are delegated torunBuiltin(like the files/memory tools) rather than reimplemented, so they never drift. (Planned — seedocs/tasks/: a tabbed Code/Desktop connect UI.) - External MCP client (outbound): the inverse of the MCP server above — the
workspace connects out to any number of external MCP endpoints (e.g. Zapier MCP in
front of Gmail/Calendar, plus others) so agents can call their tools. An admin adds servers
in Settings → External MCP servers; each is a row in
public.mcp_servers(label, url,secret_id→Vault,scope,tool_id,cached_tools; migration 0036, which replaced 0032's single-rowintegrationsmodel and migrated the existing row in place). Each token lives ONLY in Supabase Vault — the admin RPCset_mcp_server(id,label,url,token)writes it (token write-only),delete_mcp_server(id)removes a server, and the service-role-onlyread_mcp_secret(server_id)reads it. Every server gets onetoolsrow ofkind='mcp'as its in-app handle (config.server_id→mcp_servers.id): activating it (ToolsPage, "MCP" badge) or scoping it to an agent viatool_idsturns that server's whole remote toolset on/off like any other tool.supabase/functions/_shared/mcp.tsis the MCP client (JSON-RPC over Streamable HTTP —initializehandshake → optionalMcp-Session-Id→tools/list/tools/call, parsing JSON or SSE replies).expandMcpTools()resolves each handle to its server, discovers the remote tools, expands each into a first-class namespaced function (‹label›__‹remote›, e.g.zapier__gmail_find_email) and caches the list onmcp_servers.cached_tools(10-min TTL) so the loops don't re-handshake every message;runMcpTool()executes a call (per-server token);refreshServer()re-discovers one server. Wired into the three agent loops (chat, scheduler, webhook) right besidehttp/builtindispatch, so it composes with every existing gate — admin activation, agenttool_idsscoping, thewebhooks.allow_toolslock, and therunGuardrailspre-flight. The admin-onlymcp-adminedge function (verify_jwt: true) powers Settings' "Connect & list tools" — it validates one server (server_id) server-side and refreshes its cache. The remote tools are exfiltration-capable (they can send mail), so they carry the same workspace-wide trust assend_email. Themcp_servers.owner_id/scopecolumns exist for per-user servers (Phase 2, not built) — every row is workspace-wide for now. Re-exposed through the MCP server (Playwright & other browser tools): the same connected external MCP servers are also surfaced back OUT through our ownmcpserver — themcpfunction'stools/listappends each activekind='mcp'handle's discovered toolset (via the sharedexpandMcpTools, so the tool names match exactly) andtools/callroutes any namespaced‹label›__‹remote›name it doesn't implement itself torunMcpTool. So an admin connects a Playwright MCP endpoint once in Settings → External MCP and its browser-automation tools appear to Codex Desktop / Codex / any external AI that connects to the workspace MCP endpoint, alongside the build/authoring tools — a single unified endpoint. The namespacing (mcpPrefix/namespacedToolNamein_shared/mcp.ts) is pure + unit-tested (tests/mcp_test.ts); setup guide:docs/playwright-mcp.md. (Planned: per-user tokens + a member-facing UI, auto-refresh, and wiring the evalorchestrator/looploops.) - Email: two seeded
is_builtintools —send_emailandcheck_email— let any user or agent use email once an admin configures a provider in Settings → Email. Sending goes through an HTTP provider (Postmark / Resend, not raw SMTP); receiving is inbound-parse, not IMAP — the provider POSTs each incoming mail to the publicemail-inboundedge function (verify_jwt: false, token-gated likewebhook), which normalizes it intoinbox_messages;check_emailreads that table (push, not polling). Credentials live only in Supabase Vault: the non-secret config sits inpublic.integrationswith asecret_idpointer; the client writes the key solely through the admin-gated, security-definer RPCset_email_integration(admin-checked in the body), and edge functions read the decrypted key through the service-role-onlyread_email_secret. The key is never a table column, never in a client payload, never logged.send_emailis exfiltration-capable, so it stays an ordinary tool row (admin activation, agenttool_idsscoping, thewebhooks.allow_toolsgate all apply) and adds an optional recipient allowlist (exact address or@domainsuffix), a 20-per-hour rate limit, and anemail.sentactivity-log entry per send. All three agent loops execute it via the sharedsupabase/functions/_shared/builtins.ts(runBuiltin), which also holdssearch_documents— so the "morning agent emails me" flow works through the scheduler, not just chat. Planned follow-up (not built): email-triggered agents — run an agent perinbox_messagesrow. - Usage tracking: every OpenRouter call returns a
usageobject (tokens +cost); the shared client surfaces it onORResult.usageandrecordUsage()(supabase/functions/_shared/usage.ts) writes oneusage_eventsrow per call from all four loops (chat/webhook/scheduler/guardrail).UsagePage(route/usage, admin-only) shows spend (totals, daily chart, by model/context/user via theusage_summary(p_days)security-definer RPC) and the OpenRouter account balance (the admin-onlyopenrouter-balanceedge function proxiesGET /api/v1/key, since the key is server-side). RLS onusage_eventsmirrorsactivity_log(own-or-admin SELECT, service-role writes only). Visibility only — budgets/enforcement are a follow-up (ROADMAP items 2–3). - Forge (vibe-coded functions):
ForgePage(route/forge, admin-only) closes the "configuration-as-data can't deploy new code" gap. An admin describes a capability in natural language; the admin-onlyforgeedge function (verify_jwt: true) generates a Deno function (orchestrator model), then deploys it to the live project via the Supabase Management API and registers it as akind='http'tool — so chat/agents/webhooks/scheduler can call it like any custom tool. Use it for deterministic work the LLM can't do reliably (a calculator, a unit converter, a precise transform, a validate-then-call API). The generated code is justasync function handler(input); a fixed harness (forge/template.ts,buildModule) owns request handling, CORS, and a per-functionx-forge-tokencheck (baked into the deployed source, sent by the tool'sconfig.headers), so the security-critical plumbing is never the model's to write. Security: admin-only at every entry; the Management API PAT lives ONLY insupabase/functions/_shared/management.ts(readsFORGE_PAT; the project ref auto-derives fromSUPABASE_URL, override withFORGE_PROJECT_REF— note secrets cannot use the reservedSUPABASE_prefix); a static deny-list lint (lintSource) rejects generated code that touchesDeno.env, the service-role key, the PAT,mcp_tokens, subprocesses, the filesystem, oreval; arunGuardrailspre-flight screens the code; forge fails closed (any lint hit, guardrail block/error, orbundleOnly=1dry-run failure aborts before the real deploy). Generated functions get no DB/secret access — pure compute +fetch()only. Theforged_functionstable (migration 0021) is the audit/redeploy system-of-record (spec, generatedsource, slug, model, status, invoke token, linkedtool_id); RLS mirrorstools(migration 0020). Note: API-deployed functions don't live in the repo, soforged_functions.sourceis the redeploy source of truth and ToolsPage shows a "Forged" badge on the linked tool. Outcomes log toactivity_log(forge.deployed/.failed/.deleted). Planned follow-up (not built): DB-writing forged functions via admin-authoredsecurity definerRPCs; anapplies_to_forgeguardrail context; aforge_toolMCP action so Codex can forge tools too. - Multi-tenant release fan-out (
release-tenants.yml): the origin deploy workflows only touch ONE project onmain, but this template runs as many tenant apps (each its own Supabase project + Railway frontend, all under the maintainer's own Supabase org). The frontend already fans out (each tenant rebuilds from thereleasebranch), but migrations + edge functions did not — so a tenant got new frontend code calling a table/function its DB never received (Could not find … in the schema cache).release-tenants.ymlcloses that: on push torelease(the same merge that ships the frontend), it fans out to every live tenant — applying pending migrations + deploying functions — using the SINGLE org-owner PAT (SUPABASE_ACCESS_TOKEN, already used for the origin), so zero per-tenant secret and nothing for a tenant to do. The tenant list comes straight from the control plane'stenantstable (the system of record —control-plane/):scripts/list-tenants.tsqueries it (via the Management API SQL endpoint,CONTROL_PLANE_REFrepo variable) forstatus in ('active','past_due')refs, so the registry stays in sync automatically — no hand-kept list.infra/tenants.jsonis only an optional canary override (pin to one ref to roll out to a single tenant first; empty = all live tenants). Migrations go through the Management API SQL endpoint (scripts/apply-migrations.ts→/database/query), because a PAT can't fetch a project's DB password (sodb push --db-urlisn't an option); it readsschema_migrations, applies each pending file (version =NNNNprefix, matchingdb push) in a transaction, records it, then callssetup_automation_cron(0094). The pure logic (pendingMigrations,parseTenantRefs) issrc/lib/rollout.ts(unit-tested). Functions deploy viasupabase functions deploy --project-ref(PAT-only).fail-fast:falseso one bad tenant never halts the fleet;workflow_dispatchoffers a dry-run. - DB migrations on
main: a GitHub Action (.github/workflows/deploy-migrations.yml) runssupabase db pushwhenever a file undersupabase/migrations/**changes onmain, so new migrations go live automatically (the CLI's migration history makes re-runs apply only what's pending). It needsSUPABASE_ACCESS_TOKEN(the same PAT as the functions workflow) plusSUPABASE_DB_PASSWORD—db pushconnects straight to Postgres, so the access token alone can't apply migrations. Project ref defaults in the workflow, overridable via theSUPABASE_PROJECT_REFrepo variable. Each migration filename must have a unique, contiguous numeric prefix (0040_…after0039_…):db pushderives the version from the prefix, so two files sharing a number (e.g. two0032_*.sql) collide and the push is rejected. Always use the next free number.src/lib/migrations.test.tsguards this (unique prefixes) — but it only catches collisions once BOTH files are onmain, so a duplicate can slip in via a rebase (your0086+ a0086that landed while you were working); re-check the next free number right before pushing, not just when you create the file. Changing a function's return type needs a DROP first: Postgres rejectscreate or replace functionwhen thereturns/OUT signature changes (SQLSTATE 42P13 "cannot change return type of existing function"), which aborts the wholedb push. When a migration widens an existing function (e.g. adding a column to areturns table (...)), prependdrop function if exists public.fn(argtypes);. A migration that fails this way never records as applied, so its objects are missing in prod (e.g. a laterpromote_to_adminin the same file is never created) until fixed — and because it never applied, editing the file in place to add the DROP is the correct, safe fix. - In-app function deploys (edge functions don't ride
mainby default): pushing tomainredeploys the frontend (Railway), but the Supabase edge functions otherwise only update via afunctions deploy. Two things close this gap: (1) a GitHub Action (.github/workflows/deploy-functions.yml) runssupabase functions deployon pushes that touchsupabase/functions/**orconfig.toml(needs repo secretsSUPABASE_ACCESS_TOKEN— a Supabase PAT, same kind asFORGE_PAT— andSUPABASE_PROJECT_REF;verify_jwtcomes fromconfig.toml); it deploys only the core/repo functions (forged ones live in the DB, not git). (2) Forge's "Deploy maintenance" panel (admin-only, onForgePage) does the same from inside the app for when you can't push, reusing Forge's Management-API path (_shared/management.ts, now multi-file capable): "Update core functions" → forgedeploy_coreredeploys the repo functions (chat,mcp,webhook,scheduler, …) from their source bundled into the UI at build time (src/lib/functionSources.tsviaimport.meta.glob('?raw'), lazy-loaded so it never weighs down the main bundle); since the frontend auto-deploys frommain, that bundled source is current as of the last deploy. "Redeploy forged functions" →redeploy_all_forgedre-pushes every vibe-coded function's stored source (the only path for those — CI can't, they're not in git).deploy_coreis admin-only and slug allow-listed (CORE_SLUGS) so it can only (re)deploy known functions, never arbitrary ones; no lint runs (core functions legitimately useDeno.env+ the service role). Outcomes log asforge.deploy_core/forge.redeploy_all. Bootstrapping note: theforgefunction itself must be deployed once (CLI/Action/MCP) to gain these actions; after that it can redeploy everything, including itself.
What this file has done since we first saw it
Hashed on every crawl. A supply-chain change to an agent config is a question of when, not whether, so the history is kept rather than the latest state alone.
- 23d ago First seen · 1,310 lines · 29,811 tokens per session scan A 1550594fab0b
supabase-as-a-service AGENTS.md is an instructions file published in the GitHub repository alnutile/supabase-as-a-service (5 stars, last pushed 2d ago), licensed MIT. It adds 29,811 tokens to every session, about $0.1192 per session on Opus 5.5. A static security scan graded it A with 1 finding (makes network calls). It is 92% identical to supabase-as-a-service CLAUDE.md, differing in 214 lines, and is treated as a copy.
supabase-as-a-service AGENTS.md compared with instructions from other repositories
| Mod | Type | Stars | Last push | Licence | Scan | Tokens | Works with |
|---|---|---|---|---|---|---|---|
| supabase-as-a-service AGENTS.mdalnutile/supabase-as-a-service | Instructions file | 5 | 2d ago | MIT | A | 29,811 | Codex, OpenCode |
| vscode buildNext.instructions.mdmicrosoft/vscode | Instructions file | 195k | yesterday | MIT | A | 9,719 | GitHub Copilot |
| next.js AGENTS.mdvercel/next.js | Instructions file | 143k | yesterday | MIT | A | 7,461 | Codex, OpenCode |
| langchain AGENTS.mdlangchain-ai/langchain | Instructions file | 149k | yesterday | MIT | A | 4,680 | Codex, OpenCode |
| vscode oss-third-party-notices.instructions.mdmicrosoft/vscode | Instructions file | 195k | yesterday | MIT | A | 5,516 | GitHub Copilot |
| vscode copilot-instructions.mdmicrosoft/vscode | Instructions file | 195k | yesterday | MIT | A | 3,093 | GitHub Copilot |
| deepseek-harness AGENTS.mddeepseek-ai/deepseek-harness | Instructions file | 274k | 2d ago | MIT | A | 4,313 | Codex, OpenCode |