Appbricx
Docs
FeaturesPricingStart free

Get started

OverviewWhat Appbricx isQuickstart with a coding agentQuickstart with the builderCore concepts

Guides

Data and tablesAccess and rolesBusiness rulesSign-inCustom screensRealtime and topicsWorkflows, schedules and webhooksServer functionsAI in your appPaymentsFilesMessagesIntegrationsVerifyPublishing, domains and rollbackOperating your appExport and self-hostSelf-host install

Tutorials

Build a booking app with Claude CodeMulti-user todo listClient portalSlack on new sign-upTemplates

Reference

Reference homeFrontend SDKHTTP APIMCP toolsCLIIntegrations catalogContract formatAI key resolutionRun Appbricx locally

Tutorial: a multi-user todo list

The smallest real multi-user app: everyone keeps their own todo list and nobody can see anyone else's, even through the API. You will add a rule, a daily email of overdue items per person, verify it and put it on a link. It takes about fifteen minutes.

What you will learn

  • Private rows with access set to own.
  • A business rule, a generated list and form page.
  • A scheduled workflow that emails each person their own items.

1. Ask your agent

Use appbricx. Create a project "Tick": a personal todo list. Each member
sees and edits only their own todos (title, notes, due date, done). A todo
needs a title. Every morning at 09:00 London time, email each member the
list of their overdue todos. Seed two members. Check, apply, verify.

No agent? Paste the same text into the builder.

2. The contract

This is a contract that does it. It was run through the production check with no errors.

{
  "app": {
    "name": "Tick",
    "tagline": "Your list, nobody else's",
    "brand_color": "#2563EB",
    "nav_style": "topbar"
  },
  "personas": [
    {
      "id": "member",
      "label": "Member",
      "description": "keeps a personal todo list",
      "kind": "consumer"
    }
  ],
  "coordinates": {
    "primaryVerb": "track",
    "inventoryShape": "records",
    "moneySurface": "none"
  },
  "entities": [
    {
      "name": "member",
      "label_field": "name",
      "fields": [
        {
          "name": "name",
          "type": "text"
        },
        {
          "name": "email",
          "type": "email",
          "optional": true
        }
      ],
      "access": {
        "member": {
          "read": "own",
          "update": "own"
        }
      }
    },
    {
      "name": "todo",
      "label_field": "title",
      "fields": [
        {
          "name": "title",
          "type": "text"
        },
        {
          "name": "notes",
          "type": "text",
          "optional": true
        },
        {
          "name": "due_on",
          "type": "date",
          "optional": true
        },
        {
          "name": "done",
          "type": "boolean",
          "optional": true
        }
      ],
      "access": {
        "member": {
          "read": "own",
          "create": true,
          "update": "own",
          "delete": "own"
        }
      },
      "rules": [
        {
          "id": "has-title",
          "on": "both",
          "message": "Give the todo a title.",
          "check": "return String(row.title ?? '').trim().length > 0;"
        }
      ]
    }
  ],
  "seeds": {
    "member": [
      {
        "id": "m1",
        "name": "Ava Patel",
        "email": "ava@example.com"
      },
      {
        "id": "m2",
        "name": "Noah Kim",
        "email": "noah@example.com"
      }
    ],
    "todo": [
      {
        "id": "t1",
        "title": "Renew passport",
        "due_on": "2026-10-10",
        "done": false
      },
      {
        "id": "t2",
        "title": "Book dentist",
        "due_on": "2026-10-14",
        "done": false
      },
      {
        "id": "t3",
        "title": "Send invoice to Acme",
        "done": true
      }
    ]
  },
  "demo_logins": [
    {
      "persona": "member",
      "row": "m2"
    }
  ],
  "pages": [
    {
      "name": "todos",
      "route": "/todos",
      "title": "My todos",
      "icon": "ListChecks",
      "personas": [
        "member"
      ],
      "layout": {
        "kind": "stack",
        "children": [
          {
            "kind": "header",
            "title": "My todos",
            "subtitle": "Only you can see this list."
          },
          {
            "kind": "block",
            "block": "EntityList",
            "props": {
              "entity": "todo",
              "presentation": "rows",
              "columns": [
                "title",
                "due_on",
                "done"
              ],
              "searchField": "title",
              "filters": [
                {
                  "field": "done",
                  "label": "Done"
                }
              ],
              "emptyCopy": "Nothing on your list. Add the first thing you keep forgetting."
            }
          }
        ]
      },
      "form": {
        "kind": "block",
        "block": "EntityForm",
        "props": {
          "entity": "todo",
          "mode": "both",
          "fields": [
            "title",
            "notes",
            "due_on",
            "done"
          ],
          "submitLabel": "Save todo"
        }
      }
    }
  ],
  "workflows": [
    {
      "id": "overdue-digest",
      "title": "Email each member their overdue todos",
      "on": {
        "schedule": "daily 09:00",
        "entity": "todo",
        "tz": "Europe/London"
      },
      "do": [
        {
          "action": "custom",
          "code": "const today = ctx.now().toISOString().slice(0, 10);\nconst todos = await ctx.db.list('todo');\nconst late = todos.filter((t) => t.done !== true && t.due_on && String(t.due_on).slice(0, 10) < today);\nconst byOwner = new Map<string, string[]>();\nfor (const t of late) {\n  const k = String(t.created_by);\n  byOwner.set(k, [...(byOwner.get(k) ?? []), String(t.title)]);\n}\nconst members = await ctx.db.list('member');\nfor (const [owner, titles] of byOwner) {\n  const m = members.find((x) => String(x.user_id) === owner);\n  if (!m || !m.email) continue;\n  await ctx.email({ to: String(m.email), subject: `You have ${titles.length} overdue todos`, body: titles.map((x) => `- ${x}`).join('\\n') });\n}\nctx.log(`digest: ${byOwner.size} members`);"
        }
      ]
    }
  ]
}
  • "access": { "member": { "read": "own", ... } } on todo and member: each person sees only their rows. Postgres enforces it.
  • The has-title rule refuses an empty title for every caller.
  • demo_logins adds a second member login, so you can check that Ava can't see Noah's list.

3. The digest, formatted

A schedule has no row, and an email action on a schedule goes to a whole role. To send each person only their own items, the workflow loops in a custom step:

const today = ctx.now().toISOString().slice(0, 10);
const todos = await ctx.db.list('todo');
const late = todos.filter((t) => t.done !== true && t.due_on && String(t.due_on).slice(0, 10) < today);
const byOwner = new Map<string, string[]>();
for (const t of late) {
  const k = String(t.created_by);
  byOwner.set(k, [...(byOwner.get(k) ?? []), String(t.title)]);
}
const members = await ctx.db.list('member');
for (const [owner, titles] of byOwner) {
  const m = members.find((x) => String(x.user_id) === owner);
  if (!m || !m.email) continue;
  await ctx.email({ to: String(m.email), subject: `You have ${titles.length} overdue todos`, body: titles.map((x) => `- ${x}`).join('\n') });
}
ctx.log(`digest: ${byOwner.size} members`);

Workflows run as the platform, so ctx.db.list sees every todo. The step groups them by created_byand finds each member's email through their account. Email goes through the platform's sender with no setup.

4. Apply, check as each user, verify

  1. Apply. The reply lists two member sign-ins.
  2. Open the preview as each one. Add a todo as Ava; sign in as Noah: it isn't there.
  3. Run verify. The data stage proves the same for every table: Permissions match the plan, and signed-out visitors can't read todo or member.
  4. Run the digest now instead of waiting: Backend, Workflows, Run now on overdue-digest, or ask your agent to run it. The run log shows digest: N members.

5. Deploy

appbricx deploy --subdomain tick

Your list is live at https://tick.apps.appbricx.com, with a snapshot of production taken first.

Gotchas

  • Seeded todos belong to nobody (they have no created_by), so no member sees them. Create todos while signed in to see them.
  • Schedules run in UTC unless you set tz.
  • Want the digest from your own domain? Connect Resend, SendGrid, Gmail or SMTP. See messages.

Related

  • Access and roles
  • Workflows, schedules and webhooks
  • Next: post new sign-ups to Slack
PreviousBuild a booking app with Claude CodeNextClient portal
Appbricx

The backend, runtime and hosting for apps your coding agent builds. On our cloud, in yours, or on your own servers.

Features

Coding agents and MCPVerify testingBuilder consolePostgres databaseData APIRealtimeRow-level securitySign-in and authPaymentsWorkflows and jobsAI in your appIntegrationsBackups and rollbackHosting and domainsSelf-host and exportAll features

Product

ShowcasePricingFor coding agentsTemplatesPrivate cloudBring your own AI keyFAQ

Compare

Appbricx vs SupabaseLovable alternativeBolt alternativeFor freelancersFor agencies

Resources

DocumentationReferenceBlogSupportContact

© 2026 Appbricx. All rights reserved.

TermsPrivacyCookiesAcceptable Use