Install
$ agentstack add skill-tomaspozo-agentlink-edge-functions ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
Edge Functions
Edge Functions handle everything that needs to talk to the outside world — webhooks, third-party APIs, scheduled triggers, service-to-service calls. They are not for CRUD or business logic (that belongs in database functions via RPCs).
Outbound HTTP always runs in an edge function — this is not a choice
Any request to a URL outside Postgres — pinging a monitored endpoint, calling a third-party API, hitting an LLM, sending a webhook — runs in an edge function, driven by cron + a queue. Never make the outbound request from in-database pg_net. This is settled architecture: don't surface it as a decision to the user, don't ask "edge function worker vs. in-database pg_net" — the answer is always the edge function.
pg_net(net.http_post) is only the trigger transport:pg_cron→public._internal_admin_call_edge_function(...)makes one HTTP call to wake an edge function. That is its entire sanctioned role. It is never the HTTP client for business logic — no per-row fetches, no fan-out to third parties, no response handling in SQL.- Why the edge function wins every time: it gets timeouts, retries, redirect handling, secrets, real error handling, observability, and the Deno ecosystem.
pg_netis fire-and-forget from inside a transaction with none of that — N outbound calls from SQL tie up the database and silently drop failures.
Canonical flow for "do work against the outside world on a schedule":
pg_cron (e.g. every 1m)
└─ public._internal_admin_call_edge_function('internal-') ← pg_net's ONLY job: one call to wake the worker
└─ edge fn: RPC to fetch the due set (api._admin_… reads the queue / due rows)
└─ edge fn: fetch each external URL (timeout, redirects, retries)
└─ edge fn: RPC to write results back
For bursty or per-item work, enqueue into PGMQ (api.agentlink_tasks) and let internal-queue-worker drain it — cron just wakes the worker. See the [database](../database/SKILL.md) cron + queue conventions, and [recipes.md](../../agents/references/recipes.md) for full worked examples (scheduled URL pinger, queued email, periodic sync). For sending app-driven (non-auth) email specifically, use the [notifications](../notifications/SKILL.md) skill — it rides this same queue via api._admin_send_email → internal-send-email.
IMPORTANT!
- All database access uses
.rpc()— never.from(). Thepublicschema is not exposed via the Data API, so.from()cannot reach tables. Usectx.supabase.rpc()orctx.supabaseAdmin.rpc()to call functions in theapischema. - Every edge function uses the
withSupabasewrapper. No exceptions. - Every edge function needs its own
deno.jsonwith pinned dependency versions — the global one is excluded during deployment. - Every edge function must have
verify_jwt = falseinsupabase/config.toml:
[functions.my-function]
verify_jwt = false
This is required because the withSupabase wrapper handles auth itself. If verify_jwt is left as true (the default), Supabase's gateway rejects requests before they reach the wrapper.
Quick Start
First edge function in a project?
The _shared/ utilities (responses.ts) should already exist in supabase/functions/_shared/ — the CLI sets these up. If missing, run npx agentlink-sh@latest.
The withSupabase wrapper comes from the @supabase/server npm package, resolved via per-function deno.json import maps.
Creating a new function
- Create the function directory —
supabase/functions/my-function/ - Add
deno.jsonwith pinned dependency versions:
``jsonc // supabase/functions/my-function/deno.json { "imports": { "@supabase/server": "npm:@supabase/server@0.1.0-alpha.1", "@supabase/supabase-js": "npm:@supabase/supabase-js@2" } } ` Always pin versions — exact for pre-release (@0.1.0-alpha.1), major for stable (@2`). Never use unversioned specifiers.
- Choose the
authtype — who can call this function? (see Selection Guide below) - Write
index.tsusingwithSupabase:
import { withSupabase } from "@supabase/server";
import { jsonResponse, errorResponse } from "../_shared/responses.ts";
export default {
fetch: withSupabase({ auth: "user", supabaseOptions: { db: { schema: "api" } } }, async (_req, ctx) => {
try {
const { data, error } = await ctx.supabase.rpc("my_rpc_function");
if (error) return errorResponse(error.message);
return jsonResponse(data);
} catch (err) {
console.error("Unhandled error:", err);
return errorResponse("Internal server error", 500);
}
}),
};
- Add to
config.toml:
``toml [functions.my-function] enabled = true verify_jwt = false ``
- Test — Local:
npx supabase functions serve/ Deploy:pnpm exec agentlink env deploy(pushes schemas + functions to the chosen env). Directnpx supabase functions deploy --use-apialso works for functions-only pushes.
Selection Guide
| Scenario | Allow | Why | | ------------------------------------------------- | --------------------- | ----------------------------------------------------- | | User clicks a button in the app | "user" | Need user identity + RLS-scoped queries | | External webhook (Stripe, GitHub) | "public" | No Supabase JWT — validate webhook signature yourself | | Supabase Auth Hook | "public" | Called by Supabase Auth, not a user session | | Public API / health check | "public" | Open access, no auth needed | | Cron job / scheduled function | "secret" | No user context — needs secret key validation | | Called from DB via _internal_admin_call_edge_function | "secret" | DB calls use the secret key | | Called by users AND by other services | ["user", "secret"] | Dual-auth — accepts either credential |
When in doubt: logged-in user → "user". External service → "public". Internal infrastructure → "secret".
Naming convention: internal- prefix for system-only functions
Functions that are never called from client code — they're only enqueued via PGMQ, fired via pg_net, or invoked by an auth hook — get an internal- prefix in their directory name. Functions called by clients (frontend code, external webhooks, public APIs) get a bare name.
| Pattern | Examples | When to use | |---|---|---| | Bare name | stripe-webhook, share-image, chart-render | Anything a client (or external service) might hit. | | internal- prefix | internal-send-auth-email, internal-send-email, internal-queue-worker | Queue workers, auth-hook handlers, cron-only functions, anything wrapped with auth: "secret" and never exposed to users. |
Three reasons:
- Discoverability. A reader scanning
supabase/functions/sees the privilege boundary at a glance — no need to openindex.tsto learn whether a function is user-facing. - Symmetry with the SQL convention.
_internal_admin_*(DEFINER helpers inpublic) andinternal-*(system-only edge functions) share the same word for the same concept: "system, not client." - Safety net for routing accidents. If the agent later writes
enqueue('send-email')somewhere new, the missinginternal-prefix won't match anything in the queue dispatcher — fails loudly instead of silently calling the wrong function.
Don't use a leading underscore (_internal-foo) — Supabase treats top-level _* directories as non-deployable (which is why _shared/ works as a non-deployed module). The hyphenated internal- prefix is the safe form.
Edge function names beginning with internal- should always be paired with auth: "secret". If you find yourself writing auth: "user" on an internal-* function, the prefix is wrong.
> For the full wrapper API, dual-auth patterns, anti-patterns, and context reference, load [withSupabase Reference](./references/with_supabase.md).
Secrets
Edge functions need SUPABASE_PUBLISHABLE_KEY and SUPABASE_SECRET_KEY configured as secrets — they are not available by default.
# Local development — add to supabase/.env or .env.local
SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
SUPABASE_SECRET_KEY=sb_secret_...
# Cloud / Production — set via CLI (already configured by scaffold)
npx supabase secrets set SUPABASE_PUBLISHABLE_KEY=sb_publishable_...
npx supabase secrets set SUPABASE_SECRET_KEY=sb_secret_...
SUPABASE_URL is available by default and does not need to be set.
Project Structure
supabase/functions/
├── _shared/ # Shared utilities (NOT deployed — leading `_`)
│ └── responses.ts # Response helpers
├── _feature-name/ # Feature-specific shared modules (NOT deployed)
│ └── helpers.ts
├── stripe-webhook/ # Client/external-facing — bare name
│ ├── index.ts
│ └── deno.json
├── internal-queue-worker/ # System-only (cron + pg_net) — `internal-` prefix
│ ├── index.ts
│ └── deno.json
├── internal-send-auth-email/ # System-only (auth hook → queue) — `internal-` prefix
│ ├── index.ts
│ └── deno.json
└── deno.json # Global — local dev fallback only
Folders prefixed with _ are shared modules — they are not deployed as edge functions. Folders prefixed with internal- ARE deployed but signal "system-only, never call from a client" by convention (paired with auth: "secret"). Every deployed function needs its own deno.json with its dependencies mapped — the global deno.json is excluded during deployment.
Native MCP Server
Your app ships a native MCP server at supabase/functions/mcp/ — a per-user, OAuth 2.1 protected resource server that lets Claude, Cursor, or any MCP client call your app's api.* RPCs on the user's behalf. It mirrors withSupabase: the function file is export default { fetch: withMCP(info, registerTools) }, and _shared/mcp.ts owns OAuth discovery, the caller's-JWT binding, and the per-request x-workspace-id workspace model.
To expose functionality over MCP, add a domain tool — one ctx.workspaceTool(...) per RPC:
import { withMCP, registerAdminTools, z } from "../_shared/mcp.ts";
export default {
fetch: withMCP({ title: Deno.env.get("APP_NAME"), icon: ICON }, (ctx) => {
registerAdminTools(ctx); // optional: 10 free workspace/member/invitation tools
ctx.workspaceTool(
"list_notes",
{ title: "List notes", description: "List notes in a workspace.", inputSchema: { status: z.string().optional() } },
({ supabase, status }) => supabase.rpc("note_list", { p_status: status ?? null }),
);
}),
};
workspaceTool adds the workspace arg, resolves it, binds a workspace-scoped client, calls your handler, and wraps the RPC { data, error }. The handler just calls the api.* RPC — the DB (auth_verify_access + RLS) enforces per-workspace access. Never use a service-role client in a tool (ctx has no supabaseAdmin — confused-deputy). Requires [functions.mcp] verify_jwt = false + [auth.oauth_server] enabled = true in config.toml.
> For the tool config, the ctx toolkit, registerAdminTools, and the cloud OAuth caveat, load [MCP Server Reference](./references/mcp.md).
Reference Files
Load these as needed:
- [🔧 withSupabase Wrapper](./references/with_supabase.md) — Full wrapper API: auth types, dual-auth, clients, anti-patterns, context reference
- [🔌 MCP Server](./references/mcp.md) — The native per-user OAuth MCP server:
withMCP, adding a domain tool withworkspaceTool, thectxtoolkit,registerAdminTools, config.toml + cloud OAuth caveat - [📦 Dependencies & Deployment](./references/dependencies.md) — Per-function
deno.json, import maps, bare specifiers, sub-path mapping, version pinning,--use-apideployment - [📁 Edge Function Patterns](./references/edge_functions.md) — Folder structure details, response helpers, feature-specific modules
- [🔑 API Key Migration](./references/apikeymigration.md) — Migrate from legacy anon/service_role keys to new publishable/secret keys
- [📧 Resend setup](../cli/references/resend.md) — For functions that send email (
internal-send-email,internal-send-auth-email, …): howRESEND_API_KEY/RESEND_FROM_EMAILare configured per-env, local resend-box vs cloud, and the "email not sending" debug flow
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: tomaspozo
- Source: tomaspozo/agentlink
- License: MIT
- Homepage: https://agentlink.sh
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.