60 KiB
MCP LocalBosses Integrator — Phase 4: Wire Into LocalBosses
When to use this skill: You have a built MCP server (Phase 2) and HTML apps (Phase 3) and need to wire them into the LocalBosses Next.js app so they appear as a channel in the sidebar with working apps, threads, and AI interactions.
What this covers: Exact files to update, channel configuration, app registration, route mapping, system prompt engineering, APP_DATA block format, thread lifecycle integration, integration validation, and rollback strategy.
Pipeline position: Phase 4 of 6 → Input from Phases 2 & 3, output feeds mcp-qa-tester (Phase 5).
1. Inputs & Outputs
Inputs:
- Built MCP server in
{service}-mcp/(from Phase 2) - HTML app files in
{service}-mcp/app-ui/(from Phase 3) {service}-api-analysis.md(from Phase 1 — for tool names and app IDs)
Output: A fully wired LocalBosses channel where:
- Channel appears in sidebar under correct category
- All apps appear in the app toolbar
- Clicking an app opens a thread with an intake question
- AI responses include APP_DATA blocks that update the visual app
- Thread lifecycle (create → interact → delete) works end-to-end
LocalBosses app location: localbosses-app/ (Next.js app)
2. Files to Update (Checklist)
| # | File | Purpose |
|---|---|---|
| 1 | src/lib/channels.ts |
Add channel definition (sidebar entry) |
| 2 | src/lib/appNames.ts |
Add display names + icons for all apps |
| 3 | src/lib/app-intakes.ts |
Add intake questions for each app |
| 4 | src/app/api/mcp-apps/route.ts |
Add app ID → filename mapping + directory |
| 5 | src/app/api/chat/route.ts |
Add tool routing + system prompt for channel |
3. File 1: src/lib/channels.ts
What it does:
Defines the channel that appears in the LocalBosses sidebar. Controls name, icon, category, description, system prompt, default app, and available apps.
Template:
{
id: "{service}",
name: "{service}",
icon: "🔥", // Single emoji
category: "BUSINESS OPS", // "BUSINESS OPS" | "MARKETING" | "TOOLS" | "SYSTEM"
description: "{One-line description of what this channel does}",
systemPrompt: `You are the {Service Name} Specialist for LocalBosses AI.
Your expertise:
- {Capability 1 — what the user can do}
- {Capability 2}
- {Capability 3}
- {Capability 4}
TOOL SELECTION RULES:
- SEE/BROWSE/LIST multiple items → use list_* tools
- ONE specific item by name/ID → use get_* tools
- CREATE/ADD/NEW → use create_* tools
- CHANGE/UPDATE/MODIFY → use update_* tools
- DELETE/REMOVE → use delete_* tools (always confirm first)
- STATS/METRICS/OVERVIEW → use analytics tools
Before calling any tool, briefly state which tool you're choosing and why.
MULTI-INTENT MESSAGES:
- If the user asks for multiple things in one message, address them sequentially.
- State which you're handling first and that you'll get to the others.
- Complete one action before starting the next.
CORRECTIONS:
- If the user says "actually", "wait", "no I meant", "the other one", treat this as a correction to your previous action.
- If they reference "the other one" or "that one", check previous results in the conversation and clarify if needed.
- Never repeat the same action — understand what changed.
Do NOT call tools when the user asks general questions about best practices, strategy, or how-to advice. Respond from your expertise instead.
Be concise, practical, and action-oriented. When presenting data, always include an APP_DATA block so the visual app updates.`,
defaultApp: "{service}-dashboard", // Optional: auto-opens on channel entry. Omit if no dashboard.
mcpApps: [
// List ALL app IDs registered for this channel
"{service}-dashboard",
"{service}-contact-grid",
"{service}-contact-card",
"{service}-contact-creator",
"{service}-calendar-view",
"{service}-pipeline-kanban",
"{service}-activity-timeline",
// ... all apps from Phase 3
],
},
Placement:
Add the new channel object to the channels array. Place it in the appropriate category section.
Real example (from automations channel):
{
id: "automations",
name: "automations",
icon: "⚡",
category: "BUSINESS OPS",
description: "Build n8n workflows with natural language",
systemPrompt: `You are the Automations Specialist for LocalBosses AI, powered by n8n workflow automation.
Your expertise:
- Building n8n workflows from natural language descriptions
- Connecting 1,084+ integrations (apps, APIs, databases)
- Automation best practices (error handling, scheduling, data transformation)
- Workflow optimization and debugging
- Common automation patterns (lead capture, email sequences, data sync, notifications)
TOOL SELECTION RULES:
- SEE/BROWSE/LIST workflows → use list_workflows
- ONE specific workflow by ID → use get_workflow
- CREATE/ADD/NEW workflow → use create_workflow
- CHANGE/UPDATE/MODIFY → use update_workflow
- DELETE/REMOVE → use delete_workflow (always confirm first)
- STATS/EXECUTION HISTORY → use list_executions
Before calling any tool, briefly state which tool you're choosing and why.
Do NOT call tools when users ask about automation best practices, n8n concepts, or workflow design patterns. Respond from your expertise instead.
When users describe what they want to automate:
1. Break it down into workflow steps
2. Identify which n8n nodes to use
3. Explain the data flow
4. Suggest error handling approaches
Always be practical and implementation-focused. If a workflow would be complex, break it into phases.`,
defaultApp: "n8n-workflow-builder",
mcpApps: [
"n8n-workflow-builder",
"n8n-execution-monitor",
"n8n-workflow-templates",
"n8n-node-config",
"n8n-health-monitor",
"n8n-webhook-tester",
"n8n-workflow-detail",
],
},
4. File 2: src/lib/appNames.ts
What it does:
Maps app IDs to human-friendly display names and emoji icons. Used by the app toolbar and anywhere apps are shown by name.
Template:
// In the APP_DISPLAY_NAMES object, add one entry per app:
// ═══════════════════════════════════════════
// {Service Name} Apps
// ═══════════════════════════════════════════
"{service}-dashboard": { name: "Dashboard", icon: "📊" },
"{service}-contact-grid": { name: "Contacts", icon: "👥" },
"{service}-contact-card": { name: "Contact Card", icon: "👤" },
"{service}-contact-creator": { name: "New Contact", icon: "➕" },
"{service}-calendar-view": { name: "Calendar", icon: "📆" },
"{service}-pipeline-kanban": { name: "Pipeline", icon: "📈" },
"{service}-activity-timeline": { name: "Activity", icon: "📅" },
Icon guidelines:
- Use a single emoji that represents the app type
- 📊 for dashboards/analytics
- 👥 for contact lists, 👤 for single contact
- ➕ for creation forms
- 📆 for calendars
- 📈 for pipeline/funnel
- 📅 for timeline/activity
- 🔍 for search
- 📋 for lists
- 📄 for detail views
- 💰 for financial/invoice
- ⚙️ for settings/config
5. File 3: src/lib/app-intakes.ts
What it does:
Defines the intake question shown when a user clicks an app. This creates a conversational thread where the AI generates data for the app.
Interface:
export interface AppIntake {
category: string; // Grouping category for similar apps
question: string; // The question shown to the user in the thread
skipLabel?: string; // If defined, shows a "skip" button with this label
systemPromptAddon: string; // Extra AI instructions for generating APP_DATA
}
Intake Question Quality Criteria
Every intake question MUST meet these standards:
| Criterion | Requirement | Example |
|---|---|---|
| Input format hint | Suggest what to provide | "Provide a name, email, or ID" |
| skipLabel | Most common default action | "All upcoming events" |
| Length | Under 20 words | ✓ "What contacts? Filter by name, status, or tag." |
| Action-oriented | Tell what to DO, not ASK | ✓ "Filter contacts by name, status, or tag" ✗ "What would you like to see?" |
| Context-specific | Tied to this app's data | ✓ "Which pipeline stage?" ✗ "What data do you want?" |
Bad examples:
- ❌ "What would you like to see?" — too vague, no format hint
- ❌ "Please tell me what you're looking for in this application" — too long, not action-oriented
- ❌ "Enter your query" — no context, no format hint
Good examples:
- ✅ "Filter contacts by name, status, or tag — or say 'show all'." (skipLabel: "All contacts")
- ✅ "Which date range? e.g., 'this week', 'Feb 2026', 'next 7 days'" (skipLabel: "This week")
- ✅ "Which contact? Provide a name, email, or ID."
Note on MCP Elicitation: The intake question pattern maps conceptually to MCP's
elicitation/createcapability (spec 2025-06-18). In the future, intake questions could be served as MCP elicitation requests rather than hardcoded inapp-intakes.ts, enabling servers to dynamically request user input mid-flow. This would also support mid-conversation elicitation (e.g., "Which account?" during an OAuth flow, or "Confirm delete?" for destructive operations).
Template per app type:
Dashboard apps:
"{service}-dashboard": {
category: "dashboard",
question: "What time frame? e.g., last 7 days, this month, last quarter",
skipLabel: "Last 30 days",
systemPromptAddon: `The user is viewing the {Service} Dashboard. Generate APP_DATA with these fields:
{
"title": "Service Dashboard",
"timeFrame": "Last 30 days",
"metrics": {
"total_contacts": 1234,
"active_deals": 56,
"revenue": 78900,
"appointments_today": 3
},
"recent": [
{ "title": "Event name", "description": "Details", "date": "2026-02-04T10:30:00Z", "type": "event_type" }
]
}`,
},
Data grid apps:
"{service}-contact-grid": {
category: "data-grid",
question: "Filter contacts by name, status, or tag — or say 'show all'.",
skipLabel: "All contacts",
systemPromptAddon: `The user is viewing the contact grid. Generate APP_DATA with an array of contacts:
{
"title": "Contacts",
"data": [
{ "name": "John Smith", "email": "john@example.com", "phone": "(555) 123-4567", "status": "active", "created": "2026-01-15" }
],
"meta": { "total": 150, "page": 1, "pageSize": 25 }
}
Include 5-10 realistic records. Match any filters the user requested.`,
},
Detail card apps:
"{service}-contact-card": {
category: "detail-card",
question: "Which contact? Provide a name, email, or ID.",
systemPromptAddon: `The user wants to view a specific contact's details. Generate APP_DATA with full contact info:
{
"name": "John Smith",
"email": "john@example.com",
"phone": "(555) 123-4567",
"status": "active",
"company": "Acme Inc",
"tags": ["vip", "lead"],
"created": "2026-01-15",
"lastActivity": "2026-02-03T14:30:00Z",
"notes": "Key decision maker"
}`,
},
Form/wizard apps:
"{service}-contact-creator": {
category: "form",
question: "Describe the new contact — I'll pre-fill the form for you.",
systemPromptAddon: `The user wants to create a new contact. Generate APP_DATA defining the form fields:
{
"title": "Create New Contact",
"description": "Fill in the contact details",
"fields": [
{ "name": "name", "label": "Full Name", "type": "text", "required": true, "placeholder": "John Smith" },
{ "name": "email", "label": "Email", "type": "email", "required": false, "placeholder": "john@example.com" },
{ "name": "phone", "label": "Phone", "type": "tel", "required": false, "placeholder": "(555) 123-4567" },
{ "name": "status", "label": "Status", "type": "select", "options": ["active", "inactive", "lead"] }
]
}
Pre-fill any values the user mentioned.`,
},
Calendar apps:
"{service}-calendar-view": {
category: "calendar",
question: "Which date range? e.g., this week, Feb 2026, next month",
skipLabel: "This week",
systemPromptAddon: `The user is viewing the calendar. Generate APP_DATA with events:
{
"title": "Calendar",
"events": [
{ "title": "Meeting with John", "start": "2026-02-04T10:00:00Z", "end": "2026-02-04T11:00:00Z", "contact": "John Smith", "status": "confirmed", "location": "Office" }
]
}
Include events for the requested time range.`,
},
Timeline apps:
"{service}-activity-timeline": {
category: "timeline",
question: "Whose activity? Provide a contact name, or say 'all recent'.",
skipLabel: "All recent activity",
systemPromptAddon: `The user is viewing an activity timeline. Generate APP_DATA with events:
{
"title": "Activity Timeline",
"events": [
{ "title": "Email sent", "description": "Follow-up email to John", "date": "2026-02-04T10:30:00Z", "type": "email", "user": "Jake" },
{ "title": "Call completed", "description": "15 min call discussing proposal", "date": "2026-02-03T16:00:00Z", "type": "call", "user": "Jake" }
]
}
Order events from newest to oldest.`,
},
Pipeline/funnel apps:
"{service}-pipeline-kanban": {
category: "pipeline",
question: "Which pipeline? e.g., 'sales pipeline', 'hiring pipeline'",
skipLabel: "Main pipeline",
systemPromptAddon: `The user is viewing the pipeline board. Generate APP_DATA with stages and deals:
{
"title": "Sales Pipeline",
"stages": [
{
"name": "New Leads",
"items": [
{ "name": "Acme Deal", "value": 25000, "contact": "John Smith" }
]
},
{
"name": "Qualified",
"items": [
{ "name": "Beta Contract", "value": 50000, "contact": "Jane Doe" }
]
},
{ "name": "Proposal", "items": [] },
{ "name": "Closed Won", "items": [] }
]
}`,
},
6. File 4: src/app/api/mcp-apps/route.ts
What it does:
Maps app IDs to their HTML filenames and tells the server where to find the files.
Changes needed:
A. Add to APP_NAME_MAP:
const APP_NAME_MAP: Record<string, string> = {
// ... existing entries ...
// {Service Name} apps
"{service}-dashboard": "dashboard",
"{service}-contact-grid": "contact-grid",
"{service}-contact-card": "contact-card",
"{service}-contact-creator": "contact-creator",
"{service}-calendar-view": "calendar-view",
"{service}-pipeline-kanban": "pipeline-kanban",
"{service}-activity-timeline": "activity-timeline",
};
Rule: Left side is the app ID (used in channels.ts, appNames.ts, intakes). Right side is the HTML filename WITHOUT the .html extension.
B. Add to APP_DIRS:
const APP_DIRS = [
// ... existing directories ...
// {Service Name} apps
join(process.cwd(), "../{service}-mcp/app-ui"),
// OR if using dist: join(process.cwd(), "../{service}-mcp/dist/app-ui"),
];
Rule: Order matters — first match wins. Add new directories at the bottom unless they need priority.
How file resolution works:
1. User requests app ID "{service}-dashboard"
2. APP_NAME_MAP maps it to filename "dashboard"
3. For each directory in APP_DIRS:
a. Check: {dir}/dashboard.html (flat format)
b. Check: {dir}/dashboard/index.html (subdirectory format)
4. First match wins, HTML is returned
7. File 5: src/app/api/chat/route.ts
What it does:
The chat route handles AI conversations. For app threads, it injects system prompts that tell the AI to include APP_DATA blocks in responses.
The APP_DATA Block Format:
Important: APP_DATA is a LocalBosses-specific convention for embedding structured data in LLM responses. It is NOT part of the MCP protocol. MCP's native equivalent is
structuredContenton tool results (see Section 14 for the bridge roadmap).
The AI response includes a hidden block that gets parsed by the frontend and sent to the app:
Your visible text response here...
<!--APP_DATA:{"key":"value","data":[...]}:END_APP_DATA-->
Rules:
- EVERY response in an app thread MUST include exactly one APP_DATA block
- The JSON must be valid and on a SINGLE LINE (no line breaks inside)
- Place it AFTER the text explanation
- The block is automatically parsed and hidden from the user
- When the user refines, generate completely NEW APP_DATA (replace, don't append)
APP_DATA Failure Modes & Parsing Guidelines
LLMs don't always produce perfect APP_DATA. Document and handle these known failure modes:
| Failure Mode | Example | Fix |
|---|---|---|
| Line breaks in JSON | <!--APP_DATA:{\n"key":"val"\n}:END_APP_DATA--> |
Strip all \n and \r before JSON.parse |
| Wrapped in code block | ````json\n` `` | Strip ```json and ``` wrappers before extracting |
| Invalid JSON | Missing closing brace, trailing comma | Try JSON.parse, on failure try to fix common issues (trailing commas, unquoted keys) |
| Text after END_APP_DATA | ...END_APP_DATA--> more text here |
Only extract between delimiters; ignore trailing content |
| No APP_DATA at all | LLM just responds with plain text | Fallback: scan for JSON objects in the response heuristically |
| Multiple APP_DATA blocks | Two blocks in one response | Use the LAST block (most likely the refined one) |
Recommended Parser Pattern:
function parseAppData(response: string): Record<string, unknown> | null {
// 1. Try exact match first
const exactMatch = response.match(/<!--APP_DATA:(.*?):END_APP_DATA-->/s);
if (exactMatch) {
const jsonStr = exactMatch[1].replace(/[\n\r]/g, '').trim();
try { return JSON.parse(jsonStr); } catch {}
// Try fixing common issues
try {
const fixed = jsonStr.replace(/,\s*([}\]])/g, '$1'); // trailing commas
return JSON.parse(fixed);
} catch {}
}
// 2. Try stripping code block wrappers
const stripped = response.replace(/```(?:json)?\s*/g, '').replace(/```/g, '');
const codeBlockMatch = stripped.match(/<!--APP_DATA:(.*?):END_APP_DATA-->/s);
if (codeBlockMatch) {
try { return JSON.parse(codeBlockMatch[1].replace(/[\n\r]/g, '').trim()); } catch {}
}
// 3. Heuristic fallback: find largest JSON object in response
const jsonMatches = response.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g);
if (jsonMatches) {
const largest = jsonMatches.sort((a, b) => b.length - a.length)[0];
try { return JSON.parse(largest); } catch {}
}
return null; // All parsing failed
}
Track success rate: If APP_DATA parsing fails more than 10% of the time for a channel, the system prompt needs revision — add more explicit formatting examples or stronger instructions.
APP_DATA Schema Validation
After parsing APP_DATA, validate it against the app's expected data shape before sending to the iframe. This catches silent data shape mismatches (e.g., tool returns {contacts: [...]} but app expects {data: [...]}).
// Schema contracts per app type — shared between integrator and designer
const APP_SCHEMAS: Record<string, { required: string[]; arrayFields?: string[] }> = {
'dashboard': { required: ['metrics'], arrayFields: ['recent'] },
'data-grid': { required: ['data', 'meta'], arrayFields: ['data'] },
'detail-card': { required: ['name'] },
'form': { required: ['fields'], arrayFields: ['fields'] },
'calendar': { required: ['events'], arrayFields: ['events'] },
'timeline': { required: ['events'], arrayFields: ['events'] },
'pipeline': { required: ['stages'], arrayFields: ['stages'] },
};
function validateAppData(data: Record<string, unknown>, appType: string): { valid: boolean; errors: string[] } {
const schema = APP_SCHEMAS[appType];
if (!schema) return { valid: true, errors: [] };
const errors: string[] = [];
for (const field of schema.required) {
if (!(field in data) || data[field] == null) {
errors.push(`Missing required field: "${field}"`);
}
}
for (const field of schema.arrayFields || []) {
if (field in data && !Array.isArray(data[field])) {
errors.push(`Expected array for "${field}", got ${typeof data[field]}`);
}
}
return { valid: errors.length === 0, errors };
}
Usage: Call validateAppData() after parseAppData(). If validation fails, log the errors and either attempt auto-fix (wrap non-array in array) or show a diagnostic empty state in the app.
The Thread System Prompt (already exists in chat/route.ts):
const THREAD_SYSTEM_PROMPT = `
## MANDATORY: APP_DATA BLOCK (DO NOT SKIP)
You are in an APP THREAD. Every response you give MUST include a hidden APP_DATA block that updates the visual app above the conversation. This is NOT optional.
FORMAT (place at the VERY END of your response):
<!--APP_DATA:{"key":"value"}:END_APP_DATA-->
RULES:
1. EVERY response MUST have exactly one APP_DATA block — no exceptions
2. The JSON must be valid and on a SINGLE LINE (no line breaks inside)
3. Place it AFTER your text explanation
4. Generate REALISTIC data matching what the user requested
5. Include 5-10 records for lists, complete details for single items
6. The block is automatically parsed and hidden from the user
7. Also write a brief natural language explanation before the block
8. When the user refines, generate completely NEW APP_DATA (replace, don't append)
If you forget the APP_DATA block, the visual app won't update and the user will see stale data. ALWAYS include it.`;
What you MAY need to add to chat/route.ts:
Usually the existing THREAD_SYSTEM_PROMPT + the intake's systemPromptAddon is sufficient. But if your service needs special tool routing or a channel-specific system prompt override, you may need to add logic:
// Example: If the channel has MCP server tools that need explicit routing
if (channelId === '{service}') {
// Add service-specific context to the system prompt
systemPrompt += `\n\nYou have access to the following {Service} tools:\n${toolList}`;
}
For workflow-type apps (like n8n):
Use WORKFLOW_JSON format instead of APP_DATA:
<!--WORKFLOW_JSON:{"name":"...","nodes":[...]}:END_WORKFLOW-->
This is only for n8n-style workflow builders. All other apps use APP_DATA.
APP_DATA Output Formatting — Required Fields Per App Type
When writing systemPromptAddon instructions, be explicit about exact required fields. Vague instructions produce inconsistent data:
| App Type | Required APP_DATA Fields | Notes |
|---|---|---|
| Dashboard | title, timeFrame, metrics (object with 3-6 key/value pairs), recent (array of 3-5 items with title, date, type) |
Metrics keys should match the dashboard's render function |
| Data Grid | title, data (array of objects — each MUST have the same keys), meta (total, page, pageSize) |
Every object in data must have identical field names |
| Detail Card | All entity fields as top-level keys (no wrapping data object), must include name or title |
Include status, created, lastActivity for consistency |
| Form | title, description, fields (array with name, label, type, required) |
Pre-fill values in value field when user provides info |
| Calendar | title, events (array with title, start ISO, end ISO, status) |
Always use ISO 8601 dates |
| Timeline | title, events (array with title, description, date ISO, type) |
Order newest → oldest |
| Pipeline | title, stages (array with name, items array — each item has name, value) |
Include 4-6 stages even if some are empty |
| Analytics | title, timeFrame, metrics, chartData (array with label, value) |
Values should be realistic percentages or counts |
7b. Host-Side Handler for App Actions (sendToHost)
The App Designer's sendToHost() function posts mcp_app_action messages to the parent window. The host (LocalBosses) must listen for these messages — otherwise navigate, refresh, and tool_call actions from apps are dead features.
Implementation (in the iframe wrapper component):
// In the component that renders the app iframe
useEffect(() => {
function handleAppAction(event: MessageEvent) {
if (event.data?.type !== 'mcp_app_action') return;
const { action, payload, appId } = event.data;
switch (action) {
case 'navigate':
// App-to-app drill-down: open a different app with params
// e.g., click contact in grid → open contact-card
openApp(payload.app, payload.params);
break;
case 'refresh':
// Re-send the last tool call to get fresh data
resendLastToolCall(appId);
break;
case 'tool_call':
// App triggered a tool call (e.g., form submit, bulk action)
// Inject as a message into the thread so the AI executes it
sendMessageToThread(
`[Action] Call ${payload.tool} with: ${JSON.stringify(payload.args)}`,
{ hidden: true } // Don't show raw JSON to user
);
break;
default:
console.warn('[Host] Unknown app action:', action);
}
}
window.addEventListener('message', handleAppAction);
return () => window.removeEventListener('message', handleAppAction);
}, []);
Key behaviors:
navigate— Opens the target app in a new thread (or switches to existing). Passpayload.paramsas initial context so the AI knows what data to fetch.refresh— Re-executes the last tool call for that app's thread. The AI regenerates APP_DATA with fresh data.tool_call— Injects a tool invocation into the thread. The AI sees the request, calls the MCP tool, and returns updated APP_DATA. Used by form submits, bulk actions, and in-app buttons.
Sending 'user_message_sent' to apps:
When the user sends a new message in a thread, notify the app so it can show the "updating" overlay:
// In the chat message send handler
function onUserMessageSent() {
const iframe = document.querySelector(`iframe[data-app-id="${activeAppId}"]`);
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type: 'user_message_sent' }, '*');
}
}
8. System Prompt Engineering Guidelines
The channel system prompt is the most critical piece. It determines:
- What the AI knows about the service
- When it uses tools vs just responds
- How it formats data for apps
- The tone and expertise level
Prompt Budget Targets
Keep prompts lean. Every token in the system prompt is consumed on every single user message.
| Prompt Component | Budget Target | Why |
|---|---|---|
| Channel system prompt | < 500 tokens | Loaded on every message in the channel |
| systemPromptAddon (per app intake) | < 300 tokens | Only loaded in that app's thread |
| THREAD_SYSTEM_PROMPT (shared) | ~200 tokens (fixed) | Already written; don't expand |
| Total per-thread context | < 1,000 tokens | System prompt + addon + thread prompt |
Measure: Paste your system prompt into a token counter. If it exceeds the budget, cut capability descriptions to single lines and remove examples from the channel prompt (put them in the addon instead).
Structure:
1. IDENTITY — "You are the {Service} Specialist for LocalBosses AI" (1 line)
2. EXPERTISE — Bullet list of capabilities (4-6 bullets, < 15 words each)
3. TOOL ROUTING — Structured decision tree (always include)
4. NEGATIVE INSTRUCTIONS — When NOT to use tools (2-3 lines)
5. MULTI-INTENT — How to handle multiple requests in one message
6. CORRECTIONS — How to handle "actually/wait/no I meant" messages
7. RATIONALE REQUIREMENT — "State which tool and why before calling"
8. BEHAVIOR — How to respond (1-2 lines)
Multi-Intent Handling (ALWAYS include):
MULTI-INTENT MESSAGES:
- If the user asks for multiple things in one message, address them sequentially.
- State which you're handling first and that you'll get to the others.
- Complete one action before starting the next.
Correction Handling (ALWAYS include):
CORRECTIONS:
- If the user says "actually", "wait", "no I meant", "the other one",
treat this as a correction to your previous action.
- If they reference "the other one" or "that one", check previous results
in the conversation and clarify if needed.
- Never repeat the same action — understand what changed.
Tool Routing Rules (ALWAYS include in channel system prompt):
This is the single highest-impact section. Research shows structured decision trees reduce tool misrouting by ~30%.
TOOL SELECTION RULES:
- SEE/BROWSE/LIST multiple items → use list_* tools
- ONE specific item by name/ID → use get_* tools
- CREATE/ADD/NEW → use create_* tools
- CHANGE/UPDATE/MODIFY → use update_* tools
- DELETE/REMOVE → use delete_* tools (always confirm first)
- STATS/METRICS/OVERVIEW → use analytics tools
Before calling any tool, briefly state which tool you're choosing and why.
Customize the routing rules per service. Replace list_* with actual tool names when the channel has few enough tools (< 15):
TOOL SELECTION RULES:
- SEE/BROWSE events → use list_scheduled_events
- ONE specific event → use get_event
- CREATE new event type → use create_event_type
- CANCEL/RESCHEDULE → use cancel_event (always confirm first)
- SCHEDULING METRICS → use get_scheduling_analytics
Negative Instructions (ALWAYS include):
Do NOT call tools when the user asks:
- General questions about best practices or strategy
- How-to advice that doesn't require their specific data
- Clarifying questions about what they want (ask them back instead)
- About features that don't exist in the system
Do NOT use list tools when the user clearly knows which specific record they want — use the get tool instead.
Rationale Requirement:
Add this line to every channel system prompt:
Before calling any tool, briefly state which tool you're choosing and why.
This reduces misrouting by forcing the LLM to reason about tool selection before acting.
Tool description in system prompts:
DON'T list raw tool names. DO describe capabilities in natural language:
❌ BAD:
"Tools: list_contacts, get_contact, create_contact, update_contact, delete_contact"
✅ GOOD:
"You can manage contacts — search and browse the contact list, view detailed contact profiles,
create new contacts, update existing information, and remove contacts when needed."
When to use tools:
Tell the AI explicitly when to use tools vs respond from knowledge:
When the user asks about their specific data (contacts, deals, appointments), use the appropriate
MCP tools to fetch real data. When they ask general questions about CRM best practices or strategy,
respond from your expertise without making API calls.
Few-Shot Examples (include 2-3 in systemPromptAddon):
Few-shot examples are the single most effective technique for consistent tool routing and APP_DATA generation. Include them in the systemPromptAddon, NOT the channel prompt (to stay within token budget).
Example 1: List operation → Data Grid
USER: "Show me all active contacts"
ASSISTANT: I'll use list_contacts to pull up your active contacts, filtered by status.
[calls list_contacts with status="active"]
Here are your active contacts. You have 23 active contacts in your system.
<!--APP_DATA:{"title":"Active Contacts","data":[{"name":"John Smith","email":"john@example.com","phone":"(555) 123-4567","status":"active","created":"2026-01-15"},{"name":"Jane Doe","email":"jane@example.com","phone":"(555) 987-6543","status":"active","created":"2026-01-20"}],"meta":{"total":23,"page":1,"pageSize":25}}:END_APP_DATA-->
Example 2: Specific item → Detail Card
USER: "What's John Smith's email?"
ASSISTANT: I'll use get_contact to look up John Smith's full profile.
[calls get_contact with name="John Smith"]
John Smith's email is john@example.com. Here's his full profile:
<!--APP_DATA:{"name":"John Smith","email":"john@example.com","phone":"(555) 123-4567","status":"active","company":"Acme Inc","tags":["vip"],"created":"2026-01-15","lastActivity":"2026-02-03T14:30:00Z"}:END_APP_DATA-->
Example 3: General question → NO tool call
USER: "What's the best way to organize my contacts?"
ASSISTANT: Great question! Here are some CRM best practices for contact organization:
1. **Use tags consistently** — Create a standard set of tags (lead, customer, vip, partner) and apply them to every contact.
2. **Keep statuses updated** — Move contacts through your pipeline stages promptly.
3. **Add notes after every interaction** — Even a one-liner helps you remember context.
4. **Regular cleanup** — Review inactive contacts quarterly and archive or re-engage.
Would you like me to pull up your current contacts so we can start organizing?
Note on MCP Prompts: Channel system prompts could be registered as MCP Prompt resources (via the server's
promptscapability) for discoverability and versioning. Instead of hardcoding prompts inroute.ts, servers could expose them asprompts/listentries, allowing clients to discover available interaction modes and enabling prompt versioning through the MCP protocol.
Note on MCP Roots: MCP Roots let clients inform servers about workspace/project scope. For LocalBosses, roots could scope which data is relevant per channel — e.g., a "CRM" root that tells the server to only expose CRM-related tools and data. This would enable dynamic tool filtering based on channel context rather than static system prompts.
9. Thread Lifecycle & State Management
How threads work:
1. User clicks app in toolbar
2. App intake question appears (from app-intakes.ts)
3. User responds (or clicks "skip" if skipLabel exists)
4. AI receives: channel system prompt + THREAD_SYSTEM_PROMPT + intake systemPromptAddon + user message
5. AI generates response + APP_DATA block
6. Frontend parses APP_DATA, sends to iframe via postMessage
7. App renders the data
8. User can continue chatting to refine
9. Each AI response generates new APP_DATA (replaces old)
Thread-specific behavior:
- Each thread is tied to ONE app — the app stays open above the chat
- The AI always includes APP_DATA in thread responses
- When user refines ("show me only active contacts"), AI generates NEW APP_DATA
- Thread can be closed/deleted without affecting the app or other threads
Thread State Management
Threads use localStorage for persistence. Be aware of these operational constraints:
| Concern | Details | Mitigation |
|---|---|---|
| Storage mechanism | localStorage in the browser — key-value, synchronous, per-origin |
Thread data is JSON-serialized per thread ID |
| Persistence | Survives page reload and browser restart. Cleared on cache clear or incognito close. | Not a permanent store — don't rely on it for critical data |
| Expiry / Cleanup | No automatic expiry. Old threads accumulate indefinitely. | Implement cleanup: delete threads older than 30 days on app load |
| Max thread count | No hard limit, but performance degrades with 100+ threads in localStorage | Warn or auto-archive after 50 threads per channel. Archive = move to a compressed summary. |
| Storage quota | ~5-10 MB per origin (browser-dependent). Each thread with APP_DATA ≈ 2-20 KB. | At 5 MB limit: ~250-2,500 threads before quota exceeded. Handle QuotaExceededError gracefully. |
| Quota exceeded handling | localStorage.setItem() throws QuotaExceededError |
Catch the error, delete oldest threads until space is available, notify user |
Recommended cleanup pattern:
function cleanupOldThreads(maxAgeDays = 30, maxCount = 50) {
const threads = getAllThreads(); // from localStorage
const now = Date.now();
const cutoff = now - (maxAgeDays * 24 * 60 * 60 * 1000);
// Delete by age
threads.filter(t => t.lastActivity < cutoff).forEach(t => deleteThread(t.id));
// Delete by count (keep newest)
const remaining = getAllThreads().sort((a, b) => b.lastActivity - a.lastActivity);
if (remaining.length > maxCount) {
remaining.slice(maxCount).forEach(t => deleteThread(t.id));
}
}
10. Channel Configuration Rollback Strategy
Adding a channel requires editing 4 source files. If integration fails or QA reveals problems, you need a clean way to undo.
Strategy 1: Git-Based Rollback (Recommended)
# BEFORE integration: create a checkpoint
git add -A && git commit -m "pre-integration checkpoint: {service}"
# DO the integration (edit all 4 files)
# ... edit channels.ts, appNames.ts, app-intakes.ts, route.ts ...
# TEST the integration
npm run build && npm run dev
# Run QA checks...
# IF QA PASSES:
git add -A && git commit -m "feat: add {service} channel integration"
# IF QA FAILS:
git checkout -- src/lib/channels.ts src/lib/appNames.ts src/lib/app-intakes.ts src/app/api/mcp-apps/route.ts
# Clean revert, no broken state
Strategy 2: Feature-Flag Rollback
For production deployments, use a feature flag so new channels can be toggled without code changes:
// In channels.ts:
{
id: "{service}",
name: "{service}",
enabled: process.env.ENABLE_SERVICE_CHANNEL === "true", // default: disabled
// ... rest of config
}
// Filter in sidebar rendering:
const visibleChannels = channels.filter(c => c.enabled !== false);
Workflow:
- Integrate with
enabled: false(or env varENABLE_SERVICE_CHANNEL=false) - Deploy to production — channel is invisible
- QA in production with
ENABLE_SERVICE_CHANNEL=truein your session - If QA passes: set env var to
trueglobally - If QA fails: leave disabled, fix, redeploy
Strategy 3: Manifest-Based (Future)
Instead of editing 4 shared TypeScript files, each channel could be defined in a single JSON manifest file:
channels/{service}.json → contains all config (channel def, app names, intakes, route map)
Delete the file = remove the channel. This is the cleanest approach but requires refactoring the LocalBosses codebase.
11. Complete Example: Adding a New Service
Let's walk through adding "Calendly" as a complete example, applying all patterns from this guide:
channels.ts:
{
id: "calendly",
name: "calendly",
icon: "📅",
category: "BUSINESS OPS",
description: "Manage scheduling, appointments, and calendars",
systemPrompt: `You are the Scheduling Specialist for LocalBosses AI, powered by Calendly.
Your expertise:
- Managing event types and scheduling links
- Viewing and managing scheduled events
- Finding available time slots
- Scheduling analytics and insights
TOOL SELECTION RULES:
- SEE/BROWSE events → use list_scheduled_events
- ONE specific event by ID → use get_event
- VIEW event types → use list_event_types
- CANCEL/RESCHEDULE → use cancel_event (always confirm first)
- SCHEDULING METRICS → use get_scheduling_analytics
- AVAILABILITY → use get_availability
Before calling any tool, briefly state which tool you're choosing and why.
Do NOT call tools when users ask about scheduling best practices, time management tips, or general calendar advice. Respond from your expertise instead.
Be concise and action-oriented.`,
defaultApp: "calendly-dashboard",
mcpApps: [
"calendly-dashboard",
"calendly-event-grid",
"calendly-event-detail",
"calendly-calendar",
"calendly-availability",
],
},
appNames.ts:
"calendly-dashboard": { name: "Dashboard", icon: "📊" },
"calendly-event-grid": { name: "Events", icon: "📋" },
"calendly-event-detail": { name: "Event Detail", icon: "📄" },
"calendly-calendar": { name: "Calendar", icon: "📆" },
"calendly-availability": { name: "Availability", icon: "🕐" },
app-intakes.ts:
"calendly-dashboard": {
category: "dashboard",
question: "What time frame? e.g., this week, last month, Q1 2026",
skipLabel: "Last 30 days",
systemPromptAddon: `Generate APP_DATA for the Calendly dashboard.
Required fields:
- "title": descriptive (e.g., "Scheduling Dashboard — Last 30 Days")
- "timeFrame": string matching user request
- "metrics": { "total_events", "upcoming", "completed", "cancelled" }
- "recent": array of 3-5 recent events with { "title", "date" (ISO), "type" }
Example interaction:
USER: "Show me last week's stats"
→ Use get_scheduling_analytics with date range = last 7 days
→ Return APP_DATA with metrics and recent events from that period
<!--APP_DATA:{"title":"Scheduling Dashboard — Last 7 Days","timeFrame":"Last 7 days","metrics":{"total_events":12,"upcoming":3,"completed":8,"cancelled":1},"recent":[{"title":"Strategy Call","date":"2026-02-03T10:00:00Z","type":"completed"}]}:END_APP_DATA-->`,
},
"calendly-event-grid": {
category: "data-grid",
question: "Filter events by date, status, or type — or say 'all upcoming'.",
skipLabel: "All upcoming events",
systemPromptAddon: `Generate APP_DATA for the event grid.
Required fields:
- "title": descriptive (e.g., "Upcoming Events")
- "data": array of events, each with { "name", "email" (invitee), "date" (ISO), "status", "duration", "type" }
- "meta": { "total", "page", "pageSize" }
Include 5-10 realistic records matching the user's filters.`,
},
"calendly-event-detail": {
category: "detail-card",
question: "Which event? Provide a name, date, or invitee.",
systemPromptAddon: `Generate APP_DATA for a single event detail.
Required fields: "title", "name", "status", "start" (ISO), "end" (ISO), "attendee", "email", "eventType", "location", "notes"
All fields top-level (no wrapping data object).`,
},
"calendly-calendar": {
category: "calendar",
question: "Which date range? e.g., this week, February, next 14 days",
skipLabel: "This week",
systemPromptAddon: `Generate APP_DATA for the calendar view with events in the requested range.
Required fields:
- "title": descriptive
- "events": array with { "title", "start" (ISO), "end" (ISO), "contact", "status", "location" }`,
},
"calendly-availability": {
category: "form",
question: "Which schedule's availability? e.g., 'my default schedule'",
systemPromptAddon: `Generate APP_DATA with availability settings as form fields.
Required fields:
- "title", "description"
- "fields": array with { "name", "label", "type", "required" }`,
},
mcp-apps/route.ts:
// In APP_NAME_MAP:
"calendly-dashboard": "dashboard",
"calendly-event-grid": "event-grid",
"calendly-event-detail": "event-detail",
"calendly-calendar": "calendar-view",
"calendly-availability": "availability",
// In APP_DIRS:
join(process.cwd(), "../calendly-mcp/app-ui"),
12. Integration Validation Script
Run this script after every integration to catch missing or orphaned entries across all 4 files.
Save as scripts/validate-integration.ts and run with npx ts-node scripts/validate-integration.ts (or transpile and run with Node).
#!/usr/bin/env ts-node
/**
* MCP LocalBosses Integration Validator
*
* Cross-references all 4 integration files to find:
* - Missing entries (app ID in channels.ts but not in other files)
* - Orphaned entries (app ID in appNames/intakes/route but not in any channel)
* - File resolution failures (APP_NAME_MAP entry doesn't resolve to an HTML file)
*
* Usage: npx ts-node scripts/validate-integration.ts
* or: node scripts/validate-integration.js (after compiling)
*
* Exit code: 0 = all good, 1 = errors found
*/
import * as fs from "fs";
import * as path from "path";
// ─── Configuration ───────────────────────────────────────────
const BASE_DIR = path.resolve(__dirname, "../src");
const CHANNELS_FILE = path.join(BASE_DIR, "lib/channels.ts");
const APP_NAMES_FILE = path.join(BASE_DIR, "lib/appNames.ts");
const APP_INTAKES_FILE = path.join(BASE_DIR, "lib/app-intakes.ts");
const ROUTE_FILE = path.join(BASE_DIR, "app/api/mcp-apps/route.ts");
// ─── Parsers ─────────────────────────────────────────────────
function readFile(filePath: string): string {
if (!fs.existsSync(filePath)) {
console.error(`❌ File not found: ${filePath}`);
process.exit(1);
}
return fs.readFileSync(filePath, "utf-8");
}
/**
* Extract all app IDs from channels.ts mcpApps arrays.
* Looks for patterns like: mcpApps: ["app-1", "app-2", ...]
* and string literals inside those arrays.
*/
function parseChannelApps(source: string): { channelId: string; apps: string[] }[] {
const channels: { channelId: string; apps: string[] }[] = [];
// Match channel blocks with id and mcpApps
const channelBlockRegex = /\{\s*(?:[^{}]*?)id:\s*["'`]([^"'`]+)["'`][^{}]*?mcpApps:\s*\[([\s\S]*?)\]/g;
let match: RegExpExecArray | null;
while ((match = channelBlockRegex.exec(source)) !== null) {
const channelId = match[1];
const appsArrayContent = match[2];
const appIds = [...appsArrayContent.matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
channels.push({ channelId, apps: appIds });
}
// Fallback: if regex didn't catch structured blocks, try simpler pattern
if (channels.length === 0) {
const simpleRegex = /mcpApps:\s*\[([\s\S]*?)\]/g;
while ((match = simpleRegex.exec(source)) !== null) {
const appIds = [...match[1].matchAll(/["'`]([^"'`]+)["'`]/g)].map((m) => m[1]);
if (appIds.length > 0) {
channels.push({ channelId: "unknown", apps: appIds });
}
}
}
return channels;
}
/**
* Extract all keys from appNames.ts APP_DISPLAY_NAMES object.
* Looks for patterns like: "app-id": { name: "...", icon: "..." }
*/
function parseAppNames(source: string): string[] {
const keys: string[] = [];
const regex = /["'`]([a-z0-9][\w-]*)["'`]\s*:\s*\{\s*name\s*:/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(source)) !== null) {
keys.push(match[1]);
}
return keys;
}
/**
* Extract all keys from app-intakes.ts APP_INTAKES object.
* Looks for patterns like: "app-id": { category: "...", question: "..." }
*/
function parseAppIntakes(source: string): string[] {
const keys: string[] = [];
const regex = /["'`]([a-z0-9][\w-]*)["'`]\s*:\s*\{\s*(?:category|question)\s*:/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(source)) !== null) {
keys.push(match[1]);
}
return keys;
}
/**
* Extract APP_NAME_MAP keys and values, plus APP_DIRS paths.
*/
function parseRouteFile(source: string): { nameMap: Map<string, string>; dirs: string[] } {
const nameMap = new Map<string, string>();
// Extract APP_NAME_MAP entries: "app-id": "filename"
const mapRegex = /["'`]([a-z0-9][\w-]*)["'`]\s*:\s*["'`]([^"'`]+)["'`]/g;
// Only match within APP_NAME_MAP block
const mapBlockMatch = source.match(/APP_NAME_MAP[^{]*\{([\s\S]*?)\}/);
if (mapBlockMatch) {
let match: RegExpExecArray | null;
const block = mapBlockMatch[1];
while ((match = mapRegex.exec(block)) !== null) {
nameMap.set(match[1], match[2]);
}
}
// Extract APP_DIRS paths
const dirs: string[] = [];
const dirsBlockMatch = source.match(/APP_DIRS\s*=\s*\[([\s\S]*?)\]/);
if (dirsBlockMatch) {
const pathRegex = /["'`]([^"'`]+)["'`]/g;
let match: RegExpExecArray | null;
while ((match = pathRegex.exec(dirsBlockMatch[1])) !== null) {
dirs.push(match[1]);
}
// Also handle join() patterns
const joinRegex = /join\s*\([^)]*["'`]([^"'`]+)["'`]\s*\)/g;
while ((match = joinRegex.exec(dirsBlockMatch[1])) !== null) {
dirs.push(match[1]);
}
}
return { nameMap, dirs };
}
/**
* Check if an HTML file exists for a given filename in any of the app directories.
*/
function resolveHtmlFile(filename: string, dirs: string[], projectRoot: string): string | null {
for (const dir of dirs) {
const resolvedDir = dir.startsWith("/") ? dir : path.resolve(projectRoot, dir);
const flatPath = path.join(resolvedDir, `${filename}.html`);
const indexPath = path.join(resolvedDir, filename, "index.html");
if (fs.existsSync(flatPath)) return flatPath;
if (fs.existsSync(indexPath)) return indexPath;
}
return null;
}
// ─── Main Validation ─────────────────────────────────────────
function validate() {
console.log("🔍 MCP LocalBosses Integration Validator\n");
console.log("═".repeat(60));
let errors = 0;
let warnings = 0;
// 1. Parse all files
const channelsSource = readFile(CHANNELS_FILE);
const appNamesSource = readFile(APP_NAMES_FILE);
const appIntakesSource = readFile(APP_INTAKES_FILE);
const routeSource = readFile(ROUTE_FILE);
const channelData = parseChannelApps(channelsSource);
const appNameKeys = new Set(parseAppNames(appNamesSource));
const appIntakeKeys = new Set(parseAppIntakes(appIntakesSource));
const { nameMap: routeNameMap, dirs: routeDirs } = parseRouteFile(routeSource);
// Collect ALL app IDs referenced in channels
const allChannelApps = new Set<string>();
for (const channel of channelData) {
for (const app of channel.apps) {
allChannelApps.add(app);
}
}
console.log(`\n📊 Parsed Summary:`);
console.log(` Channels: ${channelData.length}`);
console.log(` Channel app references: ${allChannelApps.size}`);
console.log(` appNames entries: ${appNameKeys.size}`);
console.log(` app-intakes entries: ${appIntakeKeys.size}`);
console.log(` route APP_NAME_MAP entries: ${routeNameMap.size}`);
console.log(` route APP_DIRS: ${routeDirs.length}`);
// 2. Cross-reference: every app in channels must exist in other 3 files
console.log(`\n${"─".repeat(60)}`);
console.log(`\n🔗 Cross-Reference: Apps in channels.ts → other files\n`);
for (const channel of channelData) {
for (const appId of channel.apps) {
const inNames = appNameKeys.has(appId);
const inIntakes = appIntakeKeys.has(appId);
const inRoute = routeNameMap.has(appId);
if (!inNames || !inIntakes || !inRoute) {
const missing: string[] = [];
if (!inNames) missing.push("appNames.ts");
if (!inIntakes) missing.push("app-intakes.ts");
if (!inRoute) missing.push("route.ts");
console.log(` ❌ "${appId}" (channel: ${channel.channelId}) — MISSING from: ${missing.join(", ")}`);
errors++;
}
}
}
if (errors === 0) {
console.log(` ✅ All channel apps found in all 3 files`);
}
// 3. Find orphaned entries (in appNames/intakes/route but not in any channel)
console.log(`\n${"─".repeat(60)}`);
console.log(`\n🗑️ Orphaned Entries (in files but not in any channel)\n`);
let orphanCount = 0;
for (const key of appNameKeys) {
if (!allChannelApps.has(key)) {
console.log(` ⚠️ "${key}" in appNames.ts but not in any channel's mcpApps`);
warnings++;
orphanCount++;
}
}
for (const key of appIntakeKeys) {
if (!allChannelApps.has(key)) {
console.log(` ⚠️ "${key}" in app-intakes.ts but not in any channel's mcpApps`);
warnings++;
orphanCount++;
}
}
for (const key of routeNameMap.keys()) {
if (!allChannelApps.has(key)) {
console.log(` ⚠️ "${key}" in route.ts APP_NAME_MAP but not in any channel's mcpApps`);
warnings++;
orphanCount++;
}
}
if (orphanCount === 0) {
console.log(` ✅ No orphaned entries`);
}
// 4. Verify HTML file resolution
console.log(`\n${"─".repeat(60)}`);
console.log(`\n📁 HTML File Resolution (APP_NAME_MAP → actual files)\n`);
const projectRoot = path.resolve(__dirname, "..");
let resolutionFailures = 0;
for (const [appId, filename] of routeNameMap.entries()) {
const resolved = resolveHtmlFile(filename, routeDirs, projectRoot);
if (!resolved) {
console.log(` ❌ "${appId}" → "${filename}.html" — NOT FOUND in any APP_DIRS`);
errors++;
resolutionFailures++;
}
}
if (resolutionFailures === 0) {
console.log(` ✅ All APP_NAME_MAP entries resolve to HTML files`);
}
// 5. Summary
console.log(`\n${"═".repeat(60)}`);
console.log(`\n📋 RESULTS: ${errors} errors, ${warnings} warnings`);
if (errors > 0) {
console.log(`\n❌ VALIDATION FAILED — fix ${errors} error(s) before deploying`);
process.exit(1);
} else if (warnings > 0) {
console.log(`\n⚠️ VALIDATION PASSED with ${warnings} warning(s) — review orphaned entries`);
process.exit(0);
} else {
console.log(`\n✅ VALIDATION PASSED — all integrations are consistent`);
process.exit(0);
}
}
validate();
Run in CI:
# Add to package.json scripts:
"validate:integration": "ts-node scripts/validate-integration.ts"
# Or without ts-node (compile first):
"validate:integration": "tsc scripts/validate-integration.ts --outDir scripts/dist && node scripts/dist/validate-integration.js"
Run before every deploy and as part of Phase 5 QA.
13. Quality Gate Checklist
Before passing to Phase 5 (QA), verify:
- Channel appears in sidebar — under correct category with correct icon
- All apps appear in toolbar — when channel is selected
- Default app auto-opens — if defaultApp is configured
- Clicking each app opens a thread — with the intake question
- "Skip" button works — if skipLabel is defined
- AI generates APP_DATA — in every thread response
- App receives data — visual app updates when AI responds
- Refinement works — asking follow-up questions generates new APP_DATA
- System prompt is comprehensive — includes tool routing rules, negative instructions, rationale requirement
- System prompt is under budget — channel prompt < 500 tokens, addons < 300 tokens each
- No 404s for app files — all HTML files resolve in mcp-apps route
- No missing entries — every app ID appears in all 4 files (channels, appNames, intakes, route)
- Validation script passes —
npm run validate:integrationexits with code 0 - Intake questions meet quality criteria — format hints, skipLabels, under 20 words, action-oriented
- Test fixtures generated —
test-fixtures/tool-routing.jsonbaseline created for QA (see below)
Per-Service Test Fixture Generation
The integrator should generate a test-fixtures/tool-routing.json baseline for the QA tester (Phase 5). This file maps natural-language user messages to expected tool calls, derived from the system prompt's tool routing rules:
{
"service": "{service}",
"fixtures": [
{ "message": "show me all contacts", "expectedTool": "list_contacts", "expectedArgs": {} },
{ "message": "find John Smith", "expectedTool": "get_contact", "expectedArgs": { "name": "John Smith" } },
{ "message": "add a new contact named Sarah", "expectedTool": "create_contact", "expectedArgs": { "name": "Sarah" } },
{ "message": "delete the old lead", "expectedTool": null, "expectedBehavior": "should ask for confirmation and specifics" },
{ "message": "what's the best way to organize contacts?", "expectedTool": null, "expectedBehavior": "respond from expertise, no tool call" }
]
}
Generate at least 20 fixtures per service covering: list, get, create, update, delete, analytics, no-tool-needed, ambiguous queries, and multi-intent messages. Save to {service}-mcp/test-fixtures/tool-routing.json. The QA tester uses these for tool routing validation.
Cross-reference check (critical):
Every app ID must appear in ALL of these:
channels.ts— in themcpAppsarrayappNames.ts— inAPP_DISPLAY_NAMESapp-intakes.ts— inAPP_INTAKESmcp-apps/route.ts— inAPP_NAME_MAP
Missing from any one = broken experience. Use the validation script (Section 12) to automate this check.
14. MCP Protocol Bridge: structuredContent → APP_DATA
This section documents how MCP's native
structuredContentrelates to LocalBosses' APP_DATA pattern, and the roadmap for convergence.
The Two Layers
MCP Protocol Layer (standard):
- MCP tools return results with
content(text fallback) andstructuredContent(typed JSON) - Tools declare
outputSchemaso clients know the data shape - This is the standard way to send typed data from tools to clients
LocalBosses Application Layer (custom):
- The APP_DATA block (
<!--APP_DATA:...:END_APP_DATA-->) embeds structured data in LLM-generated text - The frontend parses APP_DATA and routes it to the appropriate iframe app via postMessage
- This is a LocalBosses-specific convention, NOT part of the MCP protocol
How They Connect Today
MCP Tool → structuredContent (typed JSON)
↓
LLM receives tool result, generates response
↓
LLM embeds data as APP_DATA block in response text
↓
LocalBosses frontend parses APP_DATA
↓
Frontend sends data to app iframe via postMessage
The LLM is the bridge — it receives structuredContent from the tool and re-serializes it as APP_DATA. This works but is lossy (the LLM may modify, truncate, or malform the data).
Roadmap
| Phase | Approach | Status |
|---|---|---|
| Short-term (current) | APP_DATA pattern — LLM embeds JSON in response text, frontend parses | ✅ Implemented |
| Medium-term | Route structuredContent directly to apps — bypass LLM re-serialization. When a tool returns structuredContent, send it directly to the appropriate app without waiting for the LLM to echo it. |
🔜 Planned |
| Long-term | Adopt official MCP Apps protocol (launched Jan 2026) — tools declare _meta.ui.resourceUri, apps communicate via JSON-RPC over postMessage, bidirectional data flow. ⚠️ This is live NOW — Claude, ChatGPT, VS Code, and Goose all support MCP Apps today. |
🔴 Live — Adopt ASAP |
Medium-Term Architecture
MCP Tool returns structuredContent
↓
LocalBosses chat route intercepts structuredContent from tool result
↓
Routes directly to app iframe via postMessage (no LLM re-serialization)
↓
LLM still generates text explanation, but data is sourced from tool result, not LLM output
Benefits: No JSON parsing failures, no data loss from LLM re-serialization, schema-validated data.
Long-Term → NOW: MCP Apps Protocol (⚠️ Live — Adopt ASAP)
Urgency: The MCP Apps extension launched January 26, 2026 and is already supported by Claude, ChatGPT, VS Code, and Goose. This is NOT a future consideration — it's a live standard. Our APP_DATA pattern works only in LocalBosses; MCP Apps works in ANY MCP client.
The official MCP Apps extension defines:
_meta.ui.resourceUrion tools — declares which UI resource to renderui://resource URIs served by the MCP server@modelcontextprotocol/ext-appsSDK — standardized App class withontoolresult,callServerTool,updateModelContext- JSON-RPC over postMessage for bidirectional app ↔ server communication
Migration path:
- Add
_meta.ui.resourceUrito tool definitions in the server builder - Register app HTML files as
ui://resources in each MCP server - Update app template to use
@modelcontextprotocol/ext-appsApp class for data reception - Maintain backward compatibility with postMessage/APP_DATA for LocalBosses during transition
Impact: MCP tools work in ANY MCP client (Claude, ChatGPT, VS Code) — not just LocalBosses. Massive distribution multiplier.
15. Execution Workflow
1. Create git checkpoint: git add -A && git commit -m "pre-integration: {service}"
2. Read {service}-api-analysis.md — get app IDs and tool groups
3. Update channels.ts — add channel definition with system prompt (include tool routing rules)
4. Update appNames.ts — add display names and icons
5. Update app-intakes.ts — add intake questions (meeting quality criteria) and systemPromptAddons
6. Update mcp-apps/route.ts — add APP_NAME_MAP entries and APP_DIRS path
7. Verify chat/route.ts — ensure THREAD_SYSTEM_PROMPT works (usually no changes needed)
8. Run validation script: npx ts-node scripts/validate-integration.ts
9. Fix any errors/warnings from validation
10. Test: build LocalBosses, open channel, click app, verify thread + data flow
11. If QA passes: git add -A && git commit -m "feat: add {service} channel integration"
12. If QA fails: git checkout -- src/lib/channels.ts src/lib/appNames.ts src/lib/app-intakes.ts src/app/api/mcp-apps/route.ts
Estimated time: 30-60 minutes per channel.
Agent model recommendation: Sonnet — well-defined patterns, file editing. But system prompt crafting benefits from Opus for nuanced AI instruction design.
This skill is Phase 4 of the MCP Factory pipeline. It wires the server and apps into LocalBosses so everything is accessible through the UI.