eleven / messenger Public

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>
James Adam 34e2dc41c11d3c5cdbae647f1b8fe24394fe17d7 parent 75fa906
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