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/README.md +61 -0
- package/bin/esoul-plugin.mjs +110 -0
- package/dist/files.d.ts +59 -0
- package/dist/files.js +39 -0
- package/dist/helpers.d.ts +40 -0
- package/dist/helpers.js +93 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/manifest.d.ts +359 -0
- package/dist/manifest.js +136 -0
- package/dist/react.d.ts +85 -0
- package/dist/react.js +43 -0
- package/dist/server.d.ts +189 -0
- package/dist/server.js +58 -0
- package/dist/testing/index.d.ts +20 -0
- package/dist/testing/index.js +121 -0
- package/dist/types.d.ts +226 -0
- package/dist/types.js +6 -0
- package/docs/01-getting-started.md +114 -0
- package/docs/02-manifest.md +52 -0
- package/docs/03-events-and-state.md +92 -0
- package/docs/04-tools.md +85 -0
- package/docs/05-ui.md +86 -0
- package/docs/06-server.md +78 -0
- package/docs/07-background-tasks.md +61 -0
- package/docs/08-connections.md +49 -0
- package/docs/09-files.md +35 -0
- package/docs/10-testing.md +62 -0
- package/docs/11-shipping.md +70 -0
- package/docs/12-rules.md +37 -0
- package/llms-full.txt +934 -0
- package/llms.txt +23 -0
- package/package.json +80 -0
- package/schemas/plugin.schema.json +263 -0
- package/scripts/build-llms.mjs +36 -0
|
@@ -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.
|
package/docs/04-tools.md
ADDED
|
@@ -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.
|
package/docs/09-files.md
ADDED
|
@@ -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.
|