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.
@@ -0,0 +1,114 @@
1
+ # 1. Getting started
2
+
3
+ ## What an app is
4
+
5
+ An ExternalSoul app is a folder. Compiled into the platform, it becomes a native app: it renders
6
+ in the same frame as the spreadsheet next to it, its state is a fold of typed events on the
7
+ workspace timeline, and its tools are callable from chat, voice, the agent builder and MCP. There
8
+ is no dynamic loading and no sandbox at runtime: the review of your submission is the trust
9
+ boundary, and this SDK plus the platform's checks make your package correct on arrival.
10
+
11
+ ## The loop
12
+
13
+ You do not need the repository, a local checkout, or a running platform.
14
+
15
+ 1. In your ExternalSoul workspace, add a **Forge** board.
16
+ 2. Tell the assistant (or your own Claude over MCP) to **open a workbench** for your app. The
17
+ platform boots a cloud machine holding the platform's source, scaffolds your app folder from
18
+ the template, and shows a **live preview** in the board's frame.
19
+ 3. Write the files with the board's tools (`write_app_file`, `edit_app_file`). Every change
20
+ hot-reloads into the preview and comes back with the preview's health, the compiler's own
21
+ words if you broke it.
22
+ 4. **Look** at it (`look_at_app`: desktop and phone, light and dark), **call its tools** before it
23
+ is installed (`call_app_tool`, the events land in the frame), **read its state**
24
+ (`read_app_state`), **run its tests** (`test_app`, seconds) and **the full checks**
25
+ (`check_app`: registry sync, the platform suites, types, the import wall).
26
+ 5. **Submit** (`ship_app`). The owner reviews the diff, approves, and releases it with one
27
+ script. The board then says "released — add it to a workspace".
28
+ 6. Add it to a workspace like any other app. Its tools are live everywhere.
29
+
30
+ From your own editor, `npm install --save-dev esoul-sdk` gives you the same types and the
31
+ manifest validator, and lets you write tests locally. The code still runs in the platform.
32
+
33
+ ## Package layout
34
+
35
+ ```
36
+ src/plugins/<id>/
37
+ plugin.json the manifest (docs/02)
38
+ app.tsx the SCHEMA — exports `pluginSchema`; never "use client" (docs/03, 04)
39
+ ui/<id>-ui.tsx the React UI — "use client" (docs/05)
40
+ server.ts only if you declare ops or webhooks (docs/06)
41
+ <id>.test.ts the fold contract as tests (docs/10)
42
+ <id>-ui.test.tsx renders the UI once, checks the empty state
43
+ package.json only if you need an npm package the platform lacks (docs/11)
44
+ ```
45
+
46
+ The folder name is the app id: lower-case, hyphens. The application type is `plugin_` plus the
47
+ id with underscores (`sticky-notes` → `plugin_sticky_notes`). Neither changes after shipping.
48
+
49
+ ## The smallest complete app
50
+
51
+ ```ts
52
+ // app.tsx
53
+ import { nanoid, EventTypes, incompleteStateNotice, type ApplicationIdentifier,
54
+ type ApplicationSchema, type EventData, type EventDefinition, type ApplicationPort } from "esoul-sdk";
55
+ import { z } from "zod";
56
+ import { NotesUi } from "./ui/notes-ui";
57
+
58
+ export interface NotesData extends ApplicationIdentifier {
59
+ notes: { id: string; text: string; at: number }[];
60
+ }
61
+
62
+ export const notedEvent: EventDefinition<NotesData> = {
63
+ eventName: "plugin_notes_noted",
64
+ type: EventTypes.Client,
65
+ dataCreator: (args) => ({
66
+ eventName: "plugin_notes_noted",
67
+ eventData: { id: args.id ?? nanoid(), text: String(args.text ?? ""), at: args.at ?? Date.now() },
68
+ timestamp: Date.now(),
69
+ workspaceId: args.workspaceId,
70
+ applicationId: args.applicationId || args.nodeId,
71
+ instanceName: args.instanceName,
72
+ chatIdSource: args.chatIdSource,
73
+ }) as EventData<any>,
74
+ processor: (state, event) => {
75
+ const { id, text, at } = event.eventData ?? {};
76
+ if (typeof id !== "string" || typeof text !== "string" || !text.trim()) return state;
77
+ if (state.notes.some((n) => n.id === id)) return state; // replay-safe
78
+ return { ...state, notes: [...state.notes, { id, text: text.trim(), at: typeof at === "number" ? at : 0 }] };
79
+ },
80
+ };
81
+
82
+ export const pluginSchema: ApplicationSchema<NotesData> = {
83
+ applicationType: "plugin_notes",
84
+ description: "Notes on the timeline.",
85
+ reactNode: NotesUi,
86
+ reconstructStateFromEventLog: true,
87
+ events: [notedEvent],
88
+ getPorts: (): ApplicationPort[] => [],
89
+ stateCreator: (identifier) => ({ ...identifier, notes: [] }),
90
+ toolkitCreator: (identifier, forChatId, eventCallback) => {
91
+ const base = identifier.instanceName.replace(/[^a-zA-Z0-9]/g, "_");
92
+ const tools = {
93
+ [`add_note_${base}`]: {
94
+ description: `Add a note to "${identifier.instanceName}". Returns its id.`,
95
+ parameters: z.object({ text: z.string().min(1) }),
96
+ execute: async ({ text }: { text: string }) => {
97
+ const id = nanoid();
98
+ await eventCallback(notedEvent.dataCreator({ ...identifier, applicationId: identifier.nodeId, chatIdSource: forChatId, id, text }));
99
+ return `Added note ${id}.`;
100
+ },
101
+ },
102
+ };
103
+ for (const t of Object.values(tools)) (t as any).onClient = (t as any).execute; // every surface
104
+ return tools;
105
+ },
106
+ getStateDescription: (state) => {
107
+ const notLoaded = incompleteStateNotice({ title: "Notes", instanceName: state?.instanceName, shape: { lists: { notes: state?.notes } } });
108
+ if (notLoaded) return notLoaded;
109
+ return [`## Notes — "${state.instanceName}"`, ...(state.notes.length ? state.notes.slice(-10).map((n) => `- ${n.text} \`id:${n.id}\``) : ["- No notes yet."])].join("\n");
110
+ },
111
+ };
112
+ ```
113
+
114
+ Every line of that is explained in the next pages. The workbench scaffolds you an equivalent.
@@ -0,0 +1,52 @@
1
+ # 2. The manifest — `plugin.json`
2
+
3
+ Validated by `esoul-app validate <dir>`, by the workbench's checks and at release. The JSON
4
+ schema is shipped at `schemas/plugin.schema.json`.
5
+
6
+ ```json
7
+ {
8
+ "manifestVersion": 1,
9
+ "id": "sticky-notes",
10
+ "name": "Sticky notes",
11
+ "version": "0.1.0",
12
+ "description": "A wall of paper notes: pin, colour, drag, and let agents add to it.",
13
+ "applicationType": "plugin_sticky_notes",
14
+ "entry": "app",
15
+ "icon": "StickyNote",
16
+ "author": { "name": "Sticky notes", "url": "esoul:user:<your id>" },
17
+
18
+ "ops": ["read-notes"],
19
+ "webhooks": ["inbound"],
20
+ "kickableTasks": ["sync"],
21
+ "pollTasks": [{ "task": "refresh", "everyMinutes": 30 }],
22
+ "workspaceTools": ["spreadsheet:add_row", "my_computer:claude_task"],
23
+ "connections": [{ "key": "github", "kind": "oauth2", "label": "GitHub",
24
+ "authorizeUrl": "https://github.com/login/oauth/authorize", "tokenUrl": "https://github.com/login/oauth/access_token",
25
+ "oauthScopes": ["repo"], "clientIdEnv": "GITHUB_CLIENT_ID", "clientSecretEnv": "GITHUB_CLIENT_SECRET" }],
26
+ "fileSources": { "workspace": "read", "providers": ["google-drive"] },
27
+ "platformApi": { "min": "1.1.0" }
28
+ }
29
+ ```
30
+
31
+ | Field | Rule |
32
+ |---|---|
33
+ | `id` | lower-case, hyphens; equals the folder name; **never changes** after shipping |
34
+ | `name` | what people see in the picker (≤ 80) |
35
+ | `version` | semver; bump on every submission |
36
+ | `description` | one honest paragraph (≤ 500) |
37
+ | `applicationType` | `plugin_` + id with underscores; the platform tells plugins from built-ins by the prefix; **never changes** |
38
+ | `entry` | the schema module without extension (`app`) |
39
+ | `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 |
40
+ | `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 |
41
+ | `ops` | server operations exported by `server.ts` (docs/06) |
42
+ | `webhooks` | inbound routes `POST /api/plugins/<id>/webhook/<hook>` served by `server.ts` (docs/06) |
43
+ | `kickableTasks` | task names the **browser** may kick (docs/07) |
44
+ | `pollTasks` | tasks the platform kicks on a cadence, 5-minute granularity, minimum 5 (docs/07) |
45
+ | `workspaceTools` | the grant wall for calling OTHER apps' tools — `<applicationType>:<tool base name>`; absent = no cross-app access (docs/04, 06) |
46
+ | `connections` | OAuth2 / API-key connections the user grants once and the platform holds sealed; **names of env vars, never values** (docs/08) |
47
+ | `fileSources` / `fileProviders` | consent to read workspace files and providers; providers you contribute (docs/09) |
48
+ | `platformApi` | refuse install outside `[min, max]` of the platform contract version |
49
+ | `scopes` | reserved |
50
+
51
+ Secrets never live in a manifest, and a `package.json` in the folder declares npm dependencies
52
+ the platform does not have yet (docs/11).
@@ -0,0 +1,92 @@
1
+ # 3. Events and state — the heart of the contract
2
+
3
+ ## The model
4
+
5
+ An app's state is **the fold of its events**. The platform stores the events (a per-workspace,
6
+ per-app log with a sequence number) and derives the state by running your processors over them:
7
+ on the client as a person taps, on the server as the canonical fold, again on every replay,
8
+ scrub, snapshot, cross-device sync and share redaction. There is no other state. That is what
9
+ makes the timeline scrubbable and an agent's action indistinguishable from a person's.
10
+
11
+ So three things must hold, and the checks and the review look for them:
12
+
13
+ 1. **A processor is pure and idempotent.** Same input, same output, on every replay; the same
14
+ event applied twice leaves the state as after once.
15
+ 2. **Ids and timestamps are minted in the `dataCreator`, never in a processor.** A processor that
16
+ calls `nanoid()` or `Date.now()` folds differently every replay and breaks time-scrubbing.
17
+ 3. **A processor refuses what it cannot trust** — returns `state` unchanged rather than throw.
18
+ Malformed payloads exist (a retried webhook, an old client), and a throw in a fold takes the
19
+ whole workspace view down.
20
+
21
+ ## Anatomy of an event
22
+
23
+ ```ts
24
+ export const noteTextSetEvent: EventDefinition<StickyNotesData> = {
25
+ eventName: "plugin_sticky_notes_text_set", // globally unique; prefix with your type
26
+ type: EventTypes.Client, // Client = dispatched by UI/tools; Server = from tasks/webhooks
27
+ dataCreator: (args) => ({ // MINTS the envelope. Called by the UI and by tools.
28
+ eventName: "plugin_sticky_notes_text_set",
29
+ eventData: { noteId: args.noteId, text: String(args.text ?? "").slice(0, 2000), at: args.at ?? Date.now() },
30
+ timestamp: Date.now(),
31
+ workspaceId: args.workspaceId,
32
+ applicationId: args.applicationId || args.nodeId,
33
+ instanceName: args.instanceName,
34
+ chatIdSource: args.chatIdSource,
35
+ }) as EventData<any>,
36
+ processor: (state, event) => { // PURE. The fold.
37
+ const d = event.eventData ?? {};
38
+ if (typeof d.noteId !== "string" || typeof d.text !== "string") return state;
39
+ const i = state.notes.findIndex((n) => n.id === d.noteId);
40
+ if (i === -1) return state; // an unknown id is ignored, never invented
41
+ const next = [...state.notes]; next[i] = { ...next[i], text: d.text, updatedAt: d.at };
42
+ return { ...state, notes: next };
43
+ },
44
+ // One row on the timeline per typing burst, not one per keystroke (see below).
45
+ collapseConfig: {
46
+ collapseKeyFn: (eventData, ctx) => `${ctx.applicationId}:${(eventData as { noteId?: string }).noteId}:text`,
47
+ collapseWindowMs: 2500,
48
+ },
49
+ };
50
+ ```
51
+
52
+ `stateCreator(identifier)` returns the empty state — always including the identifier fields
53
+ (`{ ...identifier, notes: [] }`), because tools and describers read `instanceName` from it.
54
+
55
+ ## Collapse keys: bursts are one event
56
+
57
+ A person typing, dragging or resizing produces a burst. Without a collapse key that is a hundred
58
+ timeline rows and a scrub that steps through every keystroke. `collapseConfig` merges consecutive
59
+ events with the same key inside the window into one. Key by the **entity and the field**
60
+ (`<app>:<noteId>:text`), never by the app alone: two notes edited in the same window must not
61
+ merge. Whole-replace events (an editor's content, a canvas snapshot) **must** carry a collapse key.
62
+
63
+ ## What is local and what is an event
64
+
65
+ What the workspace remembers goes through events. What is honestly local to one device — a drag
66
+ in progress, text still being typed, which tab is open — stays in component state and lands as
67
+ ONE event when done. Never mirror app state into a second store; never write derived data the
68
+ fold could recompute.
69
+
70
+ ## Bounds
71
+
72
+ Cap collections in the processor (`if (state.notes.length >= 300) return state;`) and cap
73
+ strings in the `dataCreator`. An app with an unbounded array is an app that one day cannot be
74
+ folded.
75
+
76
+ ## `reconstructStateFromEventLog: true`
77
+
78
+ Set it. It tells the platform your state IS the fold, which is what enables replay-on-load,
79
+ snapshot baselines and the durability layers. Every shipped plugin and the scaffold set it.
80
+
81
+ ## Server-typed events
82
+
83
+ `EventTypes.Server` marks events that only server code emits (a task, a webhook). The UI must
84
+ not dispatch them, and the processor still obeys the three rules.
85
+
86
+ ## Durability you get for free
87
+
88
+ Because your events flow through the platform's dispatch chokepoint, an installed app inherits
89
+ the write-ahead log with retry, session stamping, the stale-base write fence, the pagehide flush
90
+ that walks every open app, server-side deduplication of re-sent events, one canonical server fold
91
+ and the monotonic merge guard. You do nothing for this — except keep the three rules, because
92
+ every one of those layers re-runs your processors.
@@ -0,0 +1,85 @@
1
+ # 4. Tools — what agents call
2
+
3
+ A tool is how chat, voice, the agent builder, MCP, a WebMCP page, and other apps' server code act
4
+ on your app. **Every UI action has a tool twin**: if a person can do it by hand, an agent can do
5
+ it by tool, through the same event. The platform mints your tools per instance as
6
+ `<verb>_<instance name>` (spaces and punctuation → underscores).
7
+
8
+ ```ts
9
+ toolkitCreator: (identifier, forChatId, eventCallback) => {
10
+ const base = identifier.instanceName.replace(/[^a-zA-Z0-9]/g, "_");
11
+ const idArgs = { ...identifier, applicationId: identifier.nodeId, chatIdSource: forChatId };
12
+ const tools = {
13
+ [`add_note_${base}`]: {
14
+ description: `Pin a new sticky note on "${identifier.instanceName}". Returns the note's id.`,
15
+ parameters: z.object({ text: z.string().min(1).max(2000), color: z.enum(["butter", "rose", "mint", "sky", "lilac"]).optional() }),
16
+ execute: async (args) => {
17
+ const noteId = nanoid();
18
+ await eventCallback(noteAddedEvent.dataCreator({ ...idArgs, noteId, ...args }));
19
+ return `Pinned note ${noteId}.`;
20
+ },
21
+ },
22
+ [`read_notes_${base}`]: {
23
+ description: `Read every note on "${identifier.instanceName}" with the ids the other tools take.`,
24
+ parameters: z.object({}),
25
+ readOnly: true,
26
+ publicSafe: true,
27
+ execute: async () => {
28
+ const { callPluginOp } = await import("esoul-sdk");
29
+ const s = await callPluginOp<Pick<StickyNotesData, "notes">>("sticky-notes", "read-notes", identifier.nodeId);
30
+ return describeNotes(s);
31
+ },
32
+ },
33
+ };
34
+ // Browser surfaces (voice, WebMCP) run `onClient`. Same work — never an empty stub:
35
+ // the voice runtime reports a tool that returns nothing as a SUCCESS.
36
+ for (const t of Object.values(tools)) (t as any).onClient = (t as any).execute;
37
+ return tools;
38
+ },
39
+ ```
40
+
41
+ ## The two surfaces
42
+
43
+ - **`execute`** runs on the server: chat, agents, MCP, the workbench's `call_app_tool`.
44
+ - **`onClient`** runs in the browser: WebRTC voice, WebMCP. Events dispatch there too, and
45
+ `callPluginOp` rides the session. Setting `onClient = execute` for every tool is the simplest
46
+ correct form; the checks refuse an empty `onClient: () => {}` stub because it makes an agent
47
+ claim work it never did.
48
+
49
+ ## Flags
50
+
51
+ - `readOnly: true` — a read-scoped token may call it.
52
+ - `publicSafe: true` — safe to expose on a public storefront (no writes, no private data).
53
+
54
+ ## Honest results
55
+
56
+ A tool result is what the model believes happened. Return what you **did**, and refuse what you
57
+ could not do: an update to an id that is not on the wall says "No note X — nothing changed", not
58
+ "updated". When a check needs server truth (an op) and it is unavailable, say so
59
+ ("could not verify the id here"). Name what the caller should do next; say where ids come from.
60
+
61
+ ## Server truth from a tool
62
+
63
+ A tool that must read the real state (not the caller's cache) calls a **plugin op** through
64
+ `callPluginOp(pluginId, op, nodeId, args)` — never `fetch("/api/v1/…")` itself (token-gated, it
65
+ refuses the tool). Ops are yours to write (docs/06). In the workbench there is no database behind
66
+ the preview, so such a tool is refused with a message naming `read_app_state`; that is expected.
67
+
68
+ ## Calling other apps
69
+
70
+ From the UI: `useWorkspaceTools()` (docs/05). From server code: `callWorkspaceTool` (docs/06).
71
+ Both are gated by the manifest's `workspaceTools` grants, `"<applicationType>:<tool base>"`, and
72
+ both stay inside the app's own workspace.
73
+
74
+ ## Describing the app to agents
75
+
76
+ `getStateDescription(state)` is what an agent reads about your app every turn. Compact, truthful,
77
+ with ids. And **never claim a count or an emptiness you could not read**:
78
+
79
+ ```ts
80
+ getStateDescription: (state) => {
81
+ const notLoaded = incompleteStateNotice({ title: "Sticky notes", instanceName: state?.instanceName, shape: { lists: { notes: state?.notes } } });
82
+ if (notLoaded) return notLoaded; // a MISSING collection means the state did not load; [] is real
83
+ return describeNotes(state);
84
+ },
85
+ ```
package/docs/05-ui.md ADDED
@@ -0,0 +1,86 @@
1
+ # 5. The UI
2
+
3
+ ```tsx
4
+ "use client";
5
+ import React from "react";
6
+ import { useAppCanEdit, usePluginEventDispatch, useWorkspaceTools } from "esoul-sdk/react";
7
+ import { noteAddedEvent, type StickyNotesData } from "../app";
8
+
9
+ export function StickyNotesUi({ state }: { state: StickyNotesData }) {
10
+ const dispatch = usePluginEventDispatch(); // the ONLY way to change state
11
+ const canEdit = useAppCanEdit(); // readers of a share see the app and cannot mutate
12
+ const tools = useWorkspaceTools(state); // other apps' tools, per the manifest's grants
13
+ // …
14
+ }
15
+ ```
16
+
17
+ - Props are `{ state }`: the folded state, live. You never fetch it.
18
+ - `dispatch(noteAddedEvent.dataCreator({ …identifier fields from state, …args }))` is a write.
19
+ Disable writes when `!canEdit`.
20
+ - The schema module (`app.tsx`) is **never** `"use client"`; the UI module is. The schema imports
21
+ the UI, not the other way around (it closes a module cycle).
22
+
23
+ ## Theme
24
+
25
+ Read the `.dark` class on `<html>`, and **observe it** (the person toggles at runtime):
26
+
27
+ ```ts
28
+ function useIsDark() {
29
+ const [dark, setDark] = React.useState(false);
30
+ React.useEffect(() => {
31
+ const read = () => setDark(document.documentElement.classList.contains("dark"));
32
+ read();
33
+ const mo = new MutationObserver(read);
34
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
35
+ return () => mo.disconnect();
36
+ }, []);
37
+ return dark;
38
+ }
39
+ ```
40
+
41
+ Keep one palette object per app with `LIGHT` and `DARK` of the same shape. Light is warm sepia
42
+ (`#faf6f0` / `#efe7d8` / `#e8dfd0`, brown text). Dark is translucent data surfaces over the frame's
43
+ gradient, with editors, dialogs, popovers and sticky headers **opaque**.
44
+
45
+ ## Responsive
46
+
47
+ The app renders inside the platform frame at any size: a desktop window, a maximised canvas, a
48
+ 390 px phone.
49
+
50
+ - The root fills its frame: `width:100%; height:100%; overflow:hidden`, a flex column.
51
+ - Chrome rows `flexShrink:0`; the content area `flex:1; minHeight:0` with its own scroll.
52
+ - Measure the VISIBLE scroller for layout decisions (column counts), never a large inner canvas;
53
+ re-measure on resize.
54
+ - Toolbars wrap; labels collapse on narrow widths; nothing needs a horizontal page scroll.
55
+ - Touch is first-class: tap to act (never double-tap), `touch-action: manipulation`, larger hit
56
+ targets on `(pointer: coarse)`.
57
+ - Custom clickables are real buttons: `role="button"`, `tabIndex={0}`, Enter and Space,
58
+ `aria-label` on icon-only controls. Distinct icons for distinct actions. Status is never
59
+ colour-only.
60
+ - Popovers and menus portal to `document.body` with a solid backing.
61
+ - An empty state that says what to do first, in the app's own voice.
62
+
63
+ `look_at_app` in the workbench screenshots desktop-light, desktop-dark and phone-light. Open the
64
+ images; a phone shot with a horizontal scrollbar is a bug.
65
+
66
+ ## Cross-app from the UI
67
+
68
+ ```ts
69
+ const { call, listApps, available } = useWorkspaceTools(state);
70
+ await call({ appType: "spreadsheet", tool: "add_row", args: { cells: { When: new Date().toISOString() } } });
71
+ ```
72
+
73
+ In the workbench this works through the board's own tab, so you can test it before install;
74
+ installed, only the grants in `plugin.json` `workspaceTools` are allowed. Declare each as
75
+ `"<applicationType>:<tool base name>"`.
76
+
77
+ ## Files
78
+
79
+ `usePluginWorkspaceFiles()`, `usePluginFileUpload()`, `useFileSources()`, `useFileSourceEntries()`
80
+ — docs/09.
81
+
82
+ ## Character
83
+
84
+ The platform is built as craft: a sticky-notes wall is linen and paper with a gummed strip, not a
85
+ grid of yellow rectangles. Derive any randomness from stable ids, never `Math.random()` in
86
+ render, so nothing twitches between renders. Motion is brief and respects reduced-motion.
@@ -0,0 +1,78 @@
1
+ # 6. The server half — `server.ts`
2
+
3
+ Only if you declare `ops`, `webhooks` or `fileProviders`. Marked `"server-only"`, it never
4
+ reaches the client graph.
5
+
6
+ ```ts
7
+ import "server-only";
8
+ import { readAppState, callWorkspaceTool, emitPluginAppEvent, type PluginOpContext, type PluginServerModule, type PluginWebhookContext } from "esoul-sdk/server";
9
+
10
+ async function readNotes(ctx: PluginOpContext) {
11
+ const app = await readAppState(ctx.nodeId); // folded to head; null = no such app
12
+ if (!app) throw new Error(`no app ${ctx.nodeId}`);
13
+ const s = app.state as Partial<StickyNotesData>;
14
+ if (!Array.isArray(s.notes)) throw new Error("the wall did not fold (notes missing)"); // MISSING ≠ empty
15
+ return { notes: s.notes };
16
+ }
17
+
18
+ export const pluginServer: PluginServerModule = {
19
+ ops: { "read-notes": readNotes },
20
+ webhooks: { inbound: async (ctx: PluginWebhookContext) => { /* verify, validate, kick a task, ACK */ } },
21
+ };
22
+ ```
23
+
24
+ ## Ops — server truth for a tool
25
+
26
+ `POST /api/plugins/<id>/op/<name>` with `{ nodeId, args }`. The route checks the caller's
27
+ workspace write access and that `nodeId` is an instance of THIS plugin's type, then calls your
28
+ handler with `ctx = { nodeId, workspaceId, args, cloudConnectionId, … }`. A tool reaches it through
29
+ `callPluginOp` (docs/04). Ops are for reads that must be true now and for actions that need a
30
+ credential; they must be idempotent (a tool call can be retried).
31
+
32
+ ## Webhooks — push in
33
+
34
+ `POST /api/plugins/<id>/webhook/<hook>`. **Verify first** (a shared secret with
35
+ `timingSafeEqual`), validate the body, then **ACK and hand the work to a task** through
36
+ `ctx.sendInngestEvent("<applicationType>/<task>", payload)`. Never do the work in the webhook: a
37
+ sender retries, a request has a time limit, and a task is durable. Give every push an
38
+ idempotency key and derive the event's id from it (`deterministicReducerId`), so a redelivery
39
+ folds to one item. Fail CLOSED when no secret is configured.
40
+
41
+ ## `readAppState(nodeId)`
42
+
43
+ One app, folded to head: `{ nodeId, workspaceId, applicationType, foldedSeq, state }`, or `null`
44
+ when absent. Use it for your own instance and, with care, for other apps in the same workspace.
45
+ Never a raw database read — the import wall refuses `prisma` and the state column lags the log.
46
+
47
+ ## `callWorkspaceTool` — orchestrate other apps
48
+
49
+ ```ts
50
+ const r = await callWorkspaceTool({
51
+ pluginId: "research-graph", nodeId: ctx.nodeId,
52
+ appType: "my_computer", tool: "claude_task",
53
+ args: { prompt: "Summarise the three papers in ~/reading", cwd: "~/reading" },
54
+ });
55
+ // r = { ok, text }
56
+ ```
57
+
58
+ Gated exactly like the UI: the manifest must declare `"my_computer:claude_task"` in
59
+ `workspaceTools`, and the target must live in the calling app's own workspace. Name a specific
60
+ instance with `targetNodeId` instead of `appType`. This is how an app runs a `my_computer`
61
+ (commands, Claude Code sessions, results), fills a spreadsheet from a job, or books a calendar.
62
+
63
+ ## `emitPluginAppEvent`
64
+
65
+ Emit ANOTHER app's own events (its `dataCreator` shape) into the same workspace, actor-stamped as
66
+ your plugin — for mirroring into a native app whose processors you cannot call directly.
67
+ Same-workspace only.
68
+
69
+ ## Connections in server code
70
+
71
+ `ctx.cloudConnectionId` is the instance's bound connection; resolve it with
72
+ `getPluginConnectionCredentials(connectionId, pluginId)` (docs/08).
73
+
74
+ ## What server code may import
75
+
76
+ Only `esoul-sdk` / `esoul-sdk/server`, relative files, `server-only`, and npm packages the
77
+ platform already depends on. No `prisma`, no `node:*`, no `next`, no internals — the import wall
78
+ (docs/11) refuses them, and a reviewer relies on that.
@@ -0,0 +1,61 @@
1
+ # 7. Background tasks — durable work
2
+
3
+ Tasks are declared on the schema and run on the platform's durable executor (Inngest). Each task
4
+ becomes an event `<applicationType>/<taskName>`.
5
+
6
+ ```ts
7
+ tasks: [
8
+ {
9
+ taskName: "ingest",
10
+ description: "Fold one pushed item — idempotent by pushId.",
11
+ concurrency: { limit: 1, scope: "per-app" },
12
+ handler: async (ctx) => {
13
+ await ctx.step.run("fold-pushed-item", async () => {
14
+ const { text, pushId } = ctx.eventData ?? {};
15
+ if (typeof text !== "string" || typeof pushId !== "string") return;
16
+ await ctx.dispatchEvent("plugin_todo_add_item", { itemId: `push-${pushId}`, text: text.trim(), createdAt: Date.now() });
17
+ });
18
+ },
19
+ },
20
+ ],
21
+ ```
22
+
23
+ ## The context
24
+
25
+ - `ctx.identifier` — workspaceId, nodeId, applicationType, instanceName.
26
+ - `ctx.eventData` — the kick's payload.
27
+ - `ctx.step` — the durable step API; `await ctx.step.run("name", fn)` memoises `fn`'s result.
28
+ - `ctx.getState()` — the app's state, fresh.
29
+ - `ctx.dispatchEvent(eventName, eventData)` — through the full pipeline (append, fold, watermark),
30
+ using YOUR processors, so a task's mutation is identical to a tap's.
31
+ - `ctx.notify(topic, data)` — a realtime nudge to the app's channel; the UI refetches.
32
+ - `ctx.logger`.
33
+
34
+ ## The replay model — read this twice
35
+
36
+ The executor **re-runs your handler from the top** after every step boundary, replacing each
37
+ completed `step.run` with its cached result. Anything outside a `step.run` runs again on every
38
+ replay. So: **every side effect lives inside a `step.run`** — a dispatch, a fetch, an emit — or
39
+ it fires N+1 times. `nanoid()` and `Date.now()` outside a step produce different values per
40
+ replay; mint them inside. Step names are unique per logical operation; loops include the index.
41
+
42
+ ## How a task gets kicked
43
+
44
+ - **From a webhook**: `ctx.sendInngestEvent("<applicationType>/<task>", payload)` (docs/06).
45
+ - **From the browser**: list the task in `kickableTasks`; the UI posts the event through the
46
+ platform's send-event route, which allows only listed tasks.
47
+ - **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
48
+ live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
49
+ no per-app Inngest functions: function ids are fixed at module load, and the plan caps
50
+ concurrency; a shared sweep costs nothing per app. Handlers must be idempotent anyway, because
51
+ sweeps and pushes overlap by design.
52
+
53
+ ## Concurrency
54
+
55
+ `concurrency: { limit, scope: "per-app" | "global" }`. Keep the limit small; a parked wait holds
56
+ no slot.
57
+
58
+ ## Testing tasks
59
+
60
+ Unit-test the handler with a fake `ctx` (`step.run` that just calls the function, a recording
61
+ `dispatchEvent`), and assert idempotency by running it twice with the same `eventData` (docs/10).
@@ -0,0 +1,49 @@
1
+ # 8. Connections and OAuth — tokens the platform holds for you
2
+
3
+ An app that talks to an outside service declares the connection; the person grants it once in
4
+ Account settings; the platform stores the tokens sealed and refreshes them; your code asks for a
5
+ credential when it needs one. **Secrets never live in your package**: the manifest names
6
+ environment variables, not values.
7
+
8
+ ```json
9
+ "connections": [{
10
+ "key": "github", "kind": "oauth2", "label": "GitHub",
11
+ "authorizeUrl": "https://github.com/login/oauth/authorize",
12
+ "tokenUrl": "https://github.com/login/oauth/access_token",
13
+ "oauthScopes": ["repo"],
14
+ "clientIdEnv": "GITHUB_CLIENT_ID", "clientSecretEnv": "GITHUB_CLIENT_SECRET"
15
+ }]
16
+ ```
17
+
18
+ `kind: "apiKey"` instead declares `headerNames` — the header names the app will send; the person
19
+ pastes the key once.
20
+
21
+ ## Using a credential
22
+
23
+ ```ts
24
+ import { getPluginConnectionCredentials } from "esoul-sdk/server";
25
+
26
+ async function listRepos(ctx: PluginOpContext) {
27
+ if (!ctx.cloudConnectionId) throw new Error("connect GitHub in Account settings first");
28
+ const cred = await getPluginConnectionCredentials(ctx.cloudConnectionId, "my-app");
29
+ if (cred.kind !== "oauth2") throw new Error("expected an OAuth connection");
30
+ const r = await fetch("https://api.github.com/user/repos", { headers: { Authorization: `Bearer ${cred.accessToken}` } });
31
+ // …
32
+ }
33
+ ```
34
+
35
+ The platform unseals, checks expiry and refreshes with the stored refresh token before handing
36
+ you `accessToken`. Never log it, never put it in an event, never return it from a tool.
37
+
38
+ An instance is **bound** to one connection (`ctx.cloudConnectionId`); a person with two GitHub
39
+ accounts adds two instances. File providers you contribute receive `ctx.connectionId` per active
40
+ connection (docs/09).
41
+
42
+ ## The platform's own connections
43
+
44
+ The platform already holds the person's Google connection for its mail and calendar apps. Reusing
45
+ it from an app ("the mail app needs the Google tokens") is designed and not yet built: the
46
+ manifest would declare `platformConnections` with scopes, the person would consent per app, and
47
+ the SDK would hand out a token scoped to those scopes. Until then an app declares its own Google
48
+ OAuth connection with the platform's client id and secret named as env vars, and the person
49
+ grants it once more.
@@ -0,0 +1,35 @@
1
+ # 9. Files — workspace files, Drive, your own provider
2
+
3
+ Files are a consented surface. The manifest declares what the app may read:
4
+
5
+ ```json
6
+ "fileSources": { "workspace": "read", "providers": ["google-drive"] }
7
+ ```
8
+
9
+ - `workspace: "read" | "readwrite"` — the workspace's own files.
10
+ - `providers` — provider keys the app may browse (the person's connected sources).
11
+ - Absent = no file access at all. The person sees the declaration at install.
12
+
13
+ ## In the UI
14
+
15
+ `usePluginWorkspaceFiles()` (the workspace's files, live), `usePluginFileUpload()` (upload as the
16
+ person), `useFileSources(workspaceId)` and `useFileSourceEntries(source, path)` for browsing
17
+ sources with entries.
18
+
19
+ ## In server code
20
+
21
+ `pluginFiles(ctx)` / `filesForOp(ctx)` give a `FilesApi`: list a source, read a file (capped at
22
+ `FILE_READ_CAP_BYTES`), resolve a `FileRef`. Source ids are `"workspace"`, `"google-drive"`,
23
+ `"local:<mountId>"`, or your own provider's key; `parseSourceId` splits them.
24
+
25
+ ## Contributing a provider
26
+
27
+ ```json
28
+ "fileProviders": [{ "key": "dropbox", "connectionKey": "dropbox", "label": "Dropbox" }]
29
+ ```
30
+
31
+ `server.ts` exports `fileProviders: { dropbox: impl }` where `impl` implements
32
+ `PluginFileProviderImpl` (`list`, `read`, …). The host derives one mount per ACTIVE connection
33
+ of `connectionKey` and calls you with `ctx.connectionId` set; resolve the credential with
34
+ `getPluginConnectionCredentials`. Reads are capped; errors are typed (`FileSourceError`,
35
+ `FileSourceErrorKind`) so the UI can say "not found" or "too large" instead of failing blank.