esoul-sdk 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/llms-full.txt ADDED
@@ -0,0 +1,934 @@
1
+ # esoul-sdk — the complete contract for building ExternalSoul apps (llms-full.txt)
2
+
3
+ Generated from README.md and docs/. Read it whole. Every rule carries the failure that earned it.
4
+
5
+ # esoul-sdk
6
+
7
+ Build **ExternalSoul apps**: native apps for an event-sourced workspace where people and AI
8
+ agents share one canvas. An app you write with this SDK is indistinguishable from the platform's
9
+ own once it ships — the same events, the same timeline, the same durability, the same tools that
10
+ chat, voice, agents and MCP call.
11
+
12
+ ```bash
13
+ npm install --save-dev esoul-sdk
14
+ ```
15
+
16
+ Inside ExternalSoul the package name resolves to the platform's real implementations. Outside it
17
+ (your editor, your tests) the package gives you the types, the manifest validator, and the test
18
+ helpers; the server functions throw "host only" if called, because they run in the platform.
19
+
20
+ ## Where to start
21
+
22
+ - **Build it in the Forge, not on your laptop.** Open a Forge board in your workspace and ask it
23
+ to open a workbench for your app. You get a cloud machine with the platform on it, a live
24
+ preview in your frame, your app's tools callable before it is installed, checks, and a submit
25
+ button. Read [docs/01-getting-started.md](docs/01-getting-started.md).
26
+ - **The contract, one page per part:**
27
+ 1. [Getting started](docs/01-getting-started.md) — the loop, the package layout
28
+ 2. [The manifest](docs/02-manifest.md) — `plugin.json`, every field
29
+ 3. [Events and state](docs/03-events-and-state.md) — the heart: dataCreator, processor, replay
30
+ 4. [Tools](docs/04-tools.md) — what agents call, on every surface
31
+ 5. [The UI](docs/05-ui.md) — React, hooks, theme, responsive rules
32
+ 6. [The server half](docs/06-server.md) — ops, webhooks, reading state, calling other apps
33
+ 7. [Background tasks](docs/07-background-tasks.md) — durable work, polling, the replay model
34
+ 8. [Connections and OAuth](docs/08-connections.md) — tokens the platform holds for you
35
+ 9. [Files](docs/09-files.md) — workspace files, Drive, your own provider
36
+ 10. [Testing](docs/10-testing.md) — the fold contract as tests
37
+ 11. [Shipping](docs/11-shipping.md) — submit, review, release, install; the import wall
38
+ 12. [Rules and failures](docs/12-rules.md) — every rule with the failure that earned it
39
+ - **For a coding model:** `llms.txt` (short) and `llms-full.txt` (the whole contract in one file).
40
+
41
+ ## The one rule that explains the others
42
+
43
+ **Events are the truth.** Your app's state is the fold of its events, re-run on every replay,
44
+ scrub and sync. So a reducer must be pure and idempotent, ids and timestamps are minted in the
45
+ `dataCreator` (never in a reducer), whole-replace events carry a collapse key, and the agent-facing
46
+ state description never claims something it could not read. Everything in the docs follows from
47
+ that.
48
+
49
+ ## What is in the package
50
+
51
+ | Entry | What it gives you |
52
+ |---|---|
53
+ | `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `incompleteStateNotice`, `deterministicReducerId`, `stableStringify`, `timingSafeEqual`, `nanoid`, `callPluginOp`, the manifest schema |
54
+ | `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, file hooks |
55
+ | `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
56
+ | `esoul-sdk/testing` | a mock OAuth server for connection tests |
57
+ | `esoul-app validate <dir>` | validates a package folder against the manifest schema |
58
+
59
+ ## Versions
60
+
61
+ - **0.3.0** — renamed from `@externalsoul/plugin-sdk` (still resolved as an alias inside the
62
+ platform). `readAppState`, `callWorkspaceTool`, `nanoid` on the index. The import wall: an app
63
+ reaches the platform only through this package. Docs rewritten for the Forge workbench loop.
64
+ - 0.2.0 — file sources and providers.
65
+ - 0.1.0 — the contract: manifest, schema, events, tools, tasks, webhooks, ops, connections.
66
+
67
+
68
+
69
+ ==============================================================================
70
+ # 1. Getting started
71
+
72
+ ## What an app is
73
+
74
+ An ExternalSoul app is a folder. Compiled into the platform, it becomes a native app: it renders
75
+ in the same frame as the spreadsheet next to it, its state is a fold of typed events on the
76
+ workspace timeline, and its tools are callable from chat, voice, the agent builder and MCP. There
77
+ is no dynamic loading and no sandbox at runtime: the review of your submission is the trust
78
+ boundary, and this SDK plus the platform's checks make your package correct on arrival.
79
+
80
+ ## The loop
81
+
82
+ You do not need the repository, a local checkout, or a running platform.
83
+
84
+ 1. In your ExternalSoul workspace, add a **Forge** board.
85
+ 2. Tell the assistant (or your own Claude over MCP) to **open a workbench** for your app. The
86
+ platform boots a cloud machine holding the platform's source, scaffolds your app folder from
87
+ the template, and shows a **live preview** in the board's frame.
88
+ 3. Write the files with the board's tools (`write_app_file`, `edit_app_file`). Every change
89
+ hot-reloads into the preview and comes back with the preview's health, the compiler's own
90
+ words if you broke it.
91
+ 4. **Look** at it (`look_at_app`: desktop and phone, light and dark), **call its tools** before it
92
+ is installed (`call_app_tool`, the events land in the frame), **read its state**
93
+ (`read_app_state`), **run its tests** (`test_app`, seconds) and **the full checks**
94
+ (`check_app`: registry sync, the platform suites, types, the import wall).
95
+ 5. **Submit** (`ship_app`). The owner reviews the diff, approves, and releases it with one
96
+ script. The board then says "released — add it to a workspace".
97
+ 6. Add it to a workspace like any other app. Its tools are live everywhere.
98
+
99
+ From your own editor, `npm install --save-dev esoul-sdk` gives you the same types and the
100
+ manifest validator, and lets you write tests locally. The code still runs in the platform.
101
+
102
+ ## Package layout
103
+
104
+ ```
105
+ src/plugins/<id>/
106
+ plugin.json the manifest (docs/02)
107
+ app.tsx the SCHEMA — exports `pluginSchema`; never "use client" (docs/03, 04)
108
+ ui/<id>-ui.tsx the React UI — "use client" (docs/05)
109
+ server.ts only if you declare ops or webhooks (docs/06)
110
+ <id>.test.ts the fold contract as tests (docs/10)
111
+ <id>-ui.test.tsx renders the UI once, checks the empty state
112
+ package.json only if you need an npm package the platform lacks (docs/11)
113
+ ```
114
+
115
+ The folder name is the app id: lower-case, hyphens. The application type is `plugin_` plus the
116
+ id with underscores (`sticky-notes` → `plugin_sticky_notes`). Neither changes after shipping.
117
+
118
+ ## The smallest complete app
119
+
120
+ ```ts
121
+ // app.tsx
122
+ import { nanoid, EventTypes, incompleteStateNotice, type ApplicationIdentifier,
123
+ type ApplicationSchema, type EventData, type EventDefinition, type ApplicationPort } from "esoul-sdk";
124
+ import { z } from "zod";
125
+ import { NotesUi } from "./ui/notes-ui";
126
+
127
+ export interface NotesData extends ApplicationIdentifier {
128
+ notes: { id: string; text: string; at: number }[];
129
+ }
130
+
131
+ export const notedEvent: EventDefinition<NotesData> = {
132
+ eventName: "plugin_notes_noted",
133
+ type: EventTypes.Client,
134
+ dataCreator: (args) => ({
135
+ eventName: "plugin_notes_noted",
136
+ eventData: { id: args.id ?? nanoid(), text: String(args.text ?? ""), at: args.at ?? Date.now() },
137
+ timestamp: Date.now(),
138
+ workspaceId: args.workspaceId,
139
+ applicationId: args.applicationId || args.nodeId,
140
+ instanceName: args.instanceName,
141
+ chatIdSource: args.chatIdSource,
142
+ }) as EventData<any>,
143
+ processor: (state, event) => {
144
+ const { id, text, at } = event.eventData ?? {};
145
+ if (typeof id !== "string" || typeof text !== "string" || !text.trim()) return state;
146
+ if (state.notes.some((n) => n.id === id)) return state; // replay-safe
147
+ return { ...state, notes: [...state.notes, { id, text: text.trim(), at: typeof at === "number" ? at : 0 }] };
148
+ },
149
+ };
150
+
151
+ export const pluginSchema: ApplicationSchema<NotesData> = {
152
+ applicationType: "plugin_notes",
153
+ description: "Notes on the timeline.",
154
+ reactNode: NotesUi,
155
+ reconstructStateFromEventLog: true,
156
+ events: [notedEvent],
157
+ getPorts: (): ApplicationPort[] => [],
158
+ stateCreator: (identifier) => ({ ...identifier, notes: [] }),
159
+ toolkitCreator: (identifier, forChatId, eventCallback) => {
160
+ const base = identifier.instanceName.replace(/[^a-zA-Z0-9]/g, "_");
161
+ const tools = {
162
+ [`add_note_${base}`]: {
163
+ description: `Add a note to "${identifier.instanceName}". Returns its id.`,
164
+ parameters: z.object({ text: z.string().min(1) }),
165
+ execute: async ({ text }: { text: string }) => {
166
+ const id = nanoid();
167
+ await eventCallback(notedEvent.dataCreator({ ...identifier, applicationId: identifier.nodeId, chatIdSource: forChatId, id, text }));
168
+ return `Added note ${id}.`;
169
+ },
170
+ },
171
+ };
172
+ for (const t of Object.values(tools)) (t as any).onClient = (t as any).execute; // every surface
173
+ return tools;
174
+ },
175
+ getStateDescription: (state) => {
176
+ const notLoaded = incompleteStateNotice({ title: "Notes", instanceName: state?.instanceName, shape: { lists: { notes: state?.notes } } });
177
+ if (notLoaded) return notLoaded;
178
+ return [`## Notes — "${state.instanceName}"`, ...(state.notes.length ? state.notes.slice(-10).map((n) => `- ${n.text} \`id:${n.id}\``) : ["- No notes yet."])].join("\n");
179
+ },
180
+ };
181
+ ```
182
+
183
+ Every line of that is explained in the next pages. The workbench scaffolds you an equivalent.
184
+
185
+
186
+
187
+ ==============================================================================
188
+ # 2. The manifest — `plugin.json`
189
+
190
+ Validated by `esoul-app validate <dir>`, by the workbench's checks and at release. The JSON
191
+ schema is shipped at `schemas/plugin.schema.json`.
192
+
193
+ ```json
194
+ {
195
+ "manifestVersion": 1,
196
+ "id": "sticky-notes",
197
+ "name": "Sticky notes",
198
+ "version": "0.1.0",
199
+ "description": "A wall of paper notes: pin, colour, drag, and let agents add to it.",
200
+ "applicationType": "plugin_sticky_notes",
201
+ "entry": "app",
202
+ "icon": "StickyNote",
203
+ "author": { "name": "Sticky notes", "url": "esoul:user:<your id>" },
204
+
205
+ "ops": ["read-notes"],
206
+ "webhooks": ["inbound"],
207
+ "kickableTasks": ["sync"],
208
+ "pollTasks": [{ "task": "refresh", "everyMinutes": 30 }],
209
+ "workspaceTools": ["spreadsheet:add_row", "my_computer:claude_task"],
210
+ "connections": [{ "key": "github", "kind": "oauth2", "label": "GitHub",
211
+ "authorizeUrl": "https://github.com/login/oauth/authorize", "tokenUrl": "https://github.com/login/oauth/access_token",
212
+ "oauthScopes": ["repo"], "clientIdEnv": "GITHUB_CLIENT_ID", "clientSecretEnv": "GITHUB_CLIENT_SECRET" }],
213
+ "fileSources": { "workspace": "read", "providers": ["google-drive"] },
214
+ "platformApi": { "min": "1.1.0" }
215
+ }
216
+ ```
217
+
218
+ | Field | Rule |
219
+ |---|---|
220
+ | `id` | lower-case, hyphens; equals the folder name; **never changes** after shipping |
221
+ | `name` | what people see in the picker (≤ 80) |
222
+ | `version` | semver; bump on every submission |
223
+ | `description` | one honest paragraph (≤ 500) |
224
+ | `applicationType` | `plugin_` + id with underscores; the platform tells plugins from built-ins by the prefix; **never changes** |
225
+ | `entry` | the schema module without extension (`app`) |
226
+ | `icon` | a lucide-react name from the curated set (the workbench lists them when yours is wrong); missing → the generic glyph, never a broken tile |
227
+ | `author` | stamped by the workbench at scaffold: `url` is `esoul:user:<id>`, and it is what the release script and the id-collision check read. No email — a manifest may become public |
228
+ | `ops` | server operations exported by `server.ts` (docs/06) |
229
+ | `webhooks` | inbound routes `POST /api/plugins/<id>/webhook/<hook>` served by `server.ts` (docs/06) |
230
+ | `kickableTasks` | task names the **browser** may kick (docs/07) |
231
+ | `pollTasks` | tasks the platform kicks on a cadence, 5-minute granularity, minimum 5 (docs/07) |
232
+ | `workspaceTools` | the grant wall for calling OTHER apps' tools — `<applicationType>:<tool base name>`; absent = no cross-app access (docs/04, 06) |
233
+ | `connections` | OAuth2 / API-key connections the user grants once and the platform holds sealed; **names of env vars, never values** (docs/08) |
234
+ | `fileSources` / `fileProviders` | consent to read workspace files and providers; providers you contribute (docs/09) |
235
+ | `platformApi` | refuse install outside `[min, max]` of the platform contract version |
236
+ | `scopes` | reserved |
237
+
238
+ Secrets never live in a manifest, and a `package.json` in the folder declares npm dependencies
239
+ the platform does not have yet (docs/11).
240
+
241
+
242
+
243
+ ==============================================================================
244
+ # 3. Events and state — the heart of the contract
245
+
246
+ ## The model
247
+
248
+ An app's state is **the fold of its events**. The platform stores the events (a per-workspace,
249
+ per-app log with a sequence number) and derives the state by running your processors over them:
250
+ on the client as a person taps, on the server as the canonical fold, again on every replay,
251
+ scrub, snapshot, cross-device sync and share redaction. There is no other state. That is what
252
+ makes the timeline scrubbable and an agent's action indistinguishable from a person's.
253
+
254
+ So three things must hold, and the checks and the review look for them:
255
+
256
+ 1. **A processor is pure and idempotent.** Same input, same output, on every replay; the same
257
+ event applied twice leaves the state as after once.
258
+ 2. **Ids and timestamps are minted in the `dataCreator`, never in a processor.** A processor that
259
+ calls `nanoid()` or `Date.now()` folds differently every replay and breaks time-scrubbing.
260
+ 3. **A processor refuses what it cannot trust** — returns `state` unchanged rather than throw.
261
+ Malformed payloads exist (a retried webhook, an old client), and a throw in a fold takes the
262
+ whole workspace view down.
263
+
264
+ ## Anatomy of an event
265
+
266
+ ```ts
267
+ export const noteTextSetEvent: EventDefinition<StickyNotesData> = {
268
+ eventName: "plugin_sticky_notes_text_set", // globally unique; prefix with your type
269
+ type: EventTypes.Client, // Client = dispatched by UI/tools; Server = from tasks/webhooks
270
+ dataCreator: (args) => ({ // MINTS the envelope. Called by the UI and by tools.
271
+ eventName: "plugin_sticky_notes_text_set",
272
+ eventData: { noteId: args.noteId, text: String(args.text ?? "").slice(0, 2000), at: args.at ?? Date.now() },
273
+ timestamp: Date.now(),
274
+ workspaceId: args.workspaceId,
275
+ applicationId: args.applicationId || args.nodeId,
276
+ instanceName: args.instanceName,
277
+ chatIdSource: args.chatIdSource,
278
+ }) as EventData<any>,
279
+ processor: (state, event) => { // PURE. The fold.
280
+ const d = event.eventData ?? {};
281
+ if (typeof d.noteId !== "string" || typeof d.text !== "string") return state;
282
+ const i = state.notes.findIndex((n) => n.id === d.noteId);
283
+ if (i === -1) return state; // an unknown id is ignored, never invented
284
+ const next = [...state.notes]; next[i] = { ...next[i], text: d.text, updatedAt: d.at };
285
+ return { ...state, notes: next };
286
+ },
287
+ // One row on the timeline per typing burst, not one per keystroke (see below).
288
+ collapseConfig: {
289
+ collapseKeyFn: (eventData, ctx) => `${ctx.applicationId}:${(eventData as { noteId?: string }).noteId}:text`,
290
+ collapseWindowMs: 2500,
291
+ },
292
+ };
293
+ ```
294
+
295
+ `stateCreator(identifier)` returns the empty state — always including the identifier fields
296
+ (`{ ...identifier, notes: [] }`), because tools and describers read `instanceName` from it.
297
+
298
+ ## Collapse keys: bursts are one event
299
+
300
+ A person typing, dragging or resizing produces a burst. Without a collapse key that is a hundred
301
+ timeline rows and a scrub that steps through every keystroke. `collapseConfig` merges consecutive
302
+ events with the same key inside the window into one. Key by the **entity and the field**
303
+ (`<app>:<noteId>:text`), never by the app alone: two notes edited in the same window must not
304
+ merge. Whole-replace events (an editor's content, a canvas snapshot) **must** carry a collapse key.
305
+
306
+ ## What is local and what is an event
307
+
308
+ What the workspace remembers goes through events. What is honestly local to one device — a drag
309
+ in progress, text still being typed, which tab is open — stays in component state and lands as
310
+ ONE event when done. Never mirror app state into a second store; never write derived data the
311
+ fold could recompute.
312
+
313
+ ## Bounds
314
+
315
+ Cap collections in the processor (`if (state.notes.length >= 300) return state;`) and cap
316
+ strings in the `dataCreator`. An app with an unbounded array is an app that one day cannot be
317
+ folded.
318
+
319
+ ## `reconstructStateFromEventLog: true`
320
+
321
+ Set it. It tells the platform your state IS the fold, which is what enables replay-on-load,
322
+ snapshot baselines and the durability layers. Every shipped plugin and the scaffold set it.
323
+
324
+ ## Server-typed events
325
+
326
+ `EventTypes.Server` marks events that only server code emits (a task, a webhook). The UI must
327
+ not dispatch them, and the processor still obeys the three rules.
328
+
329
+ ## Durability you get for free
330
+
331
+ Because your events flow through the platform's dispatch chokepoint, an installed app inherits
332
+ the write-ahead log with retry, session stamping, the stale-base write fence, the pagehide flush
333
+ that walks every open app, server-side deduplication of re-sent events, one canonical server fold
334
+ and the monotonic merge guard. You do nothing for this — except keep the three rules, because
335
+ every one of those layers re-runs your processors.
336
+
337
+
338
+
339
+ ==============================================================================
340
+ # 4. Tools — what agents call
341
+
342
+ A tool is how chat, voice, the agent builder, MCP, a WebMCP page, and other apps' server code act
343
+ on your app. **Every UI action has a tool twin**: if a person can do it by hand, an agent can do
344
+ it by tool, through the same event. The platform mints your tools per instance as
345
+ `<verb>_<instance name>` (spaces and punctuation → underscores).
346
+
347
+ ```ts
348
+ toolkitCreator: (identifier, forChatId, eventCallback) => {
349
+ const base = identifier.instanceName.replace(/[^a-zA-Z0-9]/g, "_");
350
+ const idArgs = { ...identifier, applicationId: identifier.nodeId, chatIdSource: forChatId };
351
+ const tools = {
352
+ [`add_note_${base}`]: {
353
+ description: `Pin a new sticky note on "${identifier.instanceName}". Returns the note's id.`,
354
+ parameters: z.object({ text: z.string().min(1).max(2000), color: z.enum(["butter", "rose", "mint", "sky", "lilac"]).optional() }),
355
+ execute: async (args) => {
356
+ const noteId = nanoid();
357
+ await eventCallback(noteAddedEvent.dataCreator({ ...idArgs, noteId, ...args }));
358
+ return `Pinned note ${noteId}.`;
359
+ },
360
+ },
361
+ [`read_notes_${base}`]: {
362
+ description: `Read every note on "${identifier.instanceName}" with the ids the other tools take.`,
363
+ parameters: z.object({}),
364
+ readOnly: true,
365
+ publicSafe: true,
366
+ execute: async () => {
367
+ const { callPluginOp } = await import("esoul-sdk");
368
+ const s = await callPluginOp<Pick<StickyNotesData, "notes">>("sticky-notes", "read-notes", identifier.nodeId);
369
+ return describeNotes(s);
370
+ },
371
+ },
372
+ };
373
+ // Browser surfaces (voice, WebMCP) run `onClient`. Same work — never an empty stub:
374
+ // the voice runtime reports a tool that returns nothing as a SUCCESS.
375
+ for (const t of Object.values(tools)) (t as any).onClient = (t as any).execute;
376
+ return tools;
377
+ },
378
+ ```
379
+
380
+ ## The two surfaces
381
+
382
+ - **`execute`** runs on the server: chat, agents, MCP, the workbench's `call_app_tool`.
383
+ - **`onClient`** runs in the browser: WebRTC voice, WebMCP. Events dispatch there too, and
384
+ `callPluginOp` rides the session. Setting `onClient = execute` for every tool is the simplest
385
+ correct form; the checks refuse an empty `onClient: () => {}` stub because it makes an agent
386
+ claim work it never did.
387
+
388
+ ## Flags
389
+
390
+ - `readOnly: true` — a read-scoped token may call it.
391
+ - `publicSafe: true` — safe to expose on a public storefront (no writes, no private data).
392
+
393
+ ## Honest results
394
+
395
+ A tool result is what the model believes happened. Return what you **did**, and refuse what you
396
+ could not do: an update to an id that is not on the wall says "No note X — nothing changed", not
397
+ "updated". When a check needs server truth (an op) and it is unavailable, say so
398
+ ("could not verify the id here"). Name what the caller should do next; say where ids come from.
399
+
400
+ ## Server truth from a tool
401
+
402
+ A tool that must read the real state (not the caller's cache) calls a **plugin op** through
403
+ `callPluginOp(pluginId, op, nodeId, args)` — never `fetch("/api/v1/…")` itself (token-gated, it
404
+ refuses the tool). Ops are yours to write (docs/06). In the workbench there is no database behind
405
+ the preview, so such a tool is refused with a message naming `read_app_state`; that is expected.
406
+
407
+ ## Calling other apps
408
+
409
+ From the UI: `useWorkspaceTools()` (docs/05). From server code: `callWorkspaceTool` (docs/06).
410
+ Both are gated by the manifest's `workspaceTools` grants, `"<applicationType>:<tool base>"`, and
411
+ both stay inside the app's own workspace.
412
+
413
+ ## Describing the app to agents
414
+
415
+ `getStateDescription(state)` is what an agent reads about your app every turn. Compact, truthful,
416
+ with ids. And **never claim a count or an emptiness you could not read**:
417
+
418
+ ```ts
419
+ getStateDescription: (state) => {
420
+ const notLoaded = incompleteStateNotice({ title: "Sticky notes", instanceName: state?.instanceName, shape: { lists: { notes: state?.notes } } });
421
+ if (notLoaded) return notLoaded; // a MISSING collection means the state did not load; [] is real
422
+ return describeNotes(state);
423
+ },
424
+ ```
425
+
426
+
427
+
428
+ ==============================================================================
429
+ # 5. The UI
430
+
431
+ ```tsx
432
+ "use client";
433
+ import React from "react";
434
+ import { useAppCanEdit, usePluginEventDispatch, useWorkspaceTools } from "esoul-sdk/react";
435
+ import { noteAddedEvent, type StickyNotesData } from "../app";
436
+
437
+ export function StickyNotesUi({ state }: { state: StickyNotesData }) {
438
+ const dispatch = usePluginEventDispatch(); // the ONLY way to change state
439
+ const canEdit = useAppCanEdit(); // readers of a share see the app and cannot mutate
440
+ const tools = useWorkspaceTools(state); // other apps' tools, per the manifest's grants
441
+ // …
442
+ }
443
+ ```
444
+
445
+ - Props are `{ state }`: the folded state, live. You never fetch it.
446
+ - `dispatch(noteAddedEvent.dataCreator({ …identifier fields from state, …args }))` is a write.
447
+ Disable writes when `!canEdit`.
448
+ - The schema module (`app.tsx`) is **never** `"use client"`; the UI module is. The schema imports
449
+ the UI, not the other way around (it closes a module cycle).
450
+
451
+ ## Theme
452
+
453
+ Read the `.dark` class on `<html>`, and **observe it** (the person toggles at runtime):
454
+
455
+ ```ts
456
+ function useIsDark() {
457
+ const [dark, setDark] = React.useState(false);
458
+ React.useEffect(() => {
459
+ const read = () => setDark(document.documentElement.classList.contains("dark"));
460
+ read();
461
+ const mo = new MutationObserver(read);
462
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
463
+ return () => mo.disconnect();
464
+ }, []);
465
+ return dark;
466
+ }
467
+ ```
468
+
469
+ Keep one palette object per app with `LIGHT` and `DARK` of the same shape. Light is warm sepia
470
+ (`#faf6f0` / `#efe7d8` / `#e8dfd0`, brown text). Dark is translucent data surfaces over the frame's
471
+ gradient, with editors, dialogs, popovers and sticky headers **opaque**.
472
+
473
+ ## Responsive
474
+
475
+ The app renders inside the platform frame at any size: a desktop window, a maximised canvas, a
476
+ 390 px phone.
477
+
478
+ - The root fills its frame: `width:100%; height:100%; overflow:hidden`, a flex column.
479
+ - Chrome rows `flexShrink:0`; the content area `flex:1; minHeight:0` with its own scroll.
480
+ - Measure the VISIBLE scroller for layout decisions (column counts), never a large inner canvas;
481
+ re-measure on resize.
482
+ - Toolbars wrap; labels collapse on narrow widths; nothing needs a horizontal page scroll.
483
+ - Touch is first-class: tap to act (never double-tap), `touch-action: manipulation`, larger hit
484
+ targets on `(pointer: coarse)`.
485
+ - Custom clickables are real buttons: `role="button"`, `tabIndex={0}`, Enter and Space,
486
+ `aria-label` on icon-only controls. Distinct icons for distinct actions. Status is never
487
+ colour-only.
488
+ - Popovers and menus portal to `document.body` with a solid backing.
489
+ - An empty state that says what to do first, in the app's own voice.
490
+
491
+ `look_at_app` in the workbench screenshots desktop-light, desktop-dark and phone-light. Open the
492
+ images; a phone shot with a horizontal scrollbar is a bug.
493
+
494
+ ## Cross-app from the UI
495
+
496
+ ```ts
497
+ const { call, listApps, available } = useWorkspaceTools(state);
498
+ await call({ appType: "spreadsheet", tool: "add_row", args: { cells: { When: new Date().toISOString() } } });
499
+ ```
500
+
501
+ In the workbench this works through the board's own tab, so you can test it before install;
502
+ installed, only the grants in `plugin.json` `workspaceTools` are allowed. Declare each as
503
+ `"<applicationType>:<tool base name>"`.
504
+
505
+ ## Files
506
+
507
+ `usePluginWorkspaceFiles()`, `usePluginFileUpload()`, `useFileSources()`, `useFileSourceEntries()`
508
+ — docs/09.
509
+
510
+ ## Character
511
+
512
+ The platform is built as craft: a sticky-notes wall is linen and paper with a gummed strip, not a
513
+ grid of yellow rectangles. Derive any randomness from stable ids, never `Math.random()` in
514
+ render, so nothing twitches between renders. Motion is brief and respects reduced-motion.
515
+
516
+
517
+
518
+ ==============================================================================
519
+ # 6. The server half — `server.ts`
520
+
521
+ Only if you declare `ops`, `webhooks` or `fileProviders`. Marked `"server-only"`, it never
522
+ reaches the client graph.
523
+
524
+ ```ts
525
+ import "server-only";
526
+ import { readAppState, callWorkspaceTool, emitPluginAppEvent, type PluginOpContext, type PluginServerModule, type PluginWebhookContext } from "esoul-sdk/server";
527
+
528
+ async function readNotes(ctx: PluginOpContext) {
529
+ const app = await readAppState(ctx.nodeId); // folded to head; null = no such app
530
+ if (!app) throw new Error(`no app ${ctx.nodeId}`);
531
+ const s = app.state as Partial<StickyNotesData>;
532
+ if (!Array.isArray(s.notes)) throw new Error("the wall did not fold (notes missing)"); // MISSING ≠ empty
533
+ return { notes: s.notes };
534
+ }
535
+
536
+ export const pluginServer: PluginServerModule = {
537
+ ops: { "read-notes": readNotes },
538
+ webhooks: { inbound: async (ctx: PluginWebhookContext) => { /* verify, validate, kick a task, ACK */ } },
539
+ };
540
+ ```
541
+
542
+ ## Ops — server truth for a tool
543
+
544
+ `POST /api/plugins/<id>/op/<name>` with `{ nodeId, args }`. The route checks the caller's
545
+ workspace write access and that `nodeId` is an instance of THIS plugin's type, then calls your
546
+ handler with `ctx = { nodeId, workspaceId, args, cloudConnectionId, … }`. A tool reaches it through
547
+ `callPluginOp` (docs/04). Ops are for reads that must be true now and for actions that need a
548
+ credential; they must be idempotent (a tool call can be retried).
549
+
550
+ ## Webhooks — push in
551
+
552
+ `POST /api/plugins/<id>/webhook/<hook>`. **Verify first** (a shared secret with
553
+ `timingSafeEqual`), validate the body, then **ACK and hand the work to a task** through
554
+ `ctx.sendInngestEvent("<applicationType>/<task>", payload)`. Never do the work in the webhook: a
555
+ sender retries, a request has a time limit, and a task is durable. Give every push an
556
+ idempotency key and derive the event's id from it (`deterministicReducerId`), so a redelivery
557
+ folds to one item. Fail CLOSED when no secret is configured.
558
+
559
+ ## `readAppState(nodeId)`
560
+
561
+ One app, folded to head: `{ nodeId, workspaceId, applicationType, foldedSeq, state }`, or `null`
562
+ when absent. Use it for your own instance and, with care, for other apps in the same workspace.
563
+ Never a raw database read — the import wall refuses `prisma` and the state column lags the log.
564
+
565
+ ## `callWorkspaceTool` — orchestrate other apps
566
+
567
+ ```ts
568
+ const r = await callWorkspaceTool({
569
+ pluginId: "research-graph", nodeId: ctx.nodeId,
570
+ appType: "my_computer", tool: "claude_task",
571
+ args: { prompt: "Summarise the three papers in ~/reading", cwd: "~/reading" },
572
+ });
573
+ // r = { ok, text }
574
+ ```
575
+
576
+ Gated exactly like the UI: the manifest must declare `"my_computer:claude_task"` in
577
+ `workspaceTools`, and the target must live in the calling app's own workspace. Name a specific
578
+ instance with `targetNodeId` instead of `appType`. This is how an app runs a `my_computer`
579
+ (commands, Claude Code sessions, results), fills a spreadsheet from a job, or books a calendar.
580
+
581
+ ## `emitPluginAppEvent`
582
+
583
+ Emit ANOTHER app's own events (its `dataCreator` shape) into the same workspace, actor-stamped as
584
+ your plugin — for mirroring into a native app whose processors you cannot call directly.
585
+ Same-workspace only.
586
+
587
+ ## Connections in server code
588
+
589
+ `ctx.cloudConnectionId` is the instance's bound connection; resolve it with
590
+ `getPluginConnectionCredentials(connectionId, pluginId)` (docs/08).
591
+
592
+ ## What server code may import
593
+
594
+ Only `esoul-sdk` / `esoul-sdk/server`, relative files, `server-only`, and npm packages the
595
+ platform already depends on. No `prisma`, no `node:*`, no `next`, no internals — the import wall
596
+ (docs/11) refuses them, and a reviewer relies on that.
597
+
598
+
599
+
600
+ ==============================================================================
601
+ # 7. Background tasks — durable work
602
+
603
+ Tasks are declared on the schema and run on the platform's durable executor (Inngest). Each task
604
+ becomes an event `<applicationType>/<taskName>`.
605
+
606
+ ```ts
607
+ tasks: [
608
+ {
609
+ taskName: "ingest",
610
+ description: "Fold one pushed item — idempotent by pushId.",
611
+ concurrency: { limit: 1, scope: "per-app" },
612
+ handler: async (ctx) => {
613
+ await ctx.step.run("fold-pushed-item", async () => {
614
+ const { text, pushId } = ctx.eventData ?? {};
615
+ if (typeof text !== "string" || typeof pushId !== "string") return;
616
+ await ctx.dispatchEvent("plugin_todo_add_item", { itemId: `push-${pushId}`, text: text.trim(), createdAt: Date.now() });
617
+ });
618
+ },
619
+ },
620
+ ],
621
+ ```
622
+
623
+ ## The context
624
+
625
+ - `ctx.identifier` — workspaceId, nodeId, applicationType, instanceName.
626
+ - `ctx.eventData` — the kick's payload.
627
+ - `ctx.step` — the durable step API; `await ctx.step.run("name", fn)` memoises `fn`'s result.
628
+ - `ctx.getState()` — the app's state, fresh.
629
+ - `ctx.dispatchEvent(eventName, eventData)` — through the full pipeline (append, fold, watermark),
630
+ using YOUR processors, so a task's mutation is identical to a tap's.
631
+ - `ctx.notify(topic, data)` — a realtime nudge to the app's channel; the UI refetches.
632
+ - `ctx.logger`.
633
+
634
+ ## The replay model — read this twice
635
+
636
+ The executor **re-runs your handler from the top** after every step boundary, replacing each
637
+ completed `step.run` with its cached result. Anything outside a `step.run` runs again on every
638
+ replay. So: **every side effect lives inside a `step.run`** — a dispatch, a fetch, an emit — or
639
+ it fires N+1 times. `nanoid()` and `Date.now()` outside a step produce different values per
640
+ replay; mint them inside. Step names are unique per logical operation; loops include the index.
641
+
642
+ ## How a task gets kicked
643
+
644
+ - **From a webhook**: `ctx.sendInngestEvent("<applicationType>/<task>", payload)` (docs/06).
645
+ - **From the browser**: list the task in `kickableTasks`; the UI posts the event through the
646
+ platform's send-event route, which allows only listed tasks.
647
+ - **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
648
+ live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
649
+ no per-app Inngest functions: function ids are fixed at module load, and the plan caps
650
+ concurrency; a shared sweep costs nothing per app. Handlers must be idempotent anyway, because
651
+ sweeps and pushes overlap by design.
652
+
653
+ ## Concurrency
654
+
655
+ `concurrency: { limit, scope: "per-app" | "global" }`. Keep the limit small; a parked wait holds
656
+ no slot.
657
+
658
+ ## Testing tasks
659
+
660
+ Unit-test the handler with a fake `ctx` (`step.run` that just calls the function, a recording
661
+ `dispatchEvent`), and assert idempotency by running it twice with the same `eventData` (docs/10).
662
+
663
+
664
+
665
+ ==============================================================================
666
+ # 8. Connections and OAuth — tokens the platform holds for you
667
+
668
+ An app that talks to an outside service declares the connection; the person grants it once in
669
+ Account settings; the platform stores the tokens sealed and refreshes them; your code asks for a
670
+ credential when it needs one. **Secrets never live in your package**: the manifest names
671
+ environment variables, not values.
672
+
673
+ ```json
674
+ "connections": [{
675
+ "key": "github", "kind": "oauth2", "label": "GitHub",
676
+ "authorizeUrl": "https://github.com/login/oauth/authorize",
677
+ "tokenUrl": "https://github.com/login/oauth/access_token",
678
+ "oauthScopes": ["repo"],
679
+ "clientIdEnv": "GITHUB_CLIENT_ID", "clientSecretEnv": "GITHUB_CLIENT_SECRET"
680
+ }]
681
+ ```
682
+
683
+ `kind: "apiKey"` instead declares `headerNames` — the header names the app will send; the person
684
+ pastes the key once.
685
+
686
+ ## Using a credential
687
+
688
+ ```ts
689
+ import { getPluginConnectionCredentials } from "esoul-sdk/server";
690
+
691
+ async function listRepos(ctx: PluginOpContext) {
692
+ if (!ctx.cloudConnectionId) throw new Error("connect GitHub in Account settings first");
693
+ const cred = await getPluginConnectionCredentials(ctx.cloudConnectionId, "my-app");
694
+ if (cred.kind !== "oauth2") throw new Error("expected an OAuth connection");
695
+ const r = await fetch("https://api.github.com/user/repos", { headers: { Authorization: `Bearer ${cred.accessToken}` } });
696
+ // …
697
+ }
698
+ ```
699
+
700
+ The platform unseals, checks expiry and refreshes with the stored refresh token before handing
701
+ you `accessToken`. Never log it, never put it in an event, never return it from a tool.
702
+
703
+ An instance is **bound** to one connection (`ctx.cloudConnectionId`); a person with two GitHub
704
+ accounts adds two instances. File providers you contribute receive `ctx.connectionId` per active
705
+ connection (docs/09).
706
+
707
+ ## The platform's own connections
708
+
709
+ The platform already holds the person's Google connection for its mail and calendar apps. Reusing
710
+ it from an app ("the mail app needs the Google tokens") is designed and not yet built: the
711
+ manifest would declare `platformConnections` with scopes, the person would consent per app, and
712
+ the SDK would hand out a token scoped to those scopes. Until then an app declares its own Google
713
+ OAuth connection with the platform's client id and secret named as env vars, and the person
714
+ grants it once more.
715
+
716
+
717
+
718
+ ==============================================================================
719
+ # 9. Files — workspace files, Drive, your own provider
720
+
721
+ Files are a consented surface. The manifest declares what the app may read:
722
+
723
+ ```json
724
+ "fileSources": { "workspace": "read", "providers": ["google-drive"] }
725
+ ```
726
+
727
+ - `workspace: "read" | "readwrite"` — the workspace's own files.
728
+ - `providers` — provider keys the app may browse (the person's connected sources).
729
+ - Absent = no file access at all. The person sees the declaration at install.
730
+
731
+ ## In the UI
732
+
733
+ `usePluginWorkspaceFiles()` (the workspace's files, live), `usePluginFileUpload()` (upload as the
734
+ person), `useFileSources(workspaceId)` and `useFileSourceEntries(source, path)` for browsing
735
+ sources with entries.
736
+
737
+ ## In server code
738
+
739
+ `pluginFiles(ctx)` / `filesForOp(ctx)` give a `FilesApi`: list a source, read a file (capped at
740
+ `FILE_READ_CAP_BYTES`), resolve a `FileRef`. Source ids are `"workspace"`, `"google-drive"`,
741
+ `"local:<mountId>"`, or your own provider's key; `parseSourceId` splits them.
742
+
743
+ ## Contributing a provider
744
+
745
+ ```json
746
+ "fileProviders": [{ "key": "dropbox", "connectionKey": "dropbox", "label": "Dropbox" }]
747
+ ```
748
+
749
+ `server.ts` exports `fileProviders: { dropbox: impl }` where `impl` implements
750
+ `PluginFileProviderImpl` (`list`, `read`, …). The host derives one mount per ACTIVE connection
751
+ of `connectionKey` and calls you with `ctx.connectionId` set; resolve the credential with
752
+ `getPluginConnectionCredentials`. Reads are capped; errors are typed (`FileSourceError`,
753
+ `FileSourceErrorKind`) so the UI can say "not found" or "too large" instead of failing blank.
754
+
755
+
756
+
757
+ ==============================================================================
758
+ # 10. Testing — the fold contract as tests
759
+
760
+ The workbench runs your tests with jest (`test_app`, seconds; `check_app` adds the platform's
761
+ suites and a type check). Write the contract the reviewer wants to trust:
762
+
763
+ ```ts
764
+ import { noteAddedEvent, noteTextSetEvent, pluginSchema, type StickyNotesData } from "./app";
765
+
766
+ const IDENT = { workspaceId: "ws1", nodeId: "node1", applicationType: "plugin_sticky_notes", instanceName: "My wall" };
767
+ const fresh = (): StickyNotesData => pluginSchema.stateCreator(IDENT as any, {} as any);
768
+ const apply = (def: any, s: StickyNotesData, args: Record<string, any> = {}) => def.processor(s, def.dataCreator({ ...IDENT, ...args }));
769
+
770
+ it("starts empty, adds exactly what it says, and a replay is a no-op", () => {
771
+ expect(fresh().notes).toEqual([]);
772
+ const once = apply(noteAddedEvent, fresh(), { noteId: "n1", text: "milk" });
773
+ const ev = noteAddedEvent.dataCreator({ ...IDENT, noteId: "n1", text: "milk" });
774
+ const twice = noteAddedEvent.processor(noteAddedEvent.processor(fresh(), ev), ev);
775
+ expect(once.notes.map((n) => n.id)).toEqual(["n1"]);
776
+ expect(twice).toEqual(noteAddedEvent.processor(fresh(), ev));
777
+ });
778
+
779
+ it("refuses a payload it cannot trust, and ignores an unknown id", () => {
780
+ expect(noteAddedEvent.processor(fresh(), { eventData: { noteId: 42 } } as any)).toEqual(fresh());
781
+ expect(apply(noteTextSetEvent, fresh(), { noteId: "ghost", text: "x" })).toEqual(fresh());
782
+ });
783
+
784
+ it("no processor mints an id or a time: two folds of one log are identical", () => {
785
+ const log = [noteAddedEvent.dataCreator({ ...IDENT, noteId: "n1", text: "a" }), noteTextSetEvent.dataCreator({ ...IDENT, noteId: "n1", text: "b" })];
786
+ const fold = () => log.reduce((s, e) => (e.eventName === noteAddedEvent.eventName ? noteAddedEvent : noteTextSetEvent).processor(s, e as any), fresh());
787
+ expect(fold()).toEqual(fold());
788
+ });
789
+
790
+ it("the description never claims an emptiness it could not read", () => {
791
+ expect(pluginSchema.getStateDescription({ ...IDENT } as any)).toMatch(/not loaded|incomplete/i);
792
+ expect(pluginSchema.getStateDescription(fresh())).toContain("No notes yet");
793
+ });
794
+ ```
795
+
796
+ A UI test renders once with a fixed state and checks the visible chrome and the empty state.
797
+ Keep suites fast; they run beside the live preview on the same machine.
798
+
799
+ ## Tools
800
+
801
+ Call `execute` with a recording `eventCallback` and assert the events it emitted and the text it
802
+ returned — including the refusal text for a bad id.
803
+
804
+ ## Tasks
805
+
806
+ Fake the context: `step.run` that calls its function, a recording `dispatchEvent`. Run the
807
+ handler twice with the same `eventData`; the second run must not add a second item.
808
+
809
+ ## Connections
810
+
811
+ `esoul-sdk/testing` ships `startMockOAuth()` — a local OAuth server that issues, refreshes and
812
+ revokes tokens, so a connection-backed op can be tested end to end without a real provider.
813
+
814
+ ## The fold corpus
815
+
816
+ Once an app has real history, record it (`scripts/plugins/record-fold-corpus.mjs` in the
817
+ platform) and commit `fold-corpus.json` + hashes. From then on the checks refuse a change that
818
+ alters what history MEANS — the strongest protection an app with data can have. Re-recording is
819
+ declaring a migration; say so in the changelog.
820
+
821
+
822
+
823
+ ==============================================================================
824
+ # 11. Shipping — submit, review, release, install
825
+
826
+ ## The path
827
+
828
+ 1. **Build and test in the Forge workbench** (docs/01). No repository access is needed: the
829
+ platform clones with its own installation for the app arm.
830
+ 2. **Submit** with `ship_app`. It refuses unless every check is green, commits, pushes the app's
831
+ branch and opens (or updates) a pull request labelled `user-app` and `submitted-by:<you>`.
832
+ The board shows "submitted — awaiting review". An app id already on the base branch under
833
+ another author is refused before a pull request exists — pick another id.
834
+ 3. **The owner reviews** the diff on GitHub and approves.
835
+ 4. **One script releases it**: checks the pull request out cleanly, runs the import wall, the
836
+ sync, your tests and the type check, merges any dependencies you declared onto your branch so
837
+ the reviewer sees them, squash-merges, waits for the production deploy, grants you the
838
+ entitlement, and tells your board "released — add it to a workspace".
839
+ 5. **Add it** in any of your workspaces from the app picker, by chat (`add_app`), or over MCP.
840
+ Its tools are live in chat, voice, agents and MCP; its events ride the timeline.
841
+
842
+ ## The import wall
843
+
844
+ Review is the security boundary — your server code will run with the platform's credentials.
845
+ Review is only tractable if nothing can reach around the SDK, so the wall refuses at check time,
846
+ at sync and at release:
847
+
848
+ - allowed: `esoul-sdk` (and `/server`, `/react`, `/testing`), relative files in your folder,
849
+ `server-only`, npm packages the platform already depends on;
850
+ - refused: `@/…` platform internals, `node:*` and the node built-ins, `prisma`, `next`,
851
+ `inngest`, the Vercel SDKs, and any package not in the platform's `package.json`.
852
+
853
+ Need something the SDK lacks? Ask for it in the SDK — that is the review's pressure valve. Need
854
+ an npm package? Declare it (below); it becomes a visible line in the pull request.
855
+
856
+ ## Declaring npm dependencies
857
+
858
+ Put a `package.json` in your app folder with only `dependencies`:
859
+
860
+ ```json
861
+ { "dependencies": { "date-fns": "^4.1.0" } }
862
+ ```
863
+
864
+ The release script merges them into the platform's `package.json`, updates the lockfile on your
865
+ branch, and pushes that commit onto your pull request before merging. In the workbench the
866
+ package is not yet installed until that happens; a later platform version merges declared
867
+ dependencies at `open`.
868
+
869
+ ## Entitlement — your app on your instance
870
+
871
+ A released app is available to **its submitter** and to no one else until the owner grants
872
+ everyone (`--public`). A plugin that predates entitlements stays public. The picker only offers
873
+ what you may add; the add path enforces it.
874
+
875
+ ## Versions and upgrades
876
+
877
+ Bump `version` on every submission. Events are forever: a change that alters what an existing
878
+ event MEANS rewrites every workspace's history on the next fold — record a fold corpus (docs/10)
879
+ so the checks catch it, and treat such a change as a migration with its own events.
880
+
881
+ ## Dev and deployed are symmetric
882
+
883
+ In the workbench your app can already call other apps (through the board's tab) and its own
884
+ tools can be called (`call_app_tool`) before install. Installed, the same calls go through the
885
+ manifest's grants, and the platform mints your tools natively. Exposing an in-development app's
886
+ tools to the whole workspace — so chat could call them before install — is the next step on
887
+ the platform's side (the board minting proxy tools per app under construction).
888
+
889
+ ## Cost and lifetime
890
+
891
+ A workbench dies ten minutes after the last touch (a tool call, or a board tab watching it).
892
+ `close_workbench` stops it at once. Files and commits persist; reopening resumes and updates the
893
+ platform it runs on.
894
+
895
+
896
+
897
+ ==============================================================================
898
+ # 12. Rules and failures — every rule with the failure that earned it
899
+
900
+ | Rule | The failure it prevents |
901
+ |---|---|
902
+ | Processors are pure and idempotent | a replay duplicated items; a scrub showed a state that never existed |
903
+ | Ids and timestamps are minted in `dataCreator`, never in a processor | two folds of one log disagreed; snapshots diverged from replay |
904
+ | A processor refuses a bad payload instead of throwing | one malformed webhook took a whole workspace view down |
905
+ | Whole-replace and burst events carry a collapse key per entity and field | fifty timeline rows per sentence; two notes edited together merged into one |
906
+ | `reconstructStateFromEventLog: true` | an app hydrated from a stale column and lost its last edits |
907
+ | `getStateDescription` uses `incompleteStateNotice`; missing ≠ empty | an agent "saw" an empty list, invited a duplicate, and the notebook crashed on a throw |
908
+ | Every tool has a real `onClient` (`= execute`) | the voice agent said "added the note" while nothing ran — an empty stub reads as success |
909
+ | Tool results say what was DONE, refuse what was not | "updated" for an id that did not exist |
910
+ | Server truth through a plugin op, never `fetch("/api/v1/…")` | the token-gated route refused the tool with "not readable" |
911
+ | Cross-app calls only through `useWorkspaceTools` / `callWorkspaceTool` with manifest grants | an app could act on any app in the workspace unseen |
912
+ | Server code reaches the platform only through `esoul-sdk/server` (the import wall) | a demo plugin read the database directly; a reviewer would have had to read every line |
913
+ | Webhooks verify, validate, kick a task, ACK; idempotent by the sender's key | a retried push duplicated an item; a slow webhook timed out mid-work |
914
+ | Every task side effect lives in a `step.run` | a dispatch fired four times, once per replay |
915
+ | No per-app crons; `pollTasks` at 5-minute granularity | Inngest function ids and the plan's concurrency cap |
916
+ | Secrets are env-variable NAMES in the manifest, never values | a client secret in a package that becomes public |
917
+ | The schema module is never `"use client"` | the schema became a server proxy and every processor vanished at build |
918
+ | The UI never imports the store or the registry, only `esoul-sdk/react` | a module cycle that crashed the client on load |
919
+ | Root fills the frame, `overflow:hidden`, content `flex:1 minHeight:0` | the whole app scrolled sideways on a phone |
920
+ | Measure the visible scroller, not the canvas | a 2400 px canvas laid new notes off the right edge |
921
+ | Observe `.dark` at runtime | wrong colours until the person clicked something |
922
+ | Bound every collection and string | an app that could no longer be folded |
923
+ | Bump the manifest version; record a fold corpus once there is data | a "small" event change rewrote every workspace's history |
924
+
925
+ ## Symptom → rule
926
+
927
+ - "It works in the preview and nothing changes after install" → the tool only had `onClient`, or
928
+ the event was `Server`-typed and dispatched from the UI.
929
+ - "The timeline has hundreds of rows for one edit" → no collapse key.
930
+ - "State reverts after reload" → `reconstructStateFromEventLog` unset, or a processor minted ids.
931
+ - "check_app is red on registry" → the manifest failed validation or the import wall refused a
932
+ file; the detail names it.
933
+ - "My tool needs the database" → write an op; call it with `callPluginOp`; expect a refusal in
934
+ the workbench (no database behind a preview) and use `read_app_state` there.