docs: head off two member-app builder traps (reserved name, command args) (#407)
Two failure modes seen in generated apps, clarified in the authoring guide
the bot injects verbatim as its build prompt (/api/member-app-guide):
- Reserved envelope fields: the transport overwrites name/ts/uid with the
sender's identity, so an app that carries payload data in `name` folds it
back as the sender's own screen name (every "habit" ends up named after
you). Constraint 10 now names the symptom and points at owned fields.
- Command args: the handler always gets the raw arg string, never a parsed
object — there is no options schema. Models primed on Discord/Slack slash
commands were inventing a structured `{ args: {...} }` handler. The Commands
section now documents the { run, desc, args } object form (all display or
handler, no schema), shows it, and calls out the Discord/Slack mismatch.
Claude-Session: https://claude.ai/code/session_01FVfs5sfVYecDxQfX61Kzes
Co-authored-by: Claude <noreply@anthropic.com>
1 file changed,
+42
−4
docs/building-member-apps.md+42 −4
diff --git a/docs/building-member-apps.md b/docs/building-member-apps.md| index c5d9777..1c26eb3 100644 |
| --- a/docs/building-member-apps.md |
| +++ b/docs/building-member-apps.md |
| @@ -81,9 +81,15 @@ These fields still do **nothing** in a member app — do not emit them: |
| and `.` after the prefix. |
| 9. **Reducers are pure, immutable, and idempotent by entity id.** Folding the |
| same add twice must produce one item (the collection helpers guarantee it). |
| -10. **Never set `name`, `ts`, or `uid` on an emitted event** — the transport |
| - injects them; read them in your reducer (`e.ts` for times, never |
| - `Date.now()` in a fold). |
| +10. **Never carry app data in `name`, `ts`, or `uid`** — the transport |
| + **overwrites** these three with the sender's screen name, the send time, |
| + and the sender's user id (every event carries them, exactly like a chat |
| + message's author). Read them in your reducer (`e.ts` for times, never |
| + `Date.now()` in a fold); never set them. *Symptom if you ignore this:* an |
| + event that put its payload in `name` folds back with your **own screen |
| + name** in it — e.g. `{ type:"habit.add", name:"drink water" }` comes back as |
| + `name:"James"`, so every habit is named after you. Put app data in a field |
| + you own — `text`, `label`, `title`, `habit` — never `name`. |
| 11. **Validate events in the reducer, not at the emitter.** Malformed events |
| fold to a no-op, never a crash. |
| 12. **You never touch the server, keys, or tokens.** |
| @@ -109,7 +115,7 @@ export const app = defineApp({ /* fields below */ }); |
| | `initialState` | ✓ | `() => state`. A **factory**, called to seed the empty state. | |
| | `events` | ✓ | `{ "<type>": (state, event, meta) => state }`. The fold. | |
| | `render` | ✓ | `(state, el, ctx) => void`. Draws the whole surface. | |
| -| `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }`. Slash commands (see Commands). | |
| +| `commands` | | `{ "/cmd": (args, ctx) => void }` or `{ "/cmd": { run, desc, args } }`. `args` passed to the handler is the **raw text string** (not a parsed object); the object form's `desc`/`args` are typeahead labels. See Commands. | |
| | `defaultCommand` | | The name of one of your `commands` (e.g. `"/add"`). In the companion Chat view, plain (non-slash) text runs it — so typing "buy milk" adds an item, just like To-do. Requires that command; omit if not wanted. | |
| | `chatMessage` | | `(event) => { text, action? } \| string \| null`. Opting in makes the app a **companion** (Chat + Interface views); see Companion chat. | |
| | `styles` | | A CSS string, injected once. Use the theme variables below. | |
| @@ -218,6 +224,38 @@ commands: { |
| }, |
| ``` |
| +**The handler always receives `args` as a raw string** — everything typed after |
| +the command word, already trimmed (`"/habit drink water"` → `args` is |
| +`"drink water"`). There is **no structured or typed argument parsing**: no |
| +options schema, no `args.name`, no parsed object. If you want fields, split the |
| +string yourself (`const [first, ...rest] = args.split(/\s+/)`). This is |
| +deliberately simpler than Discord/Slack slash-command *options* — do not model |
| +those here, and never write a handler that expects a parsed argument object. |
| + |
| +The richer object form is **`{ run, desc, args }`**, and every field is display |
| +or handler — none of them is a schema: |
| + |
| +- `run(args, ctx)` — the handler, same signature as the bare function (`args` |
| + is still the raw string). |
| +- `desc` — a one-line description for the `/` typeahead. |
| +- `args` — a **usage-hint string** shown in the typeahead (e.g. `"<name>"`). It |
| + is a *label*, not an argument declaration; nothing parses it. |
| + |
| +```js |
| +commands: { |
| + "/habit": { |
| + desc: "Track a new habit", |
| + args: "<name>", // typeahead hint — a label, NOT a schema |
| + run: (args, ctx) => { // args is still the raw string |
| + const name = args.trim(); |
| + if (!name) return ctx.reply({ text: "Usage: /habit <name>" }); |
| + ctx.emit({ type: "habit.add", id: ctx.id(), text: name }); |
| + ctx.reply({ text: `Tracking “${name}”.` }); |
| + }, |
| + }, |
| +}, |
| +``` |
| + |
| Rules: reply with a usage line on bad input (never fail silently); the handler |
| gets the same `ctx` (emit/schedule/id/getState/me), plus `ctx.reply`. The reply |
| automatically gets an **Open <app>** button so the member can jump in — you |