Tutorial: assistant-enable a diagram editor (React)
Plynth is a browser-based diagramming workspace — projects, documents, and seven editors (flowchart, sequence, class, component, use-case, ER, and a freeform canvas) on a shared editor bridge. In this tutorial you add a DioscHub assistant that both manages projects (server-side tools over the REST API) and edits the live diagram on the canvas (client-side tools through the bridge) — all on the same per-device sandbox the human is looking at.
This is the tutorial for rich client apps. It shows two things the others don’t: client-side tools that run in the browser and mutate app state directly (no round-trip to a server), and a device-scoped identity model where there’s no login at all — the sandbox is the identity.
The repository is intrigsoft/plynth:
main— the starting point. The bare diagramming app. This is the branch you clone.production— the finished result.git diff main..productionis exactly what this tutorial adds.tutorial/(onmain) — bulk files you copy along the way.
Prerequisites
Section titled “Prerequisites”- A running DioscHub and access to its admin portal.
- Node 22+.
Step 1 — Run the bare app
Section titled “Step 1 — Run the bare app”git clone https://github.com/intrigsoft/plynthcd plynthnpm installnpm run dev # backend :3000 + frontend :5173Create a project, open a document, draw a flowchart. Two properties matter for what follows:
- The
plynth_devicecookie keys a per-browser in-memory sandbox (store/device.ts); there’s no login. Whatever the assistant does must land on this device’s sandbox. - The editor bridge (
frontend/src/editors/editor-bridge.ts+ai-registry.ts) is a programmatic read/mutate surface over the live editor. It exists in the bare app — the assistant will drive it, but you can script it on its own.
Step 2 — Embed the assistant
Section titled “Step 2 — Embed the assistant”The kit loads from the hub (loader.js) — no package install. Copy the persistent rail:
cp tutorial/step-2-embed/PersistentAssistant.tsx frontend/src/workspace/components/PersistentAssistant mounts <diosc-chat> once, above the router, so its socket and session survive navigation:
const s = document.createElement('script');s.src = `${HUB}/api/embed/${API_KEY}/loader.js`;document.head.appendChild(s);// …renders <diosc-chat mode="embed" … bind-endpoint="/api/diosc/bind">Wire it in (all three edits are in tutorial/step-2-embed/frontend-wiring.md): wrap the router in <AssistantProvider> (App.tsx), add a toggle + reserve rail width (AppShell.tsx), and re-fetch on the assistant’s plynth:refresh event (WorkspaceProvider.tsx). Configure the public embed vars:
VITE_DIOSC_HUB_URL=https://your-hub.example.comVITE_DIOSC_EMBED_KEY=<embed key>VITE_DIOSC_ASSISTANT_ID=<assistant id>Verify: restart, toggle the Assistant — it holds a conversation. No identity or tools yet.
Step 3 — The bind endpoint (device identity)
Section titled “Step 3 — The bind endpoint (device identity)”Plynth has no login, so the “identity” is which device sandbox to act on. The bind endpoint mints a device-bound artifact. Copy the pieces:
cp tutorial/step-3-bind/diosc.controller.ts backend/src/projects/cp tutorial/step-3-bind/artifact.ts backend/src/store/cp tutorial/step-3-bind/device.ts backend/src/store/ # the complete version, with the Bearer branchartifact.ts is a small HMAC token carrying { deviceId, exp }. The bind controller mints it for the visitor’s device and hands it — not to the hub — to the MCP server (step 4):
const { artifact } = mintArtifact({ deviceId });// POST { connectionId: wsId, identity, artifacts: artifact }// to ${PLYNTH_MCP_URL}/bind with the admin key as x-admin-keyThen teach the device chokepoint to accept a forwarded artifact — add the machine branch to store/device.ts (the copied file already has it) before the cookie branch:
const auth = req.headers.authorization;if (auth?.startsWith('Bearer ')) { const claims = verifyArtifact(auth.slice(7).trim()); if (claims) { req.deviceId = claims.deviceId; return next(); } // act on the human's sandbox}// …else the normal cookie pathRegister the controller in projects.module.ts. The app holds no hub credentials — the bind target is the MCP server.
Step 4 — The MCP server (on the framework)
Section titled “Step 4 — The MCP server (on the framework)”The MCP server exposes the app as tools. Per the MCP authorization spec (2025-11-25) it must accept only tokens issued for itself and never pass a client token upstream — which is exactly what @dioschub/mcp-server implements, so you register tools instead of writing a broker.
cp -r tutorial/step-4-mcp/mcp backend/mcpnpm install -w @plynth/backendnpm pkg set 'scripts.mcp:http=tsx mcp/http.ts' -w @plynth/backendhttp.ts is just the framework wired to your tools. The device artifact you bound in step 3 is handed back to each tool as ctx.auth:
const server = createMcpServer<string>({ name: 'plynth', adminKey: process.env.MCP_BIND_SECRET!, // authenticates the app's /bind call jwtSecrets: process.env.MCP_JWT_SECRET!, // signs the framework's handle-JWT hub: { url: process.env.DIOSC_HUB_URL!, apiKey: process.env.DIOSC_HUB_API_KEY! },});registerPlynthTools(server); // each tool relays with ctx.auth (the artifact)server.listen(5174);The framework caches the device artifact under a jti, mints an audience-bound handle-JWT, and registers that with the hub — so the hub holds only the handle; the device artifact never leaves the app+server trust domain, and an invalid/expired handle → 401 → the hub’s re-auth interrupt. (One nuance the copied mcp/ folder already handles: the NestJS backend is CommonJS but the framework is ESM-only, so backend/mcp/package.json marks that folder "type": "module".)
Because the framework performs the hub bind, the admin auth:bind key lives here:
# backend/.env — the MCP serverDIOSC_HUB_URL=https://your-hub.example.comDIOSC_HUB_API_KEY=<diosc_ak_… admin key with auth:bind>MCP_JWT_SECRET=<strong random value>MCP_BIND_SECRET=<strong random value>PLYNTH_ARTIFACT_SECRET=<shared with the app so artifacts verify># the app sidePLYNTH_MCP_URL=http://localhost:5174MCP_BIND_SECRET=<same value>Two kinds of tools
Section titled “Two kinds of tools”Plynth’s tools split across the seam — this is the interesting part:
- Server tools (
backend/mcp/server.ts): project/document CRUD, relayed to the REST API carrying the exchanged artifact — so they land on the human’s sandbox. - Client-side tools (
browser_apply_changes, defined inPersistentAssistant.tsx): the assistant proposes a diagram diff; the kit dispatches it to a handler in the browser that applies it through the editor bridge — no server round-trip, and the canvas updates live. The per-editor operations come fromai-registry.ts.
Verify the gate: npm run mcp:http -w @plynth/backend, then a tools/call without a valid JWT →
curl -si -XPOST localhost:5174/mcp -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_projects"}}' | head -3Step 5 — Configure the hub
Section titled “Step 5 — Configure the hub”tutorial/step-5-hub/HUB-WIRING.md has the checklist: register the MCP server (http://<host>:5174/mcp, Streamable HTTP, no static auth), attach it, grant tools, approval-gate destructive ones.
Verify the full journey: open a flowchart and ask the assistant to add a “Deploy” step after “Build” and connect them. Watch the canvas update in place — that’s a client-side tool running through the editor bridge on your device’s sandbox. Then ask it to create a new project called “Q3 Planning” — a server tool, and the projects list refreshes (the plynth:refresh wire from step 2). Everything lands on the same sandbox you’re looking at, because the bound artifact resolved to your device.
Where you are
Section titled “Where you are”git diff production -- ':!tutorial'An empty diff means you built exactly what ships. production also carries Dockerfile (app) + Dockerfile.mcp (the MCP server).