Read and write Supabase tables from a Code node or CodeAct action, with NodeTool's PostgREST request builder running on the host
Scanned 9/1/2026
Install to Claude Code
npx -y skills add nodetool-ai/nodetool --skill sandbox-supabase --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Sandbox Supabase?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/nodetool-ai-sandbox-supabase)More formats (shields.io, HTML) on the badges page.
---
name: sandbox-supabase
description: Read and write Supabase tables from a Code node or CodeAct action, with NodeTool's PostgREST request builder running on the host
---
# Supabase in the sandbox
Specifier: `@nodetool-ai/sandbox-supabase`. Import it at the top of the body.
Supabase's REST surface is PostgREST: the whole query lives in the URL and the
write semantics live in a `Prefer` header. Both are easy to get subtly wrong —
an upsert without `resolution=merge-duplicates` is just a failed insert — so
this pack does the encoding.
**Nothing here sends a request.** Both exports build one; the guest's own
`fetch` sends it, under the run's fetch cap and SSRF guard.
## from — one table
```js
import { from } from "@nodetool-ai/sandbox-supabase";
const url = await nodetool.secrets.get("SUPABASE_URL");
const key = await nodetool.secrets.get("SUPABASE_KEY");
const req = await from({
url, key,
table: "issues",
select: "id,title,status",
filters: { status: "eq.open" },
order: "created_at.desc",
limit: 20
});
const res = await fetch(req.url, req);
if (!res.ok) throw new Error(`Supabase select failed: ${res.status}`);
return { rows: res.json };
```
`method` picks the operation:
| method | operation |
|---|---|
| `GET` (default) | select |
| `POST` | insert, or upsert with `onConflict` |
| `PATCH` | update the rows the filters match |
| `DELETE` | delete the rows the filters match |
Options: `url` and `key` (required), `table` (required), `method`, `select`,
`filters`, `order`, `limit`, `offset`, `body`, `onConflict`, `count`,
`returning`, `query`.
**Filters** take either form:
```js
filters: { status: "eq.open", priority: "in.(high,urgent)" } // raw PostgREST
filters: { age: { gte: 18, lt: 65 } } // operator object
```
**Insert**
```js
const req = await from({ url, key, table: "events", method: "POST", body: inputs.rows });
```
**Update the matching rows**
```js
const req = await from({
url, key, table: "issues", method: "PATCH",
filters: { id: `eq.${inputs.id}` },
body: { status: "closed" }
});
```
**Upsert** — `onConflict` names the unique column and turns the insert into a
merge:
```js
const req = await from({
url, key, table: "users", method: "POST",
onConflict: "email", body: inputs.users
});
```
A write returns the affected rows. Pass `returning: "minimal"` when you do not
want them back.
## rpc — a Postgres function
```js
import { rpc } from "@nodetool-ai/sandbox-supabase";
const req = await rpc({ url, key, fn: "search_issues", args: { term: inputs.term } });
return { rows: (await fetch(req.url, req)).json };
```
## Gotchas
- **Every export is async.** A host call is a round trip.
- **A `DELETE` or `PATCH` with no filters hits every row.** PostgREST does not
refuse it. Pass the filters.
- **The anon key obeys row-level security.** A select that returns `[]` against
a table with rows usually means RLS, not an empty table. The service-role key
bypasses it — and bypasses it for every row, so keep it out of anything a
user's prompt can steer.
- **Errors arrive as JSON with a non-2xx status.** Check `res.ok` and read
`res.json.message`; PostgREST says exactly what it disliked.
- **Supabase caps a page at 1000 rows.** Page with `limit` and `offset`.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!