Skip to content

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..production is exactly what this tutorial adds.
  • tutorial/ (on main) — bulk files you copy along the way.
  • A running DioscHub and access to its admin portal.
  • Node 22+.
Terminal window
git clone https://github.com/intrigsoft/plynth
cd plynth
npm install
npm run dev # backend :3000 + frontend :5173

Create a project, open a document, draw a flowchart. Two properties matter for what follows:

  • The plynth_device cookie 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.

The kit loads from the hub (loader.js) — no package install. Copy the persistent rail:

Terminal window
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:

frontend/.env
VITE_DIOSC_HUB_URL=https://your-hub.example.com
VITE_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:

Terminal window
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 branch

artifact.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-key

Then 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 path

Register 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.

Terminal window
cp -r tutorial/step-4-mcp/mcp backend/mcp
npm install -w @plynth/backend
npm pkg set 'scripts.mcp:http=tsx mcp/http.ts' -w @plynth/backend

http.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:

Terminal window
# backend/.env — the MCP server
DIOSC_HUB_URL=https://your-hub.example.com
DIOSC_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 side
PLYNTH_MCP_URL=http://localhost:5174
MCP_BIND_SECRET=<same value>

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 in PersistentAssistant.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 from ai-registry.ts.

Verify the gate: npm run mcp:http -w @plynth/backend, then a tools/call without a valid JWT →

Terminal window
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 -3

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.

Terminal window
git diff production -- ':!tutorial'

An empty diff means you built exactly what ships. production also carries Dockerfile (app) + Dockerfile.mcp (the MCP server).