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

  1. $ openclaw login (browser tab opens)
  2. $ openclaw push skills/supabase-tasks
  3. In the web UI, enable the skill for your agent
  4. Set ENV vars in Agent → Settings → Env (they override .env locally)
  5. 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 in onclose.

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.