Tutorial: assistant-enable a task app (SvelteKit)
Cadence is a complete SvelteKit kanban app — boards, lists, cards, server-enforced per-board permissions, and an audit trail. In this tutorial you start from that working app and add a DioscHub assistant that acts as the signed-in user: same permission checks, same audit trail, no new privileges.
Where the Northwind tutorial forwards a session cookie and installs the SDK from a package, Cadence shows the other half of the design space:
- the kit loads from the hub (
loader.js) — no npm dependency at all, - the app’s internal credential is a purpose-minted HMAC artifact, not a raw session cookie,
- the host’s auth chokepoint gains an explicit agent-as-user branch, so agent actions run through the exact same domain gates as human ones — distinguished only for audit attribution.
The repository is intrigsoft/cadence:
main— the starting point. The bare kanban 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, one directory per step.
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/cadencecd cadencenpm installnpm run dev # http://localhost:5173Sign in as one of the demo personas and move some cards. Two properties of the app matter for everything that follows:
- Per-device sandbox — there is no database; each browser gets its own in-memory copy of the seed, keyed by an httpOnly
cadence_devicecookie. Whatever identity later acts “for you” must act on your sandbox. locals.actor— every request resolves an actor insrc/hooks.server.ts, and every domain/permission check reads it. That single chokepoint is where the assistant will plug in.
The app also exposes its domain as a machine REST API (/api/v1/*, uniform {ok, data} | {ok, code, message} responses) — the surface the MCP relay will drive.
Step 2 — Embed the assistant panel
Section titled “Step 2 — Embed the assistant panel”Copy the panel and mount it:
cp tutorial/step-2-embed/AssistantPanel.svelte src/lib/In src/routes/(app)/+layout.svelte, import and render it next to the main content; in src/lib/stores.ts, add the open/closed store; in src/lib/TopBar.svelte, add a toggle button:
export const assistantOpen = persisted<boolean>('cadence_assistant_open', true);The panel loads the kit from the hub — no package install:
onMount(() => { const s = document.createElement('script'); s.src = `${hub}/api/embed/${apiKey}/loader.js`; s.async = true; document.head.appendChild(s);});<diosc-chat mode="embed" api-key={apiKey} backend-url={hub} assistant-id={assistantId} bind-endpoint="/api/diosc/bind"></diosc-chat>The loader sees the existing <diosc-chat> element and skips its own floating-button injection; every setting is a plain attribute. The copied panel also wires three host-side niceties worth reading — an async @mention provider (boards + card search against /api/v1), a navigate tool override that routes through SvelteKit’s goto() instead of a full reload, and a tool:completed listener that calls invalidateAll() so the board UI refreshes when the assistant changes data.
In the admin portal, create an Assistant and generate its embed key, then configure:
PUBLIC_DIOSC_HUB_URL=https://your-hub.example.comPUBLIC_DIOSC_EMBED_KEY=<embed key>PUBLIC_DIOSC_ASSISTANT_ID=<assistant id>Verify: the Assistant toggle appears in the top bar and the panel holds a conversation. It has no identity and no tools yet.
Step 3 — BYOA artifacts and the agent-as-user seam
Section titled “Step 3 — BYOA artifacts and the agent-as-user seam”Northwind’s session artifact is a cookie. Cadence mints a dedicated artifact: an HMAC-signed token binding {deviceId, userId, exp} — the sandbox and identity an agent may act on, and nothing else. This is the shape to reach for when your production system has real SSO: the artifact is whatever your backend can later verify.
Copy the artifact module and its routes:
cp tutorial/step-3-artifacts/artifact.ts src/lib/server/api/mkdir -p src/routes/api/v1/auth/artifact src/routes/api/v1/dev/artifactcp tutorial/step-3-artifacts/auth-artifact-route.ts src/routes/api/v1/auth/artifact/+server.tscp tutorial/step-3-artifacts/dev-artifact-route.ts src/routes/api/v1/dev/artifact/+server.tscp tutorial/step-3-artifacts/artifact.test.ts src/lib/server/__tests__/Then teach the auth chokepoint to accept it. In src/hooks.server.ts, add a machine branch before the cookie branch (the finished file is in tutorial/step-3-artifacts/hooks.server.ts):
// MACHINE: an /api/* request carrying a bearer artifact resolves agent-as-user.if (isApi && bearer?.startsWith('Bearer ')) { const claims = verifyArtifact(bearer.slice(7).trim()); const state = claims ? getDevice(claims.deviceId) : null; const user = state && claims ? state.users[claims.userId] ?? null : null; if (state && user) { event.locals.state = state; event.locals.actor = { userId: user.id, isAgent: true }; // ← the only difference return resolve(event); } event.locals.actor = null; // invalid artifact → routes answer 401 return resolve(event);}Downstream, nothing distinguishes agent from human beyond actor.isAgent — same domain functions, same permission gates. That flag exists for one purpose: audit attribution (the activity feed labels agent actions and shows acting as whom).
CADENCE_ARTIFACT_SECRET=<a strong random value>CADENCE_DEV_ARTIFACT=1 # dev-only headless mint route, for the next verifyVerify: npm test — the copied artifact.test.ts covers mint/verify/tamper/expiry. Then exercise the seam end to end without any assistant: mint a dev artifact and call the machine API as an agent —
ART=$(curl -s -XPOST localhost:5173/api/v1/dev/artifact | jq -r .data.artifact)curl -s localhost:5173/api/v1/me -H "authorization: Bearer $ART" | jqStep 4 — The MCP relay (on the framework)
Section titled “Step 4 — The MCP relay (on the framework)”The relay translates assistant tool calls into /api/v1 requests. Per the MCP authorization spec (2025-11-25) it is an OAuth-style resource server — accept only tokens issued for itself, never pass a client-presented token upstream. @dioschub/mcp-server implements exactly that, so the relay is createMcpServer + your tools — there’s no broker to write.
The flow the framework runs: the app’s bind route mints the Cadence artifact (step 3) and hands it — with identity — to the relay’s POST /bind. The framework caches the artifact under a jti, mints an audience-bound handle-JWT referencing it, and registers that handle with the hub. On each tool call the hub presents the handle; the framework verifies it, resolves the cached artifact, and hands it to your tool as ctx.auth. The Cadence artifact never leaves the app+relay trust domain; the hub only ever holds the handle.
Copy the relay and wire it:
mkdir mcpcp tutorial/step-4-mcp/server.ts tutorial/step-4-mcp/api.ts tutorial/step-4-mcp/http.ts mcp/cp -r tutorial/step-4-mcp/__tests__ mcp/mkdir -p src/routes/api/diosc/bindcp tutorial/step-4-mcp/bind-route.ts src/routes/api/diosc/bind/+server.tsnpm installnpm pkg set 'scripts.mcp:http=tsx mcp/http.ts'The bootstrap (mcp/http.ts) is the whole broker, in six lines — the framework owns bind, JWT, and exchange:
const server = createMcpServer<string>({ name: 'cadence', 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! },});registerCadenceTools(server);server.listen(5174);An invalid/expired handle becomes a 401 + WWW-Authenticate the hub turns into a mid-turn re-auth interrupt — the app re-binds and the turn resumes.
Every tool in mcp/server.ts is then one line of relay, reading the artifact from ctx.auth:
server.tool({ name: 'search_cards', description: 'Search cards by title/description/label across every board the user can access.', input: z.object({ q: z.string() }), handler: ({ q }, ctx) => relay(ctx.auth, 'GET', `/cards/search?q=${encodeURIComponent(q)}`),});The relay adds no privileges: a tool call with your artifact can do exactly what you can do in the UI, because it hits the same /api/v1 routes behind the same locals.actor gates.
Configuration — the framework talks to the hub at bind time, so the hub URL and an admin capability key with the auth:bind scope live on the relay (the app holds no hub credentials at all):
# .env — relay side (mcp:http reads these)DIOSC_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>
# .env — app sideCADENCE_MCP_URL=http://localhost:5174MCP_BIND_SECRET=<same value>Verify: npm test (broker + relay tests), then npm run mcp:http and confirm the gate: 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":"whoami"}}' | head -3# HTTP/1.1 401 … WWW-Authenticate: Bearer resource="cadence-mcp" …Step 5 — Configure the hub
Section titled “Step 5 — Configure the hub”- Register the relay — MCP servers → add, URL
http://localhost:5174/mcp, Streamable HTTP, no static auth (the hub forwards each session’s bound artifact — the relay’s own JWT — as theAuthorizationheader). - Attach it to the Assistant and grant tools to the roles you want.
- Approval-gate the destructive tools (
delete_card, and anything else you consider irreversible) in the role’s approval policy. tutorial/step-5-hub/HUB-WIRING-RUNBOOK.mdwalks the same setup end-to-end for a local hub, including the exact admin API calls.
Verify the full journey: sign in, ask the assistant what’s on my plate? (my_cards), move the API-review card to Done — and watch the board update live (that’s the tool:completed → invalidateAll() wire from step 2). The card’s activity feed attributes the action to the assistant, acting as you. Ask it to delete a card: an approval card appears first.
Then the sharper test: ask it to act on a board you are not a member of. NOT_FOUND — the assistant cannot see what you cannot see. No hub configuration made that true; your own permission layer did.
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 + Dockerfile.mcp for deploying both services.