Skip to content

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

Sign 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_device cookie. Whatever identity later acts “for you” must act on your sandbox.
  • locals.actor — every request resolves an actor in src/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.

Copy the panel and mount it:

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

stores.ts
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:

.env
PUBLIC_DIOSC_HUB_URL=https://your-hub.example.com
PUBLIC_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:

Terminal window
cp tutorial/step-3-artifacts/artifact.ts src/lib/server/api/
mkdir -p src/routes/api/v1/auth/artifact src/routes/api/v1/dev/artifact
cp tutorial/step-3-artifacts/auth-artifact-route.ts src/routes/api/v1/auth/artifact/+server.ts
cp tutorial/step-3-artifacts/dev-artifact-route.ts src/routes/api/v1/dev/artifact/+server.ts
cp 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).

.env
CADENCE_ARTIFACT_SECRET=<a strong random value>
CADENCE_DEV_ARTIFACT=1 # dev-only headless mint route, for the next verify

Verify: 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 —

Terminal window
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" | jq

Step 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:

Terminal window
mkdir mcp
cp 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/bind
cp tutorial/step-4-mcp/bind-route.ts src/routes/api/diosc/bind/+server.ts
npm install
npm 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):

Terminal window
# .env — relay side (mcp:http reads these)
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>
# .env — app side
CADENCE_MCP_URL=http://localhost:5174
MCP_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 →

Terminal window
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" …
  1. 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 the Authorization header).
  2. Attach it to the Assistant and grant tools to the roles you want.
  3. Approval-gate the destructive tools (delete_card, and anything else you consider irreversible) in the role’s approval policy.
  4. tutorial/step-5-hub/HUB-WIRING-RUNBOOK.md walks 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:completedinvalidateAll() 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.

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

An empty diff means you built exactly what ships. production also carries Dockerfile + Dockerfile.mcp for deploying both services.