Contributing
This guide documents the patterns to follow and the pitfalls to avoid when contributing to WebMCP Auto-UI. It is updated as production incidents occur — every rule corresponds to an actual bug, not a theoretical convention.
Monorepo Structure
Section titled “Monorepo Structure”graph TD subgraph "Packages (npm workspaces)" CORE["core<br/>Types, MCP client, validation"] SDK["sdk<br/>HyperSkills, canvas store"] AGENT["agent<br/>Agent loop, LLM providers"] UI["ui<br/>30+ widgets, agent components"] end
subgraph "Apps" FLEX[flex — Main composer] VIEWER[viewer — HyperSkills viewer] SHOWCASE[showcase — Widget gallery] RECIPES[recipes — Recipe explorer] HOME[home — Homepage] BOILER[boilerplate — Starter template] TODO[todo — Todo demo] end
CORE --> SDK CORE --> AGENT CORE --> UI SDK --> FLEX AGENT --> FLEX UI --> FLEXCore Rule: Reuse Packages
Section titled “Core Rule: Reuse Packages”Before writing code in an app (apps/*), check whether the functionality already exists in a package. The table below lists available imports:
| Need | Import | Package |
|---|---|---|
| WebMCP server | createWebMcpServer | core |
| MCP client | McpClient, McpMultiClient | core |
| JSON Schema validation | validateJsonSchema | core |
| Vanilla widget mounting | mountWidget | core |
| Agent loop | runAgentLoop | agent |
| Remote LLM provider | RemoteLLMProvider | agent |
| Gemma provider | WasmProvider | agent |
| Ollama provider | LocalLLMProvider | agent |
| Discovery tools | buildDiscoveryTools, activateServerTools | agent |
| Canonical resolution | resolveCanonicalTools, toolAliasMap | agent |
| Token tracking | TokenTracker | agent |
| Nano-RAG | ContextRAG | agent |
| LLM selector | <LLMSelector> | ui |
| Gemma loader | <ModelLoader> | ui |
| MCP status | <McpStatus> | ui |
| Agent progress | <AgentProgress> | ui |
| Widget rendering | <WidgetRenderer>, <BlockRenderer> | ui |
| FONC message bus | bus | ui |
| HyperSkill encoding | encode, decode | sdk |
| Canvas store | canvas via @webmcp-auto-ui/sdk/canvas | sdk |
Svelte 5 — Reactivity
Section titled “Svelte 5 — Reactivity”The following rules come from real production bugs. Each rule is illustrated with the faulty code and the correct version.
Rule 1: An $effect must not read AND write the same state
Section titled “Rule 1: An $effect must not read AND write the same state”Symptom: effect_update_depth_exceeded in the console, the entire page freezes (buttons unresponsive, modals will not open).
Cause: Svelte 5 re-runs an $effect every time one of its reactive dependencies changes. If the effect writes a value it also reads, it re-triggers itself — infinite loop — stops after 5 iterations.
<!-- WRONG -- reads gemmaStatus AND writes it -->$effect(() => { const llm = canvas.llm; if (gemmaStatus === 'ready') { // reads gemmaStatus → tracked gemmaStatus = 'idle'; // writes gemmaStatus → re-run! } canvas.addMsg('system', llm);});
<!-- CORRECT -- untrack() for non-triggering reads -->import { untrack } from 'svelte';
$effect(() => { const llm = canvas.llm; // only tracked dependency untrack(() => { if (gemmaStatus === 'ready') { // read but not tracked gemmaStatus = 'idle'; } canvas.addMsg('system', llm); });});Rule: isolate the dependencies that should trigger re-runs, and wrap everything else in untrack().
Rule 2: Prefer $derived over $effect + $state for computed values
Section titled “Rule 2: Prefer $derived over $effect + $state for computed values”<!-- WRONG -- $state written in a $effect -->let paletteOpen = $state(true);$effect(() => { paletteOpen = canvas.mode === 'drag'; });
<!-- CORRECT if the value is read-only -->const paletteOpen = $derived(canvas.mode === 'drag');
<!-- CORRECT if the value is also written manually (user toggle) -->let paletteOpen = $state(true);$effect(() => { paletteOpen = canvas.mode === 'drag'; });// OK because the effect does NOT read paletteOpen$derived is always preferable when the value depends only on other reactive state. Use $effect + $state only when the value is also modified by user interaction (toggle, drag, etc.).
Rule 3: Avoid redundant $effect with onMount
Section titled “Rule 3: Avoid redundant $effect with onMount”<!-- WRONG -- unnecessary double initialization -->let skills = $state([]);$effect(() => { skills = listSkills(); }); // short-circuited by onMountonMount(() => { skills = listSkills(); });
<!-- CORRECT -- single source of truth -->let skills = $state([]);onMount(() => { skills = listSkills(); });If listSkills() does not read any reactive state, the $effect will never re-trigger after the first run. It is strictly equivalent to onMount, but more misleading to read.
Rule 4: Pass the LLM model explicitly to the provider
Section titled “Rule 4: Pass the LLM model explicitly to the provider”// WRONG -- always uses 'haiku' by defaultreturn new RemoteLLMProvider({ proxyUrl: `${base}/api/chat` });
// CORRECT -- passes the user's choicereturn new RemoteLLMProvider({ proxyUrl: `${base}/api/chat`, model: canvas.llm,});RemoteLLMProvider defaults to model ?? 'haiku'. If the user selects sonnet in the <LLMSelector>, they will still get haiku with no error message. Always pass model explicitly.
Agent Loop — loop.ts
Section titled “Agent Loop — loop.ts”onText Is Only Called on the Last Iteration
Section titled “onText Is Only Called on the Last Iteration”By default, callbacks.onText is called only when the LLM responds without tool_use. Since the system prompt pushes the use of tools, this callback is never reached in the normal flow.
Consequence: the “thinking” bubble stays stuck on the last tool call.
Fix applied in loop.ts: also call onText when there is intermediate text before tool_use:
// Intermediate text before tool_use -- live updateif (lastText) callbacks.onText?.(lastText);Deployment — Build Integrity
Section titled “Deployment — Build Integrity”Always Verify SHA-256 After Deploy
Section titled “Always Verify SHA-256 After Deploy”The deploy.sh script automatically verifies integrity after each transfer. On mismatch, the deployment fails with rollback.
Why? Without this check, a deploy can “succeed” (no SCP error, service active) but serve stale code if:
- The local build was outdated
- SCP was silently interrupted
- The target file was read-only
Always Rebuild Apps
Section titled “Always Rebuild Apps”The script automatically rebuilds apps via npm run build before copying. Before this fix, packages were recompiled but apps kept their old build/, and Svelte fixes were lost.
Pure JS Packages: Type Wrapper Required
Section titled “Pure JS Packages: Type Wrapper Required”When an npm package is plain JavaScript (no TypeScript types), never import it directly into the project’s TypeScript code. Create a type wrapper in the SDK:
// @ts-ignore — hyperskills is intentionally pure JSimport * as hs from 'hyperskills';
export const encode: (sourceUrl: string, content: string) => Promise<string> = hs.encode;export const decode: (urlOrParam: string) => Promise<{ sourceUrl: string; content: string }> = hs.decode;Why not declare module? Ignored by moduleResolution: "NodeNext" when the JS is already resolved.
Why not .d.ts in node_modules? Deleted on the next npm install.
Why not allowJs? Not sufficient with strict: true + NodeNext.
Testing
Section titled “Testing”Unit Tests (Vitest)
Section titled “Unit Tests (Vitest)”npm run test # All testsnpm run test:watch # Watch modenpm run test:coverage # With coverageEnd-to-End Tests (Playwright)
Section titled “End-to-End Tests (Playwright)”E2E tests verify the apps deployed on https://demos.hyperskills.net:
npx playwright test # All testsnpx playwright test --grep "Composer" # A suitenpx playwright test --grep "export" # A specific testWhen to run tests:
- After each significant deploy
- Before marking a bug as resolved
- After a refactor touching multiple components
// WRONG -- may click before hydrationawait page.goto(url);await page.click('button:has-text("export")');
// CORRECT -- wait for a client-side elementawait page.goto(url);await page.waitForSelector('select', { state: 'visible' });await page.waitForTimeout(500); // hydration tickawait page.click('button:has-text("export")');What Tests Do Not Verify
Section titled “What Tests Do Not Verify”- That deployed code matches local code (that is the job of SHA-256 in
deploy.sh) - Silent client-side JavaScript errors (add
page.on('pageerror')) - Svelte reactive loops that do not affect the tested CSS selectors
Debug — Checklist: “The Fix Is Not In Production”
Section titled “Debug — Checklist: “The Fix Is Not In Production””When a fix appears applied locally but is not visible in production, follow this checklist in order:
-
Is the local build up to date?
Terminal window ls -la apps/flex/build/index.js # Check modification date -
Does the deployed file match the local build?
Terminal window sha256sum apps/flex/build/index.jsssh bot "sha256sum /opt/webmcp-demos/flex/index.js"# Both hashes must match -
Did the service restart with the correct file?
Terminal window ssh bot "systemctl status webmcp-flex --no-pager | head -20" -
Are there client-side JavaScript errors? Open the browser console (
F12) on the production URL.
Documentation
Section titled “Documentation”Automatic Sync
Section titled “Automatic Sync”After any code change that modifies exports, tokens, or widget types:
npm run docs:syncCI verifies that documentation is up to date on every push.
Mermaid Diagrams
Section titled “Mermaid Diagrams”Existing diagrams are pre-rendered as SVGs in docs/starlight/public/diagrams/. To regenerate SVGs after modifying a diagram:
npx @mermaid-js/mermaid-cli -i file.mmd -o docs/starlight/public/diagrams/file.svg --backgroundColor transparentContribution Workflow
Section titled “Contribution Workflow”1. Create a Branch
Section titled “1. Create a Branch”git checkout -b feat/my-featureNaming conventions:
feat/: new featurefix/: bug fixrefactor/: restructuring without functional changedocs/: documentation only
2. Develop and Test
Section titled “2. Develop and Test”npm run dev:flex # Develop on the main appnpm run test # Run unit testsnpm run check # Check TypeScript types3. Commit
Section titled “3. Commit”Use Conventional Commits format:
feat(agent): add context compaction via Nano-RAGfix(ui): prevent effect_update_depth_exceeded in ModelLoaderrefactor(core): simplify JSON Schema validationdocs(guide): update architecture diagramperf(onnxruntime): load WASM from CDN instead of bundling4. Pull Request
Section titled “4. Pull Request”- Short, descriptive title (under 70 characters)
- Description with context and changes
- Link issues if applicable
How do I add a new widget?
Section titled “How do I add a new widget?”- Create the Svelte component in
packages/ui/src/widgets/rich/orsimple/. - Export it in
packages/ui/src/index.ts. - Write a markdown recipe with frontmatter (JSON Schema).
- Register the recipe in
packages/agent/src/autoui-server.ts. - Run
npm run docs:syncto update documentation.
How do I add a new LLM provider?
Section titled “How do I add a new LLM provider?”- Create a file in
packages/agent/src/providers/. - Implement the
LLMProviderinterface (chatmethod). - Export it in
packages/agent/src/index.ts. - Add a case in the
createProviderfactory.
How do I connect a new MCP server?
Section titled “How do I connect a new MCP server?”- Start the MCP server (must expose an SSE endpoint).
- Add the URL to the configuration (
.env.localor app settings). - The canonical resolver will automatically handle tool name mapping.
Why do E2E tests target deployed apps rather than local ones?
Section titled “Why do E2E tests target deployed apps rather than local ones?”Because the most dangerous bugs occur between the local build and the deployment. Testing in production (with test data) catches build integrity issues, nginx configuration problems, and missing environment variables.