Build a multi-user Todo app end-to-end
A complete walkthrough: prompt a fresh project, generate the UI, wire the data model with row-level security, expose auto CRUD, add a scheduled overdue-digest email, and publish to a subdomain. Every command below matches a real Appbricx endpoint.
0. Prep
- Create a new project (Getting started). Pick the blank template so nothing pre-seeds.
- Optional but recommended: attach BYOK (Bring your own key) so the generation step does not eat included AI credits.
1. The initial prompt
Open the editor chat and paste the prompt below. Keep it as one message — Appbricx generates a much cleaner backend when the data intent, RLS expectation, and UI shape land together.
Build a multi-user todo app.
Data:
- todos(id uuid pk, title text, notes text, due_at timestamptz,
done boolean default false, created_by uuid, created_at timestamptz default now())
- Enable RLS so each signed-in user sees only rows where
created_by = current app user.
- First signup in the project becomes admin.
UI:
- /login and /signup pages using db.auth.signup / db.auth.login.
- /todos as the main screen: an "Add todo" form (title + optional due date)
and a list grouped into Overdue / Today / Upcoming / Done.
- Toggle done inline; delete with a trash icon.
Use runtime.api for create/update/delete and a named query "list_my_todos"
for the grouped read. No raw SQL in components.The agent scaffolds React pages, a migration, one named query, and atables.json ACL. Watch the file tree — you should see files appear under .appbricx/backend/ as the generation completes.
2. Data model & RLS
Confirm the generated migration matches what you asked for. Open.appbricx/backend/migrations/001_todos.sql:
CREATE TABLE IF NOT EXISTS todos (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
title text NOT NULL,
notes text,
due_at timestamptz,
done boolean NOT NULL DEFAULT false,
created_by uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;
CREATE POLICY todos_owner_all ON todos
USING (created_by::text = current_setting('app.user_id', true))
WITH CHECK (created_by::text = current_setting('app.user_id', true));The data worker sets app.user_id from the app session on every request, so the same policy covers list, get, update, and delete. Background: Auth & RLS.
3. The generated UI
The list view calls a named query so grouping happens server-side — components stay dumb. Open .appbricx/backend/queries/list_my_todos.sql:
-- @name list_my_todos
SELECT
id, title, notes, due_at, done,
CASE
WHEN done THEN 'done'
WHEN due_at IS NULL THEN 'upcoming'
WHEN due_at < now() THEN 'overdue'
WHEN due_at::date = current_date THEN 'today'
ELSE 'upcoming'
END AS bucket
FROM todos
ORDER BY done ASC, due_at ASC NULLS LAST;The React screen at src/routes/todos.tsx is roughly:
import { useEffect, useState } from "react";
import { runtime } from "@appbricx/runtime";
import { db } from "@appbricx/data";
export default function Todos() {
const [rows, setRows] = useState<any[]>([]);
const [title, setTitle] = useState("");
const [dueAt, setDueAt] = useState("");
async function refresh() {
const r = await runtime.queries.run("list_my_todos");
if (r.ok) setRows(r.rows ?? []);
}
useEffect(() => { refresh(); }, []);
async function add(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
const user = await db.auth.getUser();
await runtime.api.create("todos", {
title,
due_at: dueAt || null,
created_by: user!.id,
});
setTitle(""); setDueAt("");
refresh();
}
async function toggle(row: any) {
await runtime.api.update("todos", row.id, { done: !row.done });
refresh();
}
async function remove(row: any) {
await runtime.api.delete("todos", row.id);
refresh();
}
const buckets = ["overdue", "today", "upcoming", "done"] as const;
return (
<div>
<form onSubmit={add}>
<input value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="What needs doing?" />
<input type="datetime-local" value={dueAt}
onChange={(e) => setDueAt(e.target.value)} />
<button>Add</button>
</form>
{buckets.map((b) => (
<section key={b}>
<h3>{b}</h3>
<ul>
{rows.filter((r) => r.bucket === b).map((r) => (
<li key={r.id}>
<input type="checkbox" checked={r.done}
onChange={() => toggle(r)} />
{r.title}
<button onClick={() => remove(r)}>delete</button>
</li>
))}
</ul>
</section>
))}
</div>
);
}4. Auto CRUD — the REST surface
The agent creates .appbricx/backend/api/tables.json so onlytodos is exposed. Empty ACLs open every non-internal table — never skip this file.
{
"allow": ["todos"],
"tables": {
"todos": {
"expose": true,
"methods": ["GET", "POST", "PATCH", "DELETE"]
}
}
}Exercise it against the running project:
# Create
curl -sS -X POST "$ORIGIN/__appbricx/api/v1/todos" \
-H "Authorization: Bearer $DATA_TOKEN" \
-H "x-appbricx-data-api: 1" \
-H "x-appbricx-app-session: $APP_SESSION" \
-H "content-type: application/json" \
-d '{"title":"Ship v1","due_at":"2026-09-01T17:00:00Z"}'
# List
curl -sS "$ORIGIN/__appbricx/api/v1/todos?limit=50" \
-H "Authorization: Bearer $DATA_TOKEN" \
-H "x-appbricx-data-api: 1" \
-H "x-appbricx-app-session: $APP_SESSION"
# Toggle done
curl -sS -X PATCH "$ORIGIN/__appbricx/api/v1/todos/$ID" \
-H "Authorization: Bearer $DATA_TOKEN" \
-H "x-appbricx-data-api: 1" \
-H "x-appbricx-app-session: $APP_SESSION" \
-H "content-type: application/json" \
-d '{"done":true}'Without x-appbricx-app-session the RLS policy filters everything out — that is the desired behaviour for a shared surface. Full reference: Auto CRUD REST API.
5. Add the daily overdue-digest workflow
Ask the agent to add a scheduled workflow that emails each user their overdue items at 09:00 local time.
Add a workflow "overdue-digest" that:
- runs on cron "0 9 * * *" (UTC)
- pulls every user with at least one overdue todo
- calls ctx.messages.email once per user with a bullet list
- publishes topic "todos.digest.sent" with { user_id, count }
Register a schedule manifest for it.You end up with two backend files. The workflow at .appbricx/backend/workflows/overdue-digest.workflow.js:
/** @param {import("@appbricx/runtime").WorkflowContext} ctx */
export async function run(ctx) {
const r = await ctx.queries.run("overdue_by_user");
if (!r.ok) throw new Error(r.error?.message ?? "overdue_by_user failed");
let sent = 0;
for (const group of r.rows ?? []) {
const body = group.items
.map((t) => `- ${t.title} (due ${t.due_at})`)
.join("\n");
await ctx.messages.email({
to: group.email,
subject: `You have ${group.items.length} overdue todos`,
text: body,
});
await ctx.topics.publish("todos.digest.sent", {
user_id: group.user_id,
count: group.items.length,
});
sent += 1;
}
ctx.log.info(`digest sent to ${sent} users`);
return { ok: true, sent };
}And the cron manifest at .appbricx/backend/schedules/overdue-digest.json:
{
"id": "overdue-digest",
"cron": "0 9 * * *",
"timezone": "UTC",
"workflow": "overdue-digest",
"enabled": true
}Connect a Resend, SendGrid, or Gmail integration before the first fire —ctx.messages.email resolves through the same integration registry as any other action.
6. Test the workflow without waiting for 09:00
You can invoke any workflow on demand — the runner uses the same code path as the cron ticker.
# SDK
await runtime.workflows.invoke("overdue-digest", { payload: {} });
# HTTP
curl -sS -X POST "$ORIGIN/__appbricx/runtime/workflows/overdue-digest/run" \
-H "Authorization: Bearer $DATA_TOKEN" \
-H "x-appbricx-data-api: 1" \
-H "content-type: application/json" \
-d '{"payload":{}}'
# → { "ok": true, "runId": "…" }
# Inspect the run + logs
curl -sS "$ORIGIN/__appbricx/runtime/runs/$RUN_ID" \
-H "Authorization: Bearer $DATA_TOKEN" \
-H "x-appbricx-data-api: 1"Details: Workflows · Webhooks & schedules.
7. Publish
- Open the editor → Deploy / Publish.
- Appbricx builds and serves the app at
https://<subdomain>.apps.appbricx.com. - Sign up two accounts from different browsers — each only sees their own todos, and the 09:00 cron fires per user.
Custom domain (Pro+): Publish & domains.
Internal map (what just happened)
Browser (todos.tsx)
→ /__appbricx/api/v1/todos (auto CRUD, RLS by app.user_id)
→ /__appbricx/runtime/queries/run (list_my_todos, grouped read)
↑
Schedule ticker (every ~15s)
→ app_runtime_schedules due? enqueue "overdue-digest"
→ runner loads overdue-digest.workflow.js
→ ctx.queries.run → ctx.messages.email → ctx.topics.publish