esoul-sdk
v0.25.5
Published
Build a full product on ExternalSoul: your own tables with per-person rules, a viewer on every seam, app roles, access levels, realtime with audiences, durable tasks, and bindings to other apps.
Maintainers
Readme
esoul-sdk
esoul-sdk is a toolkit for building applications on ExternalSoul. An application written with it is compiled into the platform and runs as a native app: it has its own database tables, its own server endpoints, its own background tasks, its own realtime channel, its own vocabulary of roles, and a set of tools that agents call from chat, voice, the agent builder and MCP. Access control is declared in the application's manifest and enforced by the platform at every entry point; application code does not check permissions itself.
The package provides the types, the manifest schema and validator, the rule compiler, the request helpers and an in-memory test harness. Inside ExternalSoul the same import names resolve to the platform's implementations.
Useful links
- Getting started
- Manifest reference
- Documentation index
- A service on your computer, connected to your app
- CHANGELOG.md — what each version added
api-reference.md— every export with its signature and doc commentllms.txtandllms-full.txt— the documentation in one file, for coding models- The package on npm: https://www.npmjs.com/package/esoul-sdk
Features
Data model
- Tables are declared in the manifest (
db) and created on install; migrations are additive-only - Every query is scoped to the application instance and filtered for the caller by compiled rules
- Rows can be owned by their creator, by the workspace, or by a user across instances
- Fields can be sealed (encrypted at rest, readable only by the row's owner)
- Index kinds for equality, list containment and case-insensitive text search, with cursor paging
- Application state on the workspace timeline as a fold of typed events (replayable, scrubbable)
Access control
- A
vieweron every entry point: owner, member, visitor, anonymous, agent, or the app itself - Roles as the application's own words (
customer,staff, …), mapped from the platform's kinds - Per-surface access levels:
write,read,public,token, or a list of roles - Rules scoped by a person's typed attributes — a customer linked to a number sees only rows carrying it
- Roles composed by the workspace owner at runtime, inside an envelope the manifest declares
- Refusals distinguish
login-requiredfromforbidden, so a UI can show a sign-in wall
Server
- Ops: server-side functions with a declared input schema, shared with the derived tools
- Routes: HTTP endpoints mounted per instance, including server-sent event streams
- Token routes: endpoints a machine reaches with a bearer token the application minted
- Background tasks on a durable executor, with steps, retries and concurrency limits
- Webhooks, OAuth connections held by the platform, workspace files and file providers
- Access to other applications through bindings (typed contracts) and workspace tools
Realtime
- Per-instance channels with topics; each topic declares its audience (everyone, one person, one role)
- Tokens are issued per channel, so a subscriber never receives another audience's messages
Tools
- One declaration per op is used by the server, the derived tool and the UI
- Tools run on every surface: typed chat, voice, agent builder, MCP, and the browser
Development
- The Forge: a cloud workbench with a live preview, a persona switcher (view as), the application's tools callable before installation, tests and the full checks
- An in-memory database built from the manifest, with the same compiled rules production uses
- A validator for the package folder (
esoul-app validate)
Deployment
- Installed from the application's own GitHub repository at a commit
- Additive migrations applied on install; the install card lists every public surface
Installation
npm install --save-dev esoul-sdkOutside ExternalSoul the package gives you types, the manifest validator, the rule compiler and the
test helpers. Functions that need the platform (pluginDb, viewerProfile, mintRouteToken, …)
throw host only when called outside it; they are exercised through the test harness or in a
Forge workbench.
Requirements: Node.js 20 or newer (22 for esoul-device on a computer), TypeScript 5, React 18 or
newer for the UI entry point.
Concepts
Application. A folder containing a manifest (plugin.json), a schema (app.tsx), a UI, and
optionally a server module (server.ts) and tests. The folder name is the application id. The
application type is plugin_ followed by the id with underscores.
Manifest. plugin.json declares everything the platform enforces: tables, roles, access levels
of ops, routes and tasks, realtime topics, bindings, connections and dependencies. It is validated
against schemas/plugin.schema.json.
Events and state. Application state is the result of folding typed events from the workspace
timeline. Each event has a dataCreator (mints ids and timestamps) and a processor (a pure
reducer). The same events are dispatched by the UI, by tools and by tasks.
Viewer. The identity of the caller, resolved by the platform for every op, route, task, tool and UI render. A viewer has a kind, an account id, a set of ids it has acted under, the application's role word for it, and whether it may write.
Op. A server-side function of the application, called from the UI or from a tool. Its input
schema is declared once with defineOps; the server parses with handleOp and the tool is derived
with opTool.
Route. An HTTP endpoint the application mounts at /api/plugins/<id>/route/<name>.
Task. A background job on the platform's durable executor. A task is kicked by an event, runs in steps that are memoised across retries, and may dispatch events and publish realtime messages.
Topic. A named message stream on the application's realtime channel. Its audience is declared in the manifest.
Binding. A slot the application declares (uses) and the workspace owner fills with another
application that provides the named contract.
Package entry points
| Import | Contents |
|---|---|
| esoul-sdk | Schema and event types, defineOps, handleOp, opTool, definePluginChannel, defineBindingEvent, checkBinding, resolveAppRole, chartSvg, incompleteStateNotice, deterministicReducerId, callPluginOp, kickPluginTask, pluginRouteUrl, nanoid, the manifest schema, compileEnvelope, compileCustomRole |
| esoul-sdk/server | pluginDb, viewerProfile, sseStream, mintRouteToken, readAppState, callWorkspaceTool, computer, devices, questions, credentials, llm, filesForOp, pluginFiles, emitPluginAppEvent, generateAppImage, renderChartImage, setAppRole, listAppRoles, defineAppRole, removeAppRole, getPluginConnectionCredentials, and the context types (PluginOpContext, PluginRouteContext, PluginViewer, PluginServerModule) |
| esoul-sdk/react | useViewer, useSignInWall, useAppCanEdit, usePluginEventDispatch, runOptimistic, useAppNav, usePluginRealtime, useWorkspaceTools, useRemoteReconcile, useCredential, <ConnectAccount/>, <ConnectComputer/>, useDevices, useDeviceRun, useDeviceActions, the file hooks (useFileSources, useFileSourceEntries, useFolderAutocomplete, useFileUrls, useTransfer, usePluginWorkspaceFiles, usePluginFileUpload), ImageLabeler; the types PluginViewerPublic, SignInWall |
| esoul-sdk/machine | what runs ON the person's computer: the Program type for a device program (types only), and the esoul-device runtime |
| esoul-sdk/testing | memoryDb, fakeViewer, runOp, fakeApps, capture, memoryFiles, scriptedModel, simGmail, fakeCredentials, startMockOAuth, listFailedRequests |
| esoul-sdk/schemas/plugin.schema.json | The JSON Schema of the manifest |
| esoul-app validate <dir> | Command-line validator for a package folder |
| esoul-device | The runtime on a person's computer: connect, status, sh, config set, disconnect, uninstall (docs/18) |
Getting started
The recommended way to build an application is the Forge, a workbench inside ExternalSoul. It does not require a checkout of the platform.
- Add a Forge board to a workspace.
- Ask the assistant to open a workbench for your application. The platform starts a cloud machine with the platform's source, scaffolds the application folder, and shows a live preview on the board.
- Edit files with the board's tools (
write_app_file,edit_app_file). Each change reloads the preview and reports its health. - Inspect the result:
look_at_apprenders the application on desktop and phone, light and dark, as any persona (owner,member,visitor-a,anonymous, or a composed role such asrole:packer).call_app_toolruns the application's tools before installation.test_appruns the tests;check_appruns the full gate. - Push the application to its own GitHub repository from the board, and install it from Settings → Apps in any workspace.
From your own editor, npm install --save-dev esoul-sdk gives you the same types and the
validator, and lets you run tests locally with esoul-sdk/testing.
Package layout
<id>/
plugin.json the manifest
ops.ts op input schemas (defineOps)
app.tsx the schema: events, state, tools, state description
ui/<id>-ui.tsx the React UI ("use client")
server.ts ops, routes, webhooks
<id>.test.ts tests
.esoul/ generated: db.d.ts, rules.json, migration.sqlExample: a shop
The reference application is a shop. Anyone with the link may browse the catalogue. A signed-in customer places orders and sees only their own. Staff see every order of the shop. The owner sets prices and decides who is staff, and may compose narrower roles at runtime — for example a packer who sees only orders being prepared, never the customer's note, and may only mark them shipped.
Manifest
{
"manifestVersion": 1,
"id": "shop",
"name": "Shop",
"version": "1.0.0",
"applicationType": "plugin_shop",
"entry": "app",
"roles": {
"vocabulary": ["customer", "staff", "owner"],
"default": {
"owner": "owner",
"member-edit": "staff",
"member-readonly": "staff",
"visitor": "customer",
"anonymous": "customer",
"agent": "inherit"
},
"custom": {
"models": {
"Order": { "where": ["status"], "hide": ["note"], "update": { "transitions": "status" } }
},
"ops": ["set-order-status"]
}
},
"ops": {
"browse": { "access": "public" },
"place-order": { "access": "public", "requires": "account" },
"list-orders": { "access": "public", "requires": "account" },
"set-order-status": { "access": ["staff", "owner"] },
"add-product": {}
},
"routes": {
"order-updates": { "access": "read" }
},
"channel": {
"topics": {
"catalogue": {},
"order-status": { "audience": "viewer", "mayAddress": ["staff", "owner"] },
"new-order": { "audience": "role:staff", "mayAddress": ["customer", "staff", "owner"] }
}
},
"db": {
"Product": {
"scope": "instance",
"fields": { "name": "string", "priceCents": "int", "active": "boolean=true", "tags": "string[]" },
"indexes": [["active", "createdAt"], { "fields": ["tags"], "kind": "contains" }, { "fields": ["name"], "kind": "text" }],
"rules": { "read": "anyone", "write": ["owner"] }
},
"Order": {
"scope": "instance",
"owner": "creator",
"fields": { "status": "string=new", "totalCents": "int", "lines": "json", "shipTo": "json", "note": "text?" },
"indexes": [["status"], ["createdAt"]],
"rules": {
"read": ["creator", "staff", "owner"],
"create": { "roles": ["customer", "staff", "owner"], "requires": "account" },
"update": { "roles": ["staff", "owner"], "creatorMay": ["note"] },
"delete": ["owner"]
}
},
"Address": {
"scope": "user",
"fields": { "label": "string", "lines": "json" },
"sealed": ["lines"]
}
}
}An op with no access is write: only the owner and edit members may call it. "access":
"public" admits anyone who can reach the application, including through a share link;
"requires": "account" makes an anonymous call fail with login-required instead of forbidden.
Op inputs
// ops.ts
import { z } from "zod";
import { defineOps } from "esoul-sdk";
export const ORDER_STATUSES = ["new", "preparing", "shipped", "fulfilled", "refunded"] as const;
export const ops = defineOps({
browse: z.object({}),
"add-product": z.object({ name: z.string().min(1).max(80), priceCents: z.number().int().min(0) }),
"place-order": z.object({
lines: z.array(z.object({ productId: z.string().min(1), qty: z.number().int().min(1).max(99) })).min(1),
shipTo: z.object({ name: z.string().min(1), street: z.string().min(1), city: z.string().min(1) }),
note: z.string().max(280).optional(),
}),
"list-orders": z.object({}),
"set-order-status": z.object({
orderId: z.string().min(1).describe("The order id, as list_orders shows it"),
status: z.enum(ORDER_STATUSES),
}),
});Server
// server.ts
import "server-only";
import { handleOp } from "esoul-sdk";
import { pluginDb, type PluginOpContext, type PluginServerModule } from "esoul-sdk/server";
import type { ShopDb } from "./.esoul/db";
import { ops } from "./ops";
const db = (ctx: PluginOpContext) => pluginDb<ShopDb>(ctx);
export const pluginServer: PluginServerModule = {
ops: {
browse: handleOp(ops, "browse", async (ctx) => {
const d = await db(ctx);
return d.product.findMany({ where: { active: true }, orderBy: { createdAt: "asc" }, take: 200 });
}),
"add-product": handleOp(ops, "add-product", async (ctx, input) => {
const d = await db(ctx);
return d.product.create({ data: { name: input.name, priceCents: input.priceCents, tags: [] } });
}),
"place-order": handleOp(ops, "place-order", async (ctx, input) => {
const d = await db(ctx);
// Price on the server, from the catalogue.
const products = await d.product.findMany({ where: { id: { in: input.lines.map((l) => l.productId) } } });
const totalCents = input.lines.reduce((sum, l) => sum + l.qty * (products.find((p) => p.id === l.productId)?.priceCents ?? 0), 0);
const order = await d.order.create({ data: { status: "new", totalCents, lines: input.lines, shipTo: input.shipTo, note: input.note } });
await ctx.notify("new-order", { orderId: order.id });
return { orderId: order.id, totalCents };
}),
"list-orders": handleOp(ops, "list-orders", async (ctx) => {
const d = await db(ctx);
return d.order.findMany({ orderBy: { createdAt: "desc" } });
}),
"set-order-status": handleOp(ops, "set-order-status", async (ctx, input) => {
const d = await db(ctx);
const order = await d.order.update({ where: { id: input.orderId }, data: { status: input.status } });
await ctx.notify("order-status", { orderId: order.id, status: order.status }, { to: { viewerIds: [order.ownerId] } });
return order;
}),
},
};There is no access check in this file. pluginDb(ctx) returns a client scoped to the instance and
filtered for ctx.viewer. A customer's order.findMany returns their own rows; a staff member's
returns every row of the shop. A customer calling set-order-status is refused before the handler
runs, because the manifest opens it to staff and owner only. A packer — a composed role scoped
to status: ["preparing"] — receives only those rows, with note blanked, and an update from
preparing to anything other than shipped is refused with the allowed move named.
Tools
// app.tsx (excerpt)
import { opTool } from "esoul-sdk";
import { ops } from "./ops";
toolkitCreator: (identifier) => {
const base = identifier.instanceName.replace(/[^a-zA-Z0-9]/g, "_");
const cfg = { pluginId: "shop", nodeId: identifier.nodeId };
return {
[`browse_${base}`]: opTool(ops, "browse", {
...cfg,
description: `List the products of "${identifier.instanceName}".`,
readOnly: true,
publicSafe: true,
say: (rows) => ({ text: rows.map((p) => `${p.name} — ${(p.priceCents / 100).toFixed(2)} (id ${p.id})`).join("\n") || "No products." }),
}),
[`set_order_status_${base}`]: opTool(ops, "set-order-status", {
...cfg,
description: `Move an order of "${identifier.instanceName}" to a new status.`,
say: (o) => ({ text: `Order ${o.id} is now ${o.status}.` }),
}),
};
},The tool's parameters are the op's input schema; a field the op does not declare is refused with the field named. The tool runs as the person who invoked it, on every surface.
UI
// ui/shop-ui.tsx (excerpt)
"use client";
import { callPluginOp } from "esoul-sdk";
import { useSignInWall, useViewer } from "esoul-sdk/react";
export function ShopUi({ nodeId }: { nodeId: string }) {
const viewer = useViewer(); // { kind, userId, role, canEdit, signedIn, customRole, can }
const wall = useSignInWall();
const order = async (lines, shipTo) => {
try {
return await callPluginOp("shop", "place-order", nodeId, { lines, shipTo });
} catch (err) {
wall.raise(err); // shows the sign-in wall on `login-required`, rethrows otherwise
}
};
// viewer.can.models.Order.moves tells a packer's screen which buttons to show;
// the server enforces the same rule regardless.
…
}Test
// shop.test.ts
import { fakeViewer, memoryDb, runOp } from "esoul-sdk/testing";
import manifest from "./plugin.json";
import { pluginServer } from "./server";
const owner = fakeViewer("owner");
const ada = fakeViewer("visitor", { userId: "u_ada", role: "customer" });
const lin = fakeViewer("visitor", { userId: "u_lin", role: "customer" });
it("a customer sees only their own orders", async () => {
const db = memoryDb(manifest);
const { result: p } = await runOp(pluginServer, "add-product", { viewer: owner, args: { name: "Tea", priceCents: 350 }, db: db.as(owner) });
const shipTo = { name: "Ada", street: "Main 1", city: "Brno" };
await runOp(pluginServer, "place-order", { viewer: ada, args: { lines: [{ productId: p.id, qty: 1 }], shipTo }, db: db.as(ada) });
const { result } = await runOp(pluginServer, "list-orders", { viewer: lin, args: {}, db: db.as(lin) });
expect(result).toEqual([]);
});
it("an anonymous caller is asked to sign in", async () => {
const db = memoryDb(manifest);
const anon = fakeViewer("anonymous");
await expect(runOp(pluginServer, "list-orders", { viewer: anon, args: {}, db: db.as(anon) })).rejects.toMatchObject({ code: "login-required" });
});memoryDb(manifest) builds an in-memory database from the manifest with the same compiled rules the
platform applies to the real database. runOp calls the op with a platform-shaped context and
records what it notified and emitted.
API reference
Manifest (plugin.json)
| Field | Type | Description |
|---|---|---|
| manifestVersion | 1 | Schema version. |
| id | string | Application id: lower-case letters, digits, hyphens. Equals the folder name. |
| name, description, version, icon | string | Shown in the store and the install card. version is semver. |
| applicationType | string | plugin_ + id with underscores. Never changes after release. |
| entry | string | The schema module (app). |
| roles.vocabulary | string[] | The application's role words. |
| roles.default | object | Mapping from platform kinds (owner, member-edit, member-readonly, visitor, anonymous, agent) to role words. "inherit" for agent uses the role of the person it acts for. |
| roles.describe | object | One line per role, for the install card. |
| roles.attributes | object | Name → string, int or boolean: what a grant may say about a person. Typed when granted; carried on viewer.attrs; read by scoped rules as viewer.<name>. |
| roles.custom | object | The envelope for roles the owner composes: per model, which indexed fields may scope (where), which fields may be hidden (hide), which fields and transition field may be updated (update); ops a composed role may be handed; attributes a grant may carry. |
| ops | object | Op name → { access, requires }. access: write (default), read, public, or a list of role words. requires: "account" turns an anonymous refusal into login-required. |
| routes | object | Route name → { access }, additionally token for routes reached with a minted bearer token. |
| kickableTasks | object | Tasks the browser may kick, with their access level. |
| pollTasks | array | [{ task, everyMinutes }]: tasks the platform kicks on a schedule (5 to 1440 minutes). |
| channel.topics | object | Topic name → { audience, mayAddress, description }. audience: all (default), viewer, or role:<word>. mayAddress: roles that may publish on the topic. |
| db | object | Model name → table declaration (see below). |
| uses | object | Slot name → { contract, label, optional }. |
| provides | object | Contracts this application provides, with the tools, events and tables each names. |
| connections | array | OAuth or API-key connections the platform holds for the application. |
| webhooks | string[] | Webhook names handled in server.ts. |
| workspaceTools | array | Tools of other applications the server side may call. |
| platformApi | object | { min, max? }: the platform contract versions the application accepts. |
npm packages the application needs are declared in a package.json in the application folder
with a dependencies field only.
Table declaration:
| Field | Description |
|---|---|
| scope | instance (rows belong to this instance), workspace, or user (rows follow the account across instances). |
| owner | creator marks each row with the creator's ids so rules can name creator. |
| fields | Name → type: string, text, int, float, boolean, json, datetime, string[], ref:<Model>; ? for optional, =value for a default. |
| unique, indexes | Field groups. An index entry may be { fields, kind } with kind btree (default), contains (list membership) or text (case-insensitive substring). |
| sealed | Fields encrypted at rest and readable only by the row's owner. |
| rules | read, create, update, delete: "anyone", a list of principals (creator, role words, or a scoped role { "role": "customer", "where": { "customer": "viewer.customer" } }), or { roles, requires, creatorMay }. A scoped role reads only the rows its where names, creates inside them, and grants nothing to a holder without the attribute. A model without rules is writable by the owner role only. |
Platform columns on every table: id, workspaceId, nodeId, ownerId, createdBy,
createdAt, updatedAt, deletedAt.
esoul-sdk
| Export | Description |
|---|---|
| ApplicationSchema, EventDefinition, EventTypes, ApplicationIdentifier, ApplicationPort | The schema contract: events, stateCreator, toolkitCreator, getStateDescription, tasks, channel, reconstructStateFromEventLog. |
| defineOps(inputs) | Declares the input schema of every op once. |
| handleOp(ops, name, fn) | Wraps an op handler so it receives parsed input; an undeclared or malformed field is refused as invalid, naming the field. |
| opTool(ops, name, { pluginId, nodeId, description, say, then?, readOnly?, publicSafe? }) | Derives an agent tool from an op. say turns the op's result into the tool's text; readOnly admits a read-scoped token; publicSafe allows use on a public storefront. |
| definePluginChannel({ applicationType, topics }) | Declares the realtime channel placed on the schema as channel. |
| defineBindingEvent, checkBinding, bindingsOf | Bindings: the event that records a slot being filled, the check that a provider satisfies a contract, and the current bindings from state. |
| resolveAppRole, roleKeyFor | The mapping from platform kinds to role words that the platform itself uses. |
| compileEnvelope, compileCustomRole, CustomRoleError | The compiler for composed roles, for tests that assert on them. |
| coerceAttr, coerceAttrs, attrProblem, ATTR_TYPES | Attribute typing: what a typed value becomes, and the sentence for one that does not fit. |
| chartSvg(spec) | Renders a time-series chart with panels, markers and gaps to SVG; text is drawn as glyph outlines so it renders on a server without fonts. |
| incompleteStateNotice, missingStateKeys | For getStateDescription: reports a state that did not load instead of describing it as empty. |
| deterministicReducerId(prefix, seed) | A stable id for use inside a reducer. |
| callPluginOp(pluginId, op, nodeId, args?) | Calls an op from the browser or from a tool. Throws PluginCallError carrying code (invalid, forbidden, login-required), detail and signInPath. |
| kickPluginTask(args), pluginRouteUrl(...) | Kicks a task; builds a route's URL. |
| timingSafeEqual, nanoid | Utilities. |
esoul-sdk/server
| Export | Description |
|---|---|
| PluginServerModule | { ops, routes, webhooks } exported as pluginServer from server.ts. |
| PluginOpContext | pluginId, opName, workspaceId, nodeId, instanceName, viewer, args, origin, cloudConnectionId, apps (bound applications), notify(topic, data, { to? }), emit(eventName, eventData). |
| PluginRouteContext | The op context plus request, method, searchParams, canWrite. Handlers return a Response. |
| PluginViewer | kind, userId, viewerIds, role, customRole, attrs, canWrite, shareId, agent. |
| pluginDb<T>(ctx) | A typed client over the application's tables, scoped and filtered for ctx.viewer. Per model: findMany, findUnique, count, aggregate, groupBy, create, createMany, update, updateMany, upsert, delete, deleteMany; $transaction. Refusals throw with code forbidden, login-required or invalid. |
| viewerProfile(viewer) | The caller's own account (name, email, picture), or null. |
| sseStream(fn) | A server-sent event response for a route. |
| mintRouteToken(ctx, { route, ttlSeconds?, label? }) | Mints a bearer token for a route declared "access": "token". Returns { token, url, expiresAt }; url is built on ctx.origin. Expiry is the only revocation; 30 days at most. |
| readAppState(nodeId) | The folded state of another application in the workspace. |
| callWorkspaceTool({ ... }) | Calls a tool of another application, gated by the manifest's workspaceTools. |
| computer(ctx, machineNodeId) | A paired machine: status, run, runToEnd, claude, claudeToEnd, readFile, fetchJson, python, runJson. Waits are capped and the approval gate is reported. |
| emitPluginAppEvent(args) | Appends an event to the application's own timeline from server code. |
| generateAppImage(ctx, args) | Generates an image, scales it to web weight, stores it and returns its URL. Refused in a Forge preview. |
| renderChartImage(ctx, { name, svg }) | Renders an SVG to PNG and returns { url } (installed) or { base64 } (preview). |
| setAppRole(ctx, { email, role, attrs? }) | The owner gives an account one of the application's role words, or a composed role, with attribute values ({ customer: "204" }) validated against roles.attributes. Recorded as an event. |
| listAppRoles(ctx) | { people, roles, custom, envelope }. |
| defineAppRole(ctx, definition), removeAppRole(ctx, name) | The owner composes or removes a role inside roles.custom. |
| credentials(ctx).slot(name) | The person's account for a declared slot — Google, any OAuth 2.0 provider (Microsoft, Dropbox…) or a pasted API key: status() and fetch(url, init?), with the token or key added by the platform and sent only where the slot may reach. Your code never holds it (docs/08). |
| getPluginConnectionCredentials(connectionId, pluginId) | Older: the token of one of the app's own connections, readable only from the app's own running call for the workspace owner's connection. Prefer a credentials slot (docs/08 §9). |
| pluginFiles(ctx), filesForOp(ctx) | Workspace files and file sources from server code: read by ref or address ("workspace:/Mail/a.pdf"), save to a path, and transfer a list of files into a folder with platform progress, Stop and a clean Retry (docs/09). |
esoul-sdk/react
| Hook | Description |
|---|---|
| useViewer() | The public viewer: kind, userId, role, canEdit, signedIn, attrs, customRole, can. can lists the ops a composed role may call and, per model, the hidden fields, updatable fields and allowed moves. |
| useSignInWall() | { needed, reason, signIn, raise, ask, serverSays }. raise(err) shows the wall when err.code === "login-required" and rethrows anything else; ask(call) attempts a call and returns null (wall raised) on login-required; serverSays is the server's answer so far (null, "account", "no-account"). |
| useAppCanEdit() | Whether the current viewer may write. |
| usePluginEventDispatch() | Dispatches one of the application's events from the UI. |
| usePluginRealtime({ channel, workspaceId, nodeId, topics, enabled? }) | Subscribes to the instance's channel. Returns { data, latestData, error, state }. |
| useWorkspaceTools(identity) | Lists and calls tools of other applications from the UI, subject to workspaceTools. |
| usePluginWorkspaceFiles(), usePluginFileUpload(), useFileSources(), useFileSourceEntries() | Workspace files, upload, and file sources (workspace, Google Drive, providers). |
| useTransfer(transferId) | A file transfer's progress, with retry() and cancel(). |
esoul-sdk/testing
| Export | Description |
|---|---|
| memoryDb(manifest, options?) | An in-memory database compiled from the manifest. Starts as the application's own code; .as(viewer) returns the same rows as another caller. $rules exposes the compiled rules. |
| fakeViewer(kind, { userId?, viewerIds?, role?, name?, email?, attrs? }) | A viewer of a kind. name/email are what viewerProfile returns for it; attrs are what a scoped rule reads. |
| runOp(server, opName, { viewer, args?, db?, apps?, notifyFails? }) | Calls pluginServer.ops[opName] with a platform-shaped context. Returns { result, notified, emitted }; throws the op's refusals. |
| fakeApps(fixtures) | Bound applications for ctx.apps. |
| capture() | A recorder for callbacks. |
| fakeCredentials(slots) | A stand-in for credentials(ctx): each slot answers as the test says (status, hosts, fetch), the platform's refusals apply, and calls records every request (docs/08 §7). |
| startMockOAuth(opts?) | A local OAuth server for connection tests. |
Command line
npx esoul-app validate <dir>Exits 0 when the folder is a well-formed application package; otherwise prints each problem.
Rules
Note. State is a fold of events. A processor must be a pure, idempotent function of
(state, event). Ids and timestamps are minted indataCreator, never in a processor. An event that replaces a whole value carries a collapse key so a burst folds to one event.
Note. Access is declared, not checked. An op, route or task that tests the viewer itself has a second answer to a question the platform already answered; the platform's answer is the one enforced. Use
viewer.roleandviewer.canto decide what a screen shows, not what a request may do.
Warning. A task handler is re-run from the top after every step boundary. Every side effect — a dispatch, a fetch, an emit, a random id — belongs inside
ctx.step.run. A side effect outside a step runs once per replay.
Warning. Application code imports the platform only through
esoul-sdk. A relative import into the platform's source is refused by the checks and by the install.
Note.
viewer.signedInin the UI is the page's estimate. Whether a request needs an account is decided by the op; attempt the call and letuseSignInWall().raiseact onlogin-required.
Documentation
| | |
|---|---|
| 1. Getting started | The Forge loop, package layout, the smallest complete application |
| 2. The manifest | Every field of plugin.json |
| 3. Events and state | dataCreator, processor, collapse keys, replay |
| 4. Tools | Tools on every surface; deriving a tool from its op |
| 5. The UI | React, hooks, theme, responsive rules |
| 6. The server | Ops, routes, webhooks, computer, charts, calling other applications |
| 7. Background tasks | The durable executor, the replay model, concurrency |
| 8. Accounts | The person's Google, Microsoft or any OAuth 2.0 / API-key account, used through the platform without your code holding the token |
| 9. Files | Workspace files, Drive, file providers |
| 10. Testing | The fold contract, ops, tasks and rules as tests |
| 11. Shipping | The repository, review, release, install, dependencies |
| 12. Rules and failures | Each rule with the failure it prevents |
| 13. People and access | Viewer, roles, access levels, composed roles, the sign-in wall |
| 14. Your own tables | db, rules, scopes, sealed fields, indexes, migrations |
| 15. Realtime | Topics, audiences, addressing |
| 16. Bindings | Slots, contracts, reaching another application |
| 17. Editing and merging | An editor that takes changes from other devices and agents; merges |
| 18. Your computer | The device arm: commands, the workspace, programs, esoul-device |
| 19. Model calls | llm(ctx): billed model calls, budgets, scriptedModel() for tests |
| 20. A service on your computer | A walk-through: a program that keeps a service running on the person's computer, talking to the app both ways — built in the Forge, then installed |
Versions
From 0.20.0 on, every version is in CHANGELOG.md (accounts through the platform — Google, then any OAuth 2.0 or API-key provider in 0.25.0, model calls, file transfers, opened-from-outside navigation, server-only events, the questions bell, mail waits). Earlier:
| Version | Changes |
|---|---|
| 0.16.0 – 0.19.1 | runOptimistic and the failed-request banner; stable list paging; editor sync (useRemoteReconcile, mergeLines / mergeFields / mergeRecordsById); the device arm — devices(ctx), <ConnectComputer/>, the esoul-device runtime, declared commands, the shared command line, an app's own program on the computer (docs/17, 18). |
| 0.15.0 | Files work in a Forge box, and do more: FilesApi.list(…, opts) narrowed at the source, listAll, resolvePath, readMany, write (make or replace by name — a label file beside its image; manifest fileSources.write), readGrant + fileGrantUrl (signed, expiring URLs for an <img> in a preview and for a machine). UI: useFolderAutocomplete, useFileUrls, useFileSourceEntries(…, opts) with loadMore, listFileEntries, resolveFilePath. ImageLabeler / PolygonCanvas — the Explorer's polygon editor as a component. Pure: pairImagesWithLabels, serializeLabelMe, parseLabelMe, labelFileNameFor. Testing: memoryFiles. |
| 0.14.0 | Attribute-scoped access: roles.attributes (typed; validated when granted; viewer.attrs); scoped rule principals { role, where } with viewer.<attribute>, applied to reads, aggregates, updates and deletes and filling or refusing creates, in both database clients; a rule combining creator with a scoped role is refused. compileManifestRules is the one reader of a manifest's rules. VIEW AS personas carry attributes (visitor-a?customer=204). |
| 0.13.0 | Composed roles: roles.custom envelope; defineAppRole, removeAppRole; setAppRole with attributes; listAppRoles returns composed roles and the envelope. Reads, updates and deletes are narrowed by the composition in both database clients; surfaces not handed to the role are refused; useViewer().customRole and can. access: [<roles>] on a surface. Composed roles are personas in the Forge. |
| 0.12.0 | Token routes: "access": "token" and mintRouteToken. ctx.origin on ops. |
| 0.11.0 | chartSvg, renderChartImage. computer().python() and runJson() with truncated outputs. APPROVAL_WAIT. Table refusals name the constraint. |
| 0.10.0 | computer(ctx, machineNodeId). Server code in a Forge preview reaches the workspace (callWorkspaceTool, readAppState, computer), gated by workspaceTools. |
| 0.9.0 | defineOps, handleOp, opTool: one declaration for an op's input, its server parser and its tool. |
| 0.8.0 | generateAppImage; setAppRole and listAppRoles; useSignInWall().ask() and serverSays. |
| 0.7.0 | Index kinds (contains, text) and cursor paging; ctx.emit; list field defaults; aggregate, groupBy, $transaction, *Many documented. |
| 0.6.0 | Tables (db, pluginDb, rules, scopes, sealed fields, additive migrations). viewer on every entry point, roles, access levels, the sign-in wall. Realtime audiences. Bindings. viewerProfile. Testing: memoryDb, fakeViewer, runOp. |
| 0.5.0 | Routes, sseStream, realtime channels. |
| 0.3.0 | Renamed from @externalsoul/plugin-sdk. readAppState, callWorkspaceTool. The import wall. |
| 0.2.0 | File sources and providers. |
Releasing (maintainers)
cd packages/esoul-sdk
npm run release:check # fresh docs + build, dist completeness, pack, clean-room install, every entry point, the CLI
npm publish # prepack rebuilds docs and dist; the owner's npm login
npm view esoul-sdk versionThe check refuses a version that is already on npm and a dist/ that lacks a module src/ has.
Commit the regenerated llms.txt, llms-full.txt and api-reference.md with the version bump.
