OpenClaw already ships with browser control, shell access, and 800+ SaaS integrations, but a lot of us still need a plain database. Supabase is my pick when I want Postgres without running Postgres. Below is the exact recipe I used to turn Supabase into an external, persistent memory store the agent can query, update, and subscribe to—all from a chat window.
Why Supabase + OpenClaw makes sense
Supabase exposes a REST and Realtime API on top of vanilla Postgres. That lines up nicely with an OpenClaw skill: the skill provides strongly-typed functions, the agent does the natural-language parsing, and Supabase stores whatever you throw at it—tasks, leads, feature flags, you name it.
- Zero-ops Postgres (11, 14, or 15—pick your version on creation)
- Row-level security handled by policies, not handwritten SQL
- WebSockets out of the box for live updates
- Row count pricing that won’t bankrupt a side project
Prerequisites — versions that actually work
You need:
- Node 22.x or later (
$ nvm install 22) - OpenClaw CLI v0.34.2 (
$ npm i -g openclaw) - Supabase CLI v1.135.7 if you want local DB (
$ brew install supabase/tap/supabase) - A free Supabase account
Throughout this post I’m on macOS 14.5; Linux should behave the same. Windows users may need WSL for the Supabase CLI.
Bootstrapping a Supabase project
1. Create the project
Hit the Supabase dashboard → New project. Region and DB version don’t matter for this tutorial. Keep the autogenerated password around; we won’t need it directly but Supabase CLI uses it when you pull.
2. Create a public tasks table
We’ll manage tasks via chat:
create table public.tasks (
id uuid default gen_random_uuid() primary key,
title text not null,
is_complete boolean default false,
inserted_at timestamp default now()
);
Under Authentication → Policies add:
create policy "Allow read for anon" on public.tasks
for select using (true);
create policy "Allow authenticated CRUD" on public.tasks
for all using (auth.role() = 'authenticated');
Turn on Realtime for tasks (checkbox in table settings). That’s all we need from the Supabase UI.
3. Grab your keys
- URL:
https://PROJECT.supabase.co - Anon key (for public read)
- Service role key (goes in server-side env, don’t expose it to the browser)
Scaffolding an OpenClaw skill
Directory layout
skills/
supabase-tasks/
index.js
.env
In .env:
SUPABASE_URL=https://PROJECT.supabase.co
SUPABASE_SERVICE_ROLE=eyJhbGci... # long JWT
Never commit .env. OpenClaw’s gateway will load these vars automatically when the skill is mounted.
Install the SDK
$ cd skills/supabase-tasks
$ npm init -y
$ npm i @supabase/supabase-js@2.43.1 openclaw-sdk@0.34.2
Minimal skill skeleton (index.js)
import { createClient } from '@supabase/supabase-js';
import { defineSkill } from 'openclaw-sdk';
export default defineSkill(({ env, logger }) => {
const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE, {
auth: { persistSession: false }
});
return {
name: 'supabase-tasks',
description: 'Manage tasks in Supabase',
functions: {
// we’ll fill these in next
}
};
});
defineSkill forces us to declare name, description, and a map of functions. Each function becomes callable by the agent once it matches a user utterance.
Implementing CRUD operations
I like one skill per table to keep surface area small. We’ll expose:
create_task(title)list_tasks()complete_task(id)delete_task(id)
Add them into functions:
functions: {
async create_task({ title }) {
const { error, data } = await supabase
.from('tasks')
.insert({ title })
.select();
if (error) throw new Error(error.message);
return data[0];
},
async list_tasks() {
const { error, data } = await supabase
.from('tasks')
.select('*')
.order('inserted_at', { ascending: false });
if (error) throw new Error(error.message);
return data;
},
async complete_task({ id }) {
const { error, data } = await supabase
.from('tasks')
.update({ is_complete: true })
.eq('id', id)
.select();
if (error) throw new Error(error.message);
return data[0];
},
async delete_task({ id }) {
const { error } = await supabase
.from('tasks')
.delete()
.eq('id', id);
if (error) throw new Error(error.message);
return { id };
}
}
That’s the whole CRUD surface. Each resolver returns plain JSON, which OpenClaw pipes back into the conversation context.
Making the agent understand chat commands
Skills alone don’t map language → function. OpenClaw uses the function’s signature and description. Let’s annotate ours:
functions: {
async create_task ({ title }) {/* ... */},
create_task: {
description: 'Create a new to-do item',
parameters: {
title: { type: 'string', description: 'Task title' }
}
},
// repeat for the others…
}
The agent can now parse “Add buy milk to my list” → create_task({title:"buy milk"}). Expect a couple of trial runs to adjust wording. Users reported in GitHub issue #4231 that shorter descriptions improve hit rate—seems true.
Streaming realtime updates with Supabase subscriptions
Supabase JS v2 exposes .channel(). The gateway doesn’t guarantee long-lived state between messages, but the skill’s module scope stays alive while the daemon is running, so we can open a single WebSocket per skill instance.
const channel = supabase
.channel('tasks-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'tasks' },
payload => {
gateway.pushSystemMessage(
`Task table changed: ${JSON.stringify(payload.new)}`
);
}
)
.subscribe();
gateway.pushSystemMessage is undocumented but stable as of v0.34.x. The message will show up in the chat thread for any user connected to that agent.
Remember to channel.unsubscribe() in a cleanup hook if you plan to hot-reload the skill, otherwise the Postgres replication slot leaks.
Using Supabase as external memory
A quick pattern I borrowed from the community Slack: treat Supabase as a write-behind cache for agent memory. Example snippet:
async function syncMemoryToDB(memory) {
const { error } = await supabase
.from('agent_memory')
.upsert({ id: 'default', blob: memory });
if (error) logger.error(error);
}
openclaw.memory.on('flush', syncMemoryToDB);
Now when the agent’s in-RAM context grows beyond memory.maxTokens, it pushes the snapshot to Supabase. On start-up, load it back. Cheap, stateless, works across regions.
Deploying the skill to ClawCloud
$ openclaw login(browser tab opens)$ openclaw push skills/supabase-tasks- In the web UI, enable the skill for your agent
- Set ENV vars in Agent → Settings → Env (they override
.envlocally) - Restart the daemon from the dashboard
You should see “supabase-tasks loaded” in logs, followed by any live task changes you trigger.
What broke — footguns I hit so you don’t have to
- CORS: Skills run server-side, but if you call Supabase from a browser plug-in you still need to add the gateway origin to Allowed Domains.
- Row-level security: Forgetting policies leads to silent 404-looking responses (Supabase masks unauthorized as 404). Check
error.code === '42501'. - JWT length limits: Telegram bots cap message length; if you dump full records into chat you’ll get truncation. Map the payload.
- WebSocket idle timeout: On Hobby tier, Supabase drops idle sockets after 5 minutes. Keep-alive with
channel.track()ping or reconnect inonclose.
Next step: extend beyond tasks
The pattern scales: one skill per table or logical boundary, short descriptions, keep WebSockets lazy. Fork the repo I pushed at github.com/ps/pdf/openclaw-supa-tasks, wire your own schema, and ship. If you hit edge cases, drop into #supabase on the OpenClaw Discord—several of us idle there with psql windows always open.