---
title: The postcard walkthrough
description: Building a small third-party app end to end on the public platform -- sign-in, a database with RLS, a scoped key, an image upload, and a published page.
---

# The postcard walkthrough

This walks through building a small app — call it "postcard" — entirely
against the public platform, the way an outside developer would: sign
up, get a key, provision a database, upload an image, publish a page.
Two things trip people up here more than anything else, so they're
called out up front rather than left as traps.

**Before you start:** `project_create`'s `color` field accepts only a
curated palette, not any hex code — pick one of the palette colors the
tool lists rather than inventing your own, or the call fails client-side
before any request is sent. A raw API caller who bypasses the tool and
sends a bad color directly gets a `400 invalid_project` from the server
instead, naming the same palette (see
[MCP troubleshooting](/mcp/troubleshooting) for the full list). And
`store_upload_content`'s `contentType` is required and is what the tool
uses to work backwards: if `slug` (or `path`, when given) has no file
extension, the tool infers one from `contentType` (`image/png` becomes
`.png`, and so on) — only when neither supplies a usable extension does
it fail, and it fails before any network call.

## 1. Sign up and sign in

Create an account at `dash.shebang.pro/signup` — password sign-up
requires confirming a 6-digit code sent by email, not a confirmation
link. Sign in once confirmed.

## 2. Install the CLI and log in

```bash
npx -y shebang-mcp login
```

This runs the device-authorization flow — see
[device login](/getting-started/device-flow-login) — and writes a
**master** key to your local credentials once you approve it in the
browser.

## 3. Create a project

```
project_create({ name: "postcard", tag: "postcard", color: "#3b82f6", slug: "postcard" })
```

Use one of the curated palette colors (the friction point above) — the
tool rejects anything else client-side before the call is even made. A
raw API caller bypassing the tool's validation gets a `400
invalid_project` back instead, with a `message` naming the palette.

## 4. Mint a scoped app key

```
key_create_app({ name: "postcard app", apps: ["page", "serve", "link", "base"], project: "postcard" })
```

The plaintext key (`shb_…`) is returned exactly once — this is what your
running app authenticates with, never the master key from step 2.

## 5. Provision a database and add RLS

```
base_create_database({ slug: "postcard", project: "postcard" })
```

Then, over `base_run_sql`, one statement per call (see
[limits](/sherbase/limits)):

```sql
create table postcards (
  id uuid primary key default gen_random_uuid(),
  owner uuid not null default auth.uid(),
  title text not null,
  message text not null,
  image_url text,
  created_at timestamptz not null default now()
);
alter table postcards enable row level security;
create policy "anyone can read" on postcards for select using (true);
create policy "owner can insert" on postcards for insert with check (owner = auth.uid());
create policy "owner can update" on postcards for update using (owner = auth.uid());
create policy "owner can delete" on postcards for delete using (owner = auth.uid());
```

Then `base_reload_schema` so the new table shows up over the Data API
immediately, without waiting for PostgREST to notice on its own.

## 6. Upload the postcard's image

```
store_upload_content({ slug: "postcard-image", contentType: "image/png", contentBase64: "<base64>", project: "postcard" })
```

`slug` and `contentType` are required, and exactly one of `contentBase64`
or `text` is required too. `path` (the second friction point above) is
optional and only ever used as a filename hint — omit it, as above, and
the tool falls back to `slug` for the name. Either way, if that name has
no file extension, the tool infers one from `contentType` (`image/png`
becomes `.png`); only when neither supplies a usable extension does the
call fail client-side, before any network call, rather than surfacing
the platform's own `415`.

## 7. Publish a page and share it

```
sherpage_publish({ title: "About postcard", files: [...], access: "public" })
```

Every publish and upload gets an automatic short link — `link_list`
(scoped to `project: "postcard"`, since a key's calls default to its own
home project — see
[projects and scoping](/concepts/projects-and-scoping)) finds it.
`sherpage_set_access`/`link_set_access` change the access level
afterward if you didn't set `access: "public"` at creation time.

## What to expect from RLS

Sign in as a second user and try to update or delete another user's row
directly through `supabase-js` — it silently affects zero rows rather
than raising an error. That's the expected shape of a Postgres
row-level-security block on `UPDATE`/`DELETE`: the row is invisible to
the statement, so nothing explicit ever complains. A follow-up read
confirms the original row is unchanged.

## Next

[Sign in with sherlock](/guides/sign-in-with-sherlock) — the same
sign-in flow, from the app side, for users signing into *your* app
rather than the dashboard.
