esoul-sdk 0.3.0 → 0.4.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 CHANGED
@@ -47,7 +47,7 @@ that.
47
47
  | Entry | What it gives you |
48
48
  |---|---|
49
49
  | `esoul-sdk` | `ApplicationSchema`, `EventDefinition`, `EventTypes`, `ApplicationIdentifier`, `incompleteStateNotice`, `deterministicReducerId`, `stableStringify`, `timingSafeEqual`, `nanoid`, `callPluginOp`, the manifest schema |
50
- | `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, file hooks |
50
+ | `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, `usePluginRealtime`, file hooks |
51
51
  | `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
52
52
  | `esoul-sdk/testing` | a mock OAuth server for connection tests |
53
53
  | `esoul-app validate <dir>` | validates a package folder against the manifest schema |
package/dist/helpers.d.ts CHANGED
@@ -38,3 +38,23 @@ export declare function callPluginOp<T = unknown>(pluginId: string, op: string,
38
38
  * Pure JS (no node:crypto) so it is safe in any runtime the SDK reaches.
39
39
  */
40
40
  export declare function timingSafeEqual(a: string, b: string): boolean;
41
+ /**
42
+ * Kick one of your app's background tasks (docs/07) from the browser OR from
43
+ * a tool's `execute` on the server. The task must be listed in the
44
+ * manifest's `kickableTasks`; the platform's send-event route refuses the
45
+ * rest. `data` rides on `ctx.eventData` — keep it small and JSON. The kick
46
+ * is fire-and-forget: the task's result lands as events, or on the channel.
47
+ */
48
+ export declare function kickPluginTask(args: {
49
+ applicationType: string;
50
+ taskName: string;
51
+ identifier: {
52
+ workspaceId: string;
53
+ nodeId: string;
54
+ instanceName?: string;
55
+ };
56
+ data?: Record<string, unknown>;
57
+ }): Promise<{
58
+ ok: boolean;
59
+ status: number;
60
+ }>;
package/dist/helpers.js CHANGED
@@ -91,3 +91,32 @@ export function timingSafeEqual(a, b) {
91
91
  }
92
92
  return diff === 0;
93
93
  }
94
+ /**
95
+ * Kick one of your app's background tasks (docs/07) from the browser OR from
96
+ * a tool's `execute` on the server. The task must be listed in the
97
+ * manifest's `kickableTasks`; the platform's send-event route refuses the
98
+ * rest. `data` rides on `ctx.eventData` — keep it small and JSON. The kick
99
+ * is fire-and-forget: the task's result lands as events, or on the channel.
100
+ */
101
+ export async function kickPluginTask(args) {
102
+ const onServer = typeof window === "undefined";
103
+ const base = onServer ? (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000") : "";
104
+ const headers = { "Content-Type": "application/json" };
105
+ if (onServer && process.env.INTERNAL_TOOL_SECRET)
106
+ headers["x-esoul-internal-secret"] = process.env.INTERNAL_TOOL_SECRET;
107
+ const res = await fetch(`${base}/api/inngest/send-event`, {
108
+ method: "POST",
109
+ headers,
110
+ body: JSON.stringify({
111
+ name: `${args.applicationType}/${args.taskName}`,
112
+ data: {
113
+ workspaceId: args.identifier.workspaceId,
114
+ nodeId: args.identifier.nodeId,
115
+ applicationType: args.applicationType,
116
+ instanceName: args.identifier.instanceName,
117
+ ...(args.data ?? {}),
118
+ },
119
+ }),
120
+ });
121
+ return { ok: res.ok, status: res.status };
122
+ }
package/dist/index.d.ts CHANGED
@@ -4,3 +4,22 @@ export * from "./helpers.js";
4
4
  export * from "./files.js";
5
5
  /** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
6
6
  export { nanoid } from "nanoid";
7
+ /**
8
+ * Declare an app's realtime channel — one per instance. Inside the host this
9
+ * is the platform's real channel; outside it (tests, your editor) a
10
+ * descriptor of the same shape. Put it on the schema as `channel` and pass it
11
+ * to `usePluginRealtime`; a task publishes on it with `ctx.notify(topic, data)`.
12
+ */
13
+ export declare function definePluginChannel<T extends Record<string, {
14
+ schema: unknown;
15
+ }>>(args: {
16
+ applicationType: string;
17
+ topics: T;
18
+ }): ((p: {
19
+ workspaceId: string;
20
+ nodeId: string;
21
+ }) => Record<keyof T, unknown> & {
22
+ name: string;
23
+ }) & {
24
+ topicNames: (keyof T & string)[];
25
+ };
package/dist/index.js CHANGED
@@ -4,3 +4,20 @@ export * from "./helpers.js";
4
4
  export * from "./files.js";
5
5
  /** Id minting for dataCreators; an app depends on the SDK, not on nanoid. */
6
6
  export { nanoid } from "nanoid";
7
+ /**
8
+ * Declare an app's realtime channel — one per instance. Inside the host this
9
+ * is the platform's real channel; outside it (tests, your editor) a
10
+ * descriptor of the same shape. Put it on the schema as `channel` and pass it
11
+ * to `usePluginRealtime`; a task publishes on it with `ctx.notify(topic, data)`.
12
+ */
13
+ export function definePluginChannel(args) {
14
+ const type = args.applicationType.replace(/[^a-z0-9_]/gi, "_");
15
+ const factory = ((p) => {
16
+ const out = { name: `plugin:${type}:${p.workspaceId}:${p.nodeId}` };
17
+ for (const t of Object.keys(args.topics))
18
+ out[t] = { topic: t, channel: out.name };
19
+ return out;
20
+ });
21
+ factory.topicNames = Object.keys(args.topics);
22
+ return factory;
23
+ }
package/dist/react.d.ts CHANGED
@@ -83,3 +83,32 @@ export declare function usePluginFileUpload(_workspaceId: string): (file: File)
83
83
  export declare function useFileSources(_workspaceId: string | null): FileSourcesState;
84
84
  /** One mount's listing; a failing source reports error/errorKind, never []. */
85
85
  export declare function useFileSourceEntries(_workspaceId: string | null, _sourceId: string | null, _folderRef?: string): FileEntriesState;
86
+ export interface PluginRealtimeMessage<T = unknown> {
87
+ topic: string;
88
+ data: T;
89
+ }
90
+ export interface PluginRealtime<T = unknown> {
91
+ /** Every message received this mount, oldest first. */
92
+ data: PluginRealtimeMessage<T>[];
93
+ latestData: PluginRealtimeMessage<T> | null;
94
+ error: Error | null;
95
+ /** "connecting" | "active" | "closed" | "error". */
96
+ state: string;
97
+ }
98
+ /**
99
+ * Subscribe to this instance's channel (the one you declared with
100
+ * `definePluginChannel` and put on the schema as `channel`). A task publishes
101
+ * with `ctx.notify(topic, data)`; each message arrives here as
102
+ * `{ topic, data }`. Realtime is a NUDGE, never the truth: anything that must
103
+ * survive a refresh goes on the timeline as an event as well.
104
+ */
105
+ export declare function usePluginRealtime<T = unknown>(_args: {
106
+ channel: (p: {
107
+ workspaceId: string;
108
+ nodeId: string;
109
+ }) => unknown;
110
+ workspaceId: string;
111
+ nodeId: string;
112
+ topics: readonly string[];
113
+ enabled?: boolean;
114
+ }): PluginRealtime<T>;
package/dist/react.js CHANGED
@@ -41,3 +41,13 @@ export function useFileSources(_workspaceId) {
41
41
  export function useFileSourceEntries(_workspaceId, _sourceId, _folderRef) {
42
42
  return hostOnly("useFileSourceEntries");
43
43
  }
44
+ /**
45
+ * Subscribe to this instance's channel (the one you declared with
46
+ * `definePluginChannel` and put on the schema as `channel`). A task publishes
47
+ * with `ctx.notify(topic, data)`; each message arrives here as
48
+ * `{ topic, data }`. Realtime is a NUDGE, never the truth: anything that must
49
+ * survive a refresh goes on the timeline as an event as well.
50
+ */
51
+ export function usePluginRealtime(_args) {
52
+ return hostOnly("usePluginRealtime");
53
+ }
package/docs/05-ui.md CHANGED
@@ -63,6 +63,36 @@ The app renders inside the platform frame at any size: a desktop window, a maxim
63
63
  `look_at_app` in the workbench screenshots desktop-light, desktop-dark and phone-light. Open the
64
64
  images; a phone shot with a horizontal scrollbar is a bug.
65
65
 
66
+ ## Live data from a background task
67
+
68
+ ```tsx
69
+ import { usePluginRealtime } from "esoul-sdk/react";
70
+ import { kickPluginTask } from "esoul-sdk";
71
+ import { stopwatchChannel } from "./channel";
72
+
73
+ const live = usePluginRealtime<{ runId: string; elapsedMs: number; serverNow: number }>({
74
+ channel: stopwatchChannel,
75
+ workspaceId: state.workspaceId,
76
+ nodeId: state.nodeId,
77
+ topics: stopwatchChannel.topicNames,
78
+ });
79
+ // live.latestData → { topic: "tick", data: {...} } — the newest message, or null.
80
+ // live.data → everything received this mount, oldest first.
81
+
82
+ const start = () =>
83
+ kickPluginTask({
84
+ applicationType: "plugin_stopwatch",
85
+ taskName: "tick",
86
+ identifier: state,
87
+ data: { runId, kickedAt: Date.now() },
88
+ });
89
+ ```
90
+
91
+ The subscription is per instance and read-only; the token the platform mints for it carries only
92
+ the topics your schema declares. Treat messages as nudges: what the UI must still show after a
93
+ refresh comes from state, so a task that changes anything durable dispatches an event as well
94
+ (docs/07 has the whole loop, including the stop).
95
+
66
96
  ## Cross-app from the UI
67
97
 
68
98
  ```ts
@@ -28,7 +28,8 @@ tasks: [
28
28
  - `ctx.getState()` — the app's state, fresh.
29
29
  - `ctx.dispatchEvent(eventName, eventData)` — through the full pipeline (append, fold, watermark),
30
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.
31
+ - `ctx.notify(topic, data)` — publish on the app's realtime channel (see *Live tasks* below). A
32
+ nudge, never the truth: the UI hears it while mounted; a refresh only sees the timeline.
32
33
  - `ctx.logger`.
33
34
 
34
35
  ## The replay model — read this twice
@@ -42,8 +43,10 @@ replay; mint them inside. Step names are unique per logical operation; loops inc
42
43
  ## How a task gets kicked
43
44
 
44
45
  - **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.
46
+ - **From the browser or a tool**: list the task in `kickableTasks`, then
47
+ `kickPluginTask({ applicationType, taskName, identifier, data })` (from `esoul-sdk`; works in
48
+ a component and in a tool's `execute`). The platform's send-event route allows only listed
49
+ tasks and stamps nothing — `data` is exactly what `ctx.eventData` sees.
47
50
  - **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
48
51
  live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
49
52
  no per-app Inngest functions: function ids are fixed at module load, and the plan caps
package/llms-full.txt CHANGED
@@ -51,7 +51,7 @@ that.
51
51
  | Entry | What it gives you |
52
52
  |---|---|
53
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 |
54
+ | `esoul-sdk/react` | `usePluginEventDispatch`, `useAppCanEdit`, `usePluginCurrentChatId`, `useWorkspaceTools`, `usePluginRealtime`, file hooks |
55
55
  | `esoul-sdk/server` | `PluginServerModule`, `readAppState`, `callWorkspaceTool`, `emitPluginAppEvent`, `getPluginConnectionCredentials`, file provider types |
56
56
  | `esoul-sdk/testing` | a mock OAuth server for connection tests |
57
57
  | `esoul-app validate <dir>` | validates a package folder against the manifest schema |
@@ -491,6 +491,36 @@ The app renders inside the platform frame at any size: a desktop window, a maxim
491
491
  `look_at_app` in the workbench screenshots desktop-light, desktop-dark and phone-light. Open the
492
492
  images; a phone shot with a horizontal scrollbar is a bug.
493
493
 
494
+ ## Live data from a background task
495
+
496
+ ```tsx
497
+ import { usePluginRealtime } from "esoul-sdk/react";
498
+ import { kickPluginTask } from "esoul-sdk";
499
+ import { stopwatchChannel } from "./channel";
500
+
501
+ const live = usePluginRealtime<{ runId: string; elapsedMs: number; serverNow: number }>({
502
+ channel: stopwatchChannel,
503
+ workspaceId: state.workspaceId,
504
+ nodeId: state.nodeId,
505
+ topics: stopwatchChannel.topicNames,
506
+ });
507
+ // live.latestData → { topic: "tick", data: {...} } — the newest message, or null.
508
+ // live.data → everything received this mount, oldest first.
509
+
510
+ const start = () =>
511
+ kickPluginTask({
512
+ applicationType: "plugin_stopwatch",
513
+ taskName: "tick",
514
+ identifier: state,
515
+ data: { runId, kickedAt: Date.now() },
516
+ });
517
+ ```
518
+
519
+ The subscription is per instance and read-only; the token the platform mints for it carries only
520
+ the topics your schema declares. Treat messages as nudges: what the UI must still show after a
521
+ refresh comes from state, so a task that changes anything durable dispatches an event as well
522
+ (docs/07 has the whole loop, including the stop).
523
+
494
524
  ## Cross-app from the UI
495
525
 
496
526
  ```ts
@@ -628,7 +658,8 @@ tasks: [
628
658
  - `ctx.getState()` — the app's state, fresh.
629
659
  - `ctx.dispatchEvent(eventName, eventData)` — through the full pipeline (append, fold, watermark),
630
660
  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.
661
+ - `ctx.notify(topic, data)` — publish on the app's realtime channel (see *Live tasks* below). A
662
+ nudge, never the truth: the UI hears it while mounted; a refresh only sees the timeline.
632
663
  - `ctx.logger`.
633
664
 
634
665
  ## The replay model — read this twice
@@ -642,8 +673,10 @@ replay; mint them inside. Step names are unique per logical operation; loops inc
642
673
  ## How a task gets kicked
643
674
 
644
675
  - **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.
676
+ - **From the browser or a tool**: list the task in `kickableTasks`, then
677
+ `kickPluginTask({ applicationType, taskName, identifier, data })` (from `esoul-sdk`; works in
678
+ a component and in a tool's `execute`). The platform's send-event route allows only listed
679
+ tasks and stamps nothing — `data` is exactly what `ctx.eventData` sees.
647
680
  - **On a cadence**: `pollTasks: [{ task, everyMinutes }]` — one shared platform sweep kicks each
648
681
  live instance at 5-minute granularity (minimum 5). This is your cron. There are deliberately
649
682
  no per-app Inngest functions: function ids are fixed at module load, and the plan caps
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esoul-sdk",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Build ExternalSoul apps: event-sourced state, agent tools, server ops, background tasks, OAuth connections, files — the contract the Forge compiles against.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",