Friends drop photos onto one shared board. Meta's models, called through the Meta Model API, cut out the things in them, and anyone can mix everyone's pins into new scenes that credit whose things are in them.
- Live app: https://pinneds.com (live from Nov 1)
- Dev, live models: https://dev.pinneds.com
- Video: 3-minute demo on YouTube
- Hackathon: Meta Global AI Developer Hackathon, Indie track, theme Build for Your World. Solo developer, Oct 26 – Nov 4, 2026.
- Built during the event. All application code was written between the Oct 26, 2026
kickoff and the Nov 4 deadline. The five commits before the 12:00 ET kickoff hold only
the licence, planning docs and the secret-scanner setup; the app scaffold landed at
15:09 ET on Oct 26. Every merged PR with its merge time and the coding agent that built
it:
docs/build-log.md.
A scene from the landing page, made from three made-up friends' pins: a cat, a beanie with heart sunglasses, a skateboard. Muse Image made their photos, Muse Spark named the things, SAM 3.1 cut them out and Muse Image composed the scene (docs/17).
- Share. Start a board and send its link to the group chat. No app, no sign-up: the link is the invitation.
- Pin. Anyone adds a photo. Muse Spark names the things in it, SAM 3.1 cuts each one out, and they land on the board as stickers with the owner's name. Missed something? Tap it in the photo.
- Make a scene. Pick 1–4 pins from anyone, then type an idea, tap a suggested one or say it out loud. Muse Image composes a new picture, "Made from" the people whose things are in it.
- Remix. Any scene can be remixed with new pins and a new idea; the credits carry everyone who contributed.
Also: owners can rename or delete their pins (deleting one hides the scenes that used it); people are allowed in photos, and a face gets a one-line consent note, never a block; optional Google sign-in adds My boards and My pins on every device but never gates a board; the UI speaks 7 languages (English, French, Spanish, Portuguese (Brazil), German, Italian, Indonesian), chosen per visitor from one shared link.
Five Meta models. Four run inside the app behind six capability ports in src/gateway/;
the fifth ran at build time. The app walks through one real example at
pinneds.com/models. Prompts, Policy keys, fallbacks, costs
and evidence for each: docs/19-meta-models.md.
| Model (ID) | What it does in Pinneds | Port |
|---|---|---|
Muse Spark (muse-spark-1.3) |
Names 1–6 things per photo as short noun phrases SAM 3.1 can cut, and flags faces (elements.v1). Names a cut-out you tapped, from its crop (tap-name.v1). Suggests scene ideas for the picked pins in the viewer's UI language, cached 24 h per language (chips.v2). |
ElementNamer, TapNamer, ChipSuggester |
SAM 3.1 (sam-3.1) |
Cuts out each named thing (one concept per call, four calls at a time) and whatever you tap (point prompt), keeping the most confident usable mask. | Segmenter |
Muse Image (muse-image-1.0) |
Composes 1–4 pins into one new scene through /v1/responses (scene.v1, store: false); a remix sends the parent scene first. It also made the source photos of the Meta-made landing sets. |
SceneComposer |
Muse Voice Transcribe (muse-voice-transcribe-1.0) |
Turns a spoken scene idea (up to 30 s) into text. The audio is never stored. | SpeechTranscriber |
Muse Spark Open (meta-models/muse-spark-open-1026, on Baseten) |
Build time only: translated the UI into six languages, with a second reviewer pass (translate-ui.v1, review-ui.v1). Never called by the running app. |
scripts/i18n-translate.mjs |
Every runtime call goes through one decorator chain (src/gateway/decorators.ts):
timeout → retry once on 429/5xx → reserve budget, fail closed → call → log the estimated
cost → a typed ready / failed / refused outcome. A refusal is a friendly line, never a
500, and a scene that runs out of AI budget becomes a free collage of the cut-outs.
flowchart LR
photo["Photo"] --> strip["EXIF stripped<br/>working copy"]
strip --> name["Muse Spark<br/>names the things"]
name --> cut["SAM 3.1<br/>cuts each one out"]
cut --> pins["Pins on the board"]
strip -->|tap| point["SAM 3.1<br/>point cut"]
point --> tapname["Muse Spark<br/>names the tapped pin"]
tapname --> pins
pins --> chips["Muse Spark<br/>idea chips"]
voice["Spoken idea"] --> asr["Muse Voice Transcribe<br/>transcribes"]
chips --> idea["Scene idea"]
asr --> idea
pins --> compose["Muse Image<br/>composes"]
idea --> compose
compose --> scene["Credited scene"]
scene -->|remix| compose
entext["English UI text"] -.->|build time| sparkopen["Muse Spark Open<br/>translates + reviews"]
sparkopen -.-> langs["6 more languages"]
Spec: docs/11-studio.md §5. Decisions:
ADR-003.
- Policy as data. One typed Zod
Policy(src/policy/policy.ts) holds every count, size, cap, timeout and TTL: defaults in code, then envPOLICY_OVERRIDES_JSON, then per-board overrides. Prompts, validators and the UI read the same object.src/config.tsis the only file that readsprocess.env. - Six ports and decorators.
ElementNamer,TapNamer,ChipSuggester,Segmenter,SceneComposerandSpeechTranscribereach have a Meta adapter (src/gateway/meta-ports.tsoversrc/gateway/meta-gateway.ts, the one file that calls the Meta Model API: plainfetch, no provider SDK), a mock adapter (the default,AI_MOCK=1) and a recorded-fixture adapter. Product code only gets the decorated ports (src/server/ai-ports.ts). Spend is reserved before a call on a per-day counter, with per-person and per-board caps. - Jobs, request and poll. A
DecomposeJob(photo → pins) and aSceneJob(pins → scene) movepending → running → ready / failed / refused, idempotent by id with claim-before-call, so a repeated request never pays twice. The request does the work; the board page polls (TanStack Query). No queue. - Data model (
src/domain/board.ts). AnElementis an owned cut-out, aPinputs it on aBoard, and aScenecredits itselementIdsand records itspromptVersionandcostUsd. Firestore when hosted, in memory locally. Board links are 128-bit capability slugs, validated before any data path is built. - Privacy.
- Original photos are never stored. An upload is checked, stripped of EXIF and re-encoded into a working copy; only cut-outs and scenes persist, served through routes that check the board link. Client Firestore and Storage rules deny everything.
- The working copy is deleted when the uploader confirms their pins. If they never do,
the next photo added to that board sweeps any copy older than Policy
workingCopyTtlHours(24 h), and the bucket lifecycle rule instorage.lifecycle.jsonis the backstop. - Calls to Meta run in Standard mode: not used for training, which is not the same as
not stored. The Muse Image compose call sends
store: false. We make no "zero data retention" claim.
flowchart LR
up["Upload<br/>(original never written)"] --> chk["Checked, EXIF stripped,<br/>re-encoded"]
chk --> wc["Working copy<br/>tmp/"]
wc --> spark["Muse Spark<br/>names"]
wc --> sam["SAM 3.1<br/>cuts out"]
sam --> el["Cut-outs<br/>elements/"]
el --> sc["Scenes<br/>scenes/"]
wc -->|"pins confirmed"| del["Working copy deleted"]
wc -.->|"older than 24 h: swept"| del
wc -.->|"1-day lifecycle rule"| del
What persists: cut-outs and scenes. The original is never written; the working copy is temporary.
Needs Node 20.9+ and npm. The defaults in .env.example (AI_MOCK=1,
DATA_BACKEND=memory) run the whole loop with no key and no paid calls. Mock pins are
always the same few things, and a mock scene is a collage of the cut-outs.
git clone https://github.com/MoriaCrew/Pinned.git
cd Pinned
npm install
cp .env.example .env.local # PowerShell: Copy-Item .env.example .env.local
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# paste that value after SESSION_SECRET= in .env.local (joining a board needs it)
npm run devOpen http://localhost:3000 and start a board. Health check: http://localhost:3000/api/health.
Set AI_MOCK=0 and your own META_API_KEY in .env.local (never commit it; see
SECURITY.md). META_MODEL_SPARK, META_MODEL_SAM, META_MODEL_IMAGE and
META_MODEL_VOICE override the model IDs. AI_DAILY_BUDGET_USD (default 5; 0 turns AI
off) and AI_DAILY_CALL_LIMIT (default 500) cap the day's spend. Every call logs an
estimated cost (docs/16-ai-usage.md). Hosting is Firebase App Hosting: the dev branch
builds the dev backend and main builds prod (apphosting*.yaml,
docs/DEPLOY.md).
npm run typecheck
npm test
npm run buildOn dev at d958f29 (Nov 4, 2026), npm test reports 2,091 tests passed, 3
skipped, in 181 test files (one of them skipped). CI (.github/workflows/ci.yml) runs
all three with AI_MOCK=1, so it never makes a paid model call. Gitleaks scans the full
history on every PR (.github/workflows/secret-scan.yml) and runs as a pre-commit hook,
and Cursor Bugbot reviews every PR: as of Nov 4, each of the 240 merged PRs passed CI and
gitleaks and got a Bugbot review (docs/build-log.md). Domain and
Policy logic have unit tests; the gateway has contract tests that replay recorded spike
fixtures
(eval/fixtures/studio/). Live-model runs happen by hand, with cost logged; results are in
eval/studio-findings.md.
PLUS_MODE is off (the default: nothing is sold), mock (a no-money demo) or stripe.
With stripe the app sells one pack, Board Plus (€2.99, Stripe): once per board, it
unlocks every board look and foil stickers and adds 20 creations. Any member
can buy it for the board; guests never have to pay. Creations are a Policy switch
(credits.enabled: off in the code defaults, on in both hosted backends through
POLICY_OVERRIDES_JSON). When on, a maker's first 3 boards a week start with 5 free
creations (prod gives 20 during judging week; later boards start at 0), each AI scene uses
one (photos and cut-outs never do), and a collage is free. A board without Plus cuts up to
15 free photos; Plus removes that limit. Purchases are granted by Stripe webhook only, and
keys come from Secret Manager, never git. Design and runbook:
docs/15-board-plus.md.
docs/README.md: index of every doc, in reading orderdocs/19-meta-models.md: each Meta model, prompt, cost and fallbackdocs/11-studio.md: product and architecture specdocs/adr/003-studio-architecture.md: Policy, ports, jobs, remixdocs/build-log.md: who built what, PR by PRSECURITY.md: public-repo guardrails
- Cuts can miss. Sometimes SAM 3.1 finds no usable mask for a named thing, and it does not become a pin; tapping it in the photo is the workaround. Muse Spark names the same photo a little differently from run to run.
- Costs are estimates from Meta list prices (
src/gateway/costs.ts). The provider dashboard is authoritative. - Not every picture is from Meta. The classic landing set, the
/newlook-picker samples (public/start/), the board theme art (public/themes/) and the eval fixture photos are Higgsfield images (SAM 3.1 cut out the stickers). The try-on and cat landing sets are Meta end to end. - AI-written text is English in places. Pin names and some scene text stay English whatever the UI language. Idea chips, generated and fixed, follow the UI language.
- Machine translations. Muse Spark Open wrote and reviewed them; no native speaker has checked them yet. Latin-script languages only.
Next.js 15 (App Router) and TypeScript, Firebase App Hosting, Firestore and Cloud Storage,
the Meta Model API, Stripe Checkout, sharp, Zod, TanStack Query and Vitest; built by one
developer with AI coding agents, one issue per PR (docs/03-ai-sdlc.md).
Which agent built what, with PR numbers and dates: docs/build-log.md.
PIN-n in code and commits is the Linear issue ID; history is in git log.
Copyright (C) 2026 Moria Crew. AGPL-3.0, see LICENSE. In short: you may use, study and modify this
code, but if you offer a modified version to others over a network you
must release your source under the same licence. Hackathon note: the
organisers separately receive a broad licence to submitted materials
under the event rules; the AGPL governs reuse by everyone else.
