---
name: MulmoTerminal Shared App
slug: mulmoterminal-shared-app
category: Automation
description: MulmoTerminal Shared App builds a web app that multiple people can use together, with answers stored in one shared place. Use it for surveys, sign-up sheets, booking forms, shared lists, and publishing or unpublishing the app.
github: "https://github.com/receptron/mulmoterminal/tree/main/server/skills/mulmoterminal-shared-app"
language: TypeScript
stars: 202
forks: 31
install: "npx degit https://github.com/receptron/mulmoterminal/tree/main/server/skills/mulmoterminal-shared-app ~/.claude/skills/mulmoterminal-shared-app"
installs_to: ~/.claude/skills/mulmoterminal-shared-app
source_path: server/skills/mulmoterminal-shared-app/SKILL.md
collection_size: 12
category_size: 1956
collection_url: "https://dirskills.com/collections/receptron/mulmoterminal"
added: 2026-09-05T05:30:16.008Z
last_synced: 2026-09-05T05:30:16.008Z
canonical_url: "https://dirskills.com/skills/mulmoterminal-shared-app"
---

# MulmoTerminal Shared App

MulmoTerminal Shared App builds a web app that multiple people can use together, with answers stored in one shared place. Use it for surveys, sign-up sheets, booking forms, shared lists, and publishing or unpublishing the app.

**Install:**

```bash
npx degit https://github.com/receptron/mulmoterminal/tree/main/server/skills/mulmoterminal-shared-app ~/.claude/skills/mulmoterminal-shared-app
```

## README

# Something other people use

A request like "make a survey for my talk", "I need a sign-up sheet", "let people book a slot",
"a form I can send a link to" is asking for a SHARED APP — a thing that lives on the web, keeps
its answers in one place, and can be handed to people who do not have this repository or this
machine.

**Do not offer a printable page, a Google Form, or a stand-alone HTML form as the answer.** They
are what this looked like before there was anywhere to keep the answers, and each of them leaves
the user to solve the actual problem — where the responses go — by themselves. Offer them only if
the user turns this down.

## What a shared app is

- **One repository is one app.** The folder this session is open in becomes the app; its
  declaration is `app.json` at the root.
- **The definition is committed; the answers are not.** Schemas and views are files in the
  repository. Records live in the app's cloud store, so everyone sees the same rows.
- **Who may do what is a list of email addresses** in `app.json`. Inviting somebody is adding a
  line and publishing — they need no account here and no repository.

## Start from a template when one fits

Nine shapes are written out in full — declaration, schemas, and the reasoning behind each key:

- **[templates/salon.md](./templates/salon.md)** — a request that a NAMED PERSON approves, and only
  their own (a salon's bookings, interviews, repairs, review assignments). This is what `assignee`
  is for.
- **[templates/gym.md](./templates/gym.md)** — **first come, first served**, with a waiting list and
  a per-class opening time (a gym class, a workshop, a slot booking). This is what `stampField` and
  `window.fromField` are for, and it explains why the capacity lives in the VIEW and not in the
  rules.
- **[templates/survey.md](./templates/survey.md)** — **collecting answers**, with nothing to run out
  of (a survey, a quiz, an application form, a sign-up with no cap). The shortest declaration of the
  nine, and the shape most often written with a public page and nothing else — so this one is built
  around its `member` page, which is where the answers are read. It also spells out the three-way
  trade above, and what a tally may and may not claim about values a respondent typed.
- **[templates/meeting-room.md](./templates/meeting-room.md)** — a bookable unit you can LIST IN
  ADVANCE, taken on the spot with no approval (a meeting room, a desk, equipment on loan, a parking
  space). This is what `idFrom: "field"` and `mirror` are for, and it is the one that spells out who
  refills the slots, and what a cancellation does NOT do.
- **[templates/live-poll.md](./templates/live-poll.md)** — a page that MOVES while people are looking
  at it (a live stream, a lecture, a stand-up quiz). This is what `views[].live` is for, and the only
  one whose declaration is decided by FAN-OUT: the audience may watch the questions, only the desk may
  watch the votes, and the shape that would let both is refused by publish. **In English**, because
  the strings in its pages are what a stranger reads.
- **[templates/project-board.md](./templates/project-board.md)** — the same work-claiming board with
  a ROSTER and an owner's desk: people register a name once (`names`, `idFrom: "auth.uid"`), then
  take work; the owner adds and removes tasks and frees an abandoned one from `/m/`. This is what
  `writerDelete` is for, and the one that spells out `view.mine()`'s three states — "you have not
  registered" and "nobody looked" are different answers, and treating them alike shows the
  registration form to somebody who has already registered. It also shows the owner-only form: a
  `public.submit` declaration whose window is CLOSED, which is how a page gets a create form that
  only a writer may send.
- **[templates/append-feed.md](./templates/append-feed.md)** — a record a CLOSED GROUP writes to and
  nobody rewrites: rows are only ever added, and only their author may remove one (a shift handover
  note, an incident timeline, a team log, a class's question board, a chat room). This is what
  `views[].limit` is for — the one shape whose collection grows with the app's AGE rather than with
  the thing being booked — and the one where `writerDelete` is deliberately ABSENT, which is
  `project-board.md` turned over: an owner pressing delete on somebody else's row is refused by
  Firestore. It is also the only sample showing `public.enabled: false` beside a populated
  `public.submit`, which is the pair the naming makes look contradictory.
- **[templates/ai-council.md](./templates/ai-council.md)** — a discussion the participants are AI
  AGENTS rather than people, held in public, which a human host can END (a debate, a design review
  by several models, a red-team panel). This is what **`agents[]`** is for — the app publishes the
  JOB beside the pages, so an agent at another MulmoTerminal takes a seat from the slug alone, with
  no copy of your repository. It is also the only sample that has to bind its own OWNER: every agent
  here signs in as the owner, so the host's close is enforced by `refIn`, a `transitions` map with no
  exit, and `sealed` together — any one alone is walked around in two writes. Read it for what a
  declaration can and cannot hold when the writer is an agent you handed your sign-in to.
- **[templates/magazine.md](./templates/magazine.md)** — several writers publishing things to READ,
  each at its own URL, each signing their own and editing nobody else's (a team blog, a newsletter's
  back issues, a research log, release notes, a review column). This is what **`views[].article`**
  is for — the platform draws the article page, so the app declares which fields are the title, the
  body and the byline, and `idFrom: "slug"` makes the writer's chosen name the document id and
  freezes it. It is the only sample that states a `protocol` of its own, the only one whose `limit`
  is a COST — publish works it out in bytes and refuses the declaration when it is too large — and
  the one that explains why the owner has to hold
  `participant` on their own collection — `audience` forces `submitOnly`, `submitOnly` closes the
  writer branch, and an owner who skips it cannot publish at all. Read it before any app where a
  record is something a stranger is meant to sit and read.

Read the matching one before writing `app.json` by hand. All nine are checked against the real
publish gate by this repository's tests, so what they show is what publishes — and they spend most
of their length on the traps, which is the part you cannot recover by guessing.

They also show what the page should LOOK like, and that is not decoration. The page is the only
thing a visitor ever sees — nobody reads `app.json` — so one that arrives as unstyled boxes does
not read as plain, it reads as unfinished, and an unfinished-looking booking form is one people
close. **[templates/design.md](./templates/design.md)** is the rules: a palette derived from one
hue you choose for this app, fluid type, a radius and a shadow that descend with the nesting, and
what to do about the fact that you can load no stylesheet, no webfont and no image. Read it before
you write the pages, and **change `--hue`** — every template ships a different one so that copying
a template does not make every app the same colour, and a page left in the template's colours is a
page nobody chose.

## The path

Say what you are doing in the user's words ("作っています", "みんなが見えるようにしました"). The
words below are for you, not for them: an author does not need to know what a `cid` is.

### 1. Start the app

`manageSharedApp` with `action: "init"`, and `name` (and `slug`, if you have one worth wanting).

**Do not compose `app.json` yourself.** The declaration names its owner by EMAIL and it has to be
the address this machine is SIGNED IN with — you cannot read that, and the address the user tells
you is the one that fails at publish. `init` writes it, generates the `aid`, and refuses if the
repository already declares an app.

`init` also TAKES the `aid` on the server before it writes the file, so it needs a connected
session and reports a refusal instead of leaving a half-started app. That is not bookkeeping: the
id lives on a shelf shared by everyone using this deployment, `app.json` is meant to be committed,
and an id that is written down but not yet taken can be taken by whoever reads the file first — and
an app id can never be freed. If the reservation is refused, nothing was written and `init` can
just be run again.

`slug` is the name in the URL people will be given. Take it from what the thing IS
(`aug-talk-survey`), lowercase with hyphens. It is a wish: if it is taken, a number is appended and
written back. **`init` RESERVES it** along with the app id, so the address is fixed from the start —
and the reservation can never be freed, which is why it follows a name the user wanted rather than
one you invent. The name resolves for the app's own roster immediately (`/m/{slug}`) and for
everybody when you publish.

The file is an ordinary committed declaration afterwards — you may read it, and the user may edit
it in a pull request. What you should not do is REWRITE it: `invite` changes one roster entry, and
`check` tells you whether what is there would publish.

#### The repository is a CLONE of somebody else's app

`manageSharedApp` with `action: "fork"` — not `init`, which refuses here, and above all not by
editing `app.json` yourself.

A cloned repository already carries a declaration, and the schemas beside it are exactly what the
user cloned it FOR. `fork` mints a new `aid`, makes the signed-in address the only member, and
carries `collections` and `public` over unchanged. It does not touch `.claude/skills/`.

The signals are a user saying this is a clone, someone else's address in `members`, or `init`
telling you an app is already declared. Ask for a `name` and a `slug` before you run it — the
cloned app's URL name is deliberately NOT carried, because kept it would be honoured as a wish and
come back as `their-name-2`, which is a name nobody chose.

What the user must be told, in their words: the app they cloned is untouched, and **its answers do
not come across.** They are getting the same form, empty. And the people on the old roster are not
on theirs.

`fork` refuses when the signed-in address already owns the app — that is not a clone, and forking
it would abandon the existing app and every record in it.

### 2. Write the collection

One collection per kind of record — a survey has one (`responses`), a booking app might have two
(`bookings`, `services`).

**A NEW collection is created by writing the files**: `SKILL.md` and `schema.json` under
`.claude/skills/<slug>/`. `putSchema` is EDIT-ONLY and refuses a collection that does not exist
yet ("unknown collection … create it by writing SKILL.md + schema.json"), so do not try to create
one with it. Use it afterwards, to CHANGE a schema.

**Read the shape first**: `manageCollection` with `action: "schemaDocs"`, and
`topic: "Shared storage (firestore)"` for this part specifically. The shape is not what a
reasonable person guesses — `fields` is an OBJECT keyed by field name (not a list), `primaryKey`
and `icon` are required, and the key for a field's human name is `label`. A schema in the shape
you would design does not parse, and a collection whose schema fails validation is **skipped
silently**: nothing errors, it simply never appears.

That topic covers the STORAGE key and not the field types, and the two are asked for separately:
`topic: "Field types"` is where a `datetime`'s exact format lives, and it is the one a seeded
collection gets wrong (step 2b).

The one thing that differs from an ordinary collection:

```json
{ "storage": { "type": "firestore" } }
```

That is what makes the records shared. Declare no `dataPath` beside it — exactly one of the two.

**The app already has its `aid`** — `init` wrote it in step 1 — so a shared collection you write
correctly is discovered straight away. If `getSchema` says "unknown collection" after you have
written the files, that is the schema FAILING VALIDATION, not something publishing will fix: read it
back against `schemaDocs` (`primaryKey` naming a field flagged `primary: true`, `icon` present,
exactly one of `dataPath` / `dataSource` / `storage`). Publishing past it produces an app with the
collection missing and no error anywhere.

**Everything in the folder is shared or nothing is.** Do not mix a shared collection and a local
one in an app's repository.

### 2b. Load the inventory, if the app needs rows before anybody arrives

Some apps have nothing to show until rows exist: bookable slots, a timetable, a menu of services.
Nothing generates them — the platform runs no code of its own — so they are written with
`manageCollection` `putItems` before the app opens, and topped up later (the meeting room's
[枠の補充](./templates/meeting-room.md) covers the weekly refill task).

**Prove ONE batch before you generate thousands.** Write a single day, read it back with
`getItems`, then run `check` (step 4b). `putItems` REFUSES a row missing a required field or
carrying an unknown `enum` value (and, under `mode: "create"`, an id that already exists) — that is
what it refuses, and it is not everything it checks. The SHAPE of a typed value (a real date, a
numeric `number`, a `datetime`'s exact format) is **written and reported**: the answer carries a
`lint` block beside `written`, and the same rows are **refused at publish**. So an empty `rejected`
is not proof — 720 accepted rows are not 720 valid rows — and one day first is one round trip
against one regeneration per batch.

**That proof needs a session.** `check` answers offline, and offline it does NOT read the records —
it says so in as many words ("the live records were NOT scanned"). A `check` that has not scanned
them proves nothing about the batch you just wrote, so connect first, and read what it says about the
records. Exactly one answer is a proof: the scan RAN, over every shared collection in this
repository, and found nothing.
Everything else is repaired first, and the rest are not degrees of that one — they are different
repairs, and more than one can be reported at once (a collection that could not be read does not
stop the others being scanned):

- **rows that do not fit** — named, and a MIGRATION. `confirm` at publish is the decision to break
  them for everybody, not a way past this.
- **UNKNOWN** — a collection could not be READ, so nothing at all is known about the rows behind it.
  That is access, not data.
- **not scanned** — the line says which: no session, or an `app.json` that does not parse.

**`datetime` is a wall clock, not an instant.** `YYYY-MM-DDTHH:MM`, seconds optional, **no timezone
suffix**. `new Date(...).toISOString()` is the reflex and it is wrong twice: the `Z` is refused at
publish, and the time SHIFTS into whatever timezone this machine is in — a Tokyo court's 08:00
becomes `15:00Z` when the script runs in Seattle, and `16:00Z` for the same 08:00 in winter, because
the offset moves too. Had the format been accepted, the app would have published with every row
seven hours out — eight, on the dates the other side of the change. Build the string from its parts
(`` `${dateKey}T${hh}:00` ``). A `stampField`'s `…Z` (step "limited number of places") is the one
`datetime` shaped that way (nine fractional digits), and the rules write it — no script does.
`putItems` flags the wrong shape in `lint` as it writes, so the reflex is caught on the first batch
rather than at publish — if you read the answer.

**Generate with a deterministic script, and do not write the rows out yourself.** Dates, month ends
and daylight saving are what an LLM gets wrong, and a few hundred inline rows are tens of KB emitted
a token at a time. Have the script write a bare array of records to a JSON file under the workspace
and pass its absolute path as `putItems`' `itemsFile` — 1000 rows and 8 MiB per call, and an
over-limit call writes nothing at all.

**Pass `mode: "create"`.** The default REPLACES a whole record, so a re-run — a retry, a refill that
overlaps what is already there — silently overwrites fields nothing regenerated, and reading the ids
first does not save you: another run or a hand edit can create the same id in the gap between the
read and the write. `create` has the host refuse a colliding row instead.

**And then read `rejected` AND `lint`.** They answer different questions, and only one of them is
about rows that failed to land. `lint` appears beside `written` when a row WAS written and its
values are the wrong shape — a `datetime` that is an instant, a `date` that is not a real day, a
`number` holding text — with `total` (every flagged row) and `rows` (the first ten, as
`{ id, problem }`). Those rows are in the collection and publish will refuse them, so a `lint` block
is the generator to fix and the batch to rewrite, now rather than after the other 719. No `lint`
key at all is the clean answer, and `total` — not the length of `rows` — is how many there are.

Rewriting them is the one place `mode: "create"` is the wrong mode: the rows exist, so `create`
would refuse every one of them. Fix the generator, regenerate the SAME ids, and send them with the
default `upsert` — the script produces the whole record, so replacing it whole is exactly right, and
it is only safe here because these ids are the ones you just wrote in this same batch.

`rejected` is the other half. It is not a count and not only about collisions: `putItems` returns
`{ written, rejected }` with one `{ id, problem }` per refused row, and the `problem` is as likely
to be a missing required field or an unknown `enum` value as an id that already existed. So go
through them: every `problem` that is not "already exists" is a row that was NOT written, and it
needs fixing and re-sending — just that row. A refusal of the whole CALL (over the row or byte
limit) is a different thing and does not arrive as `rejected` at all: no `{ written, rejected }`
comes back, nothing was written, and the fix is to split the file rather than to re-send rows.

And "already exists" says exactly that much: the id was there when the write ran. It does not say
who put it there — an earlier attempt of this same refill, another run, a hand edit — and it does
not say the stored row is the row you just generated. `create` cannot correct it either. If that
matters — a regeneration that changes what a slot should say — read those ids back with `getItems`
and compare before calling them done.

### 2c. Decide the ENTRANCES. An app with one page is usually unfinished

Three entrances exist, and **only the ones written into `views` are real**:

| `audience` | address | who opens it |
|---|---|---|
| `public` | `/a/{slug}` | anybody the app admits |
| `member` | `/m/{slug}` | anybody holding a role in `members` |
| `participant` | `/p/{slug}` | anybody LISTED in `members`, seeing their own row |

**An app that collects records gets a `member` page, and you do not ask first.** Without one, the
only way to read what was collected is the collection pane on the author's own machine — so the
answers exist but nobody can reach them from a phone, and the author finds this out after handing
the link around. It is not a feature the author chose to skip; it is one nobody mentioned. Write
it, and say in their words what it is ("集まった回答はここで見られます").

This is the step that gets missed. The reason is worth knowing: an app built from a template
inherits that template's pages, while a form-shaped app is written from scratch — so the apps with
no member page were the ones nobody had a sample for. Every template now shows one, and
`templates/survey.md` is the one for a form.

**Whether an answerer can see their own answer back is a real question, and it is not "do you want
a participant page".** Ask **whether the people who answer need to see their own answer later**,
then pi
