Daily backup: 2026-01-30

This commit is contained in:
Jake Shore 2026-01-30 23:00:51 -05:00
parent cbc2f5e973
commit 30d55b5899
42 changed files with 25504 additions and 108 deletions

View File

@ -15,4 +15,4 @@
Building bulk animation generator for MCP marketing. Using Remotion with canvas viewport technique (dolly camera). Camera should zoom into typing area, follow text as typed, zoom out when done. Category-specific questions per software type.
---
*Last updated: 2026-01-28 23:00 EST*
*Last updated: 2026-01-30 23:00 EST*

View File

@ -5,7 +5,7 @@
"command": "node",
"args": ["/Users/jakeshore/.clawdbot/workspace/mcp-diagrams/GoHighLevel-MCP/dist/server.js"],
"env": {
"GHL_API_KEY": "pit-0aebc49f-07f7-47dc-a494-181b72a1df54",
"GHL_API_KEY": "pit-0480982f-750b-4baa-bc10-1340a9b2102b",
"GHL_BASE_URL": "https://services.leadconnectorhq.com",
"GHL_LOCATION_ID": "DZEpRd43MxUJKdtrev9t",
"NODE_ENV": "production"

@ -1 +1 @@
Subproject commit 69db02d7cf9aca4fc39ae34b6f20f26617e57316
Subproject commit 19b03fe777b18657c9e31ec79e740fac568cfa03

View File

@ -0,0 +1,11 @@
# GoHighLevel API Configuration
GHL_API_KEY=your_private_integration_api_key_here
GHL_BASE_URL=https://services.leadconnectorhq.com
GHL_LOCATION_ID=your_location_id_here
# Server Configuration
MCP_SERVER_PORT=8000
NODE_ENV=development
# Optional: For AI features
OPENAI_API_KEY=your_openai_key_here_optional

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,654 @@
/**
* MCP Apps Manager
* Manages rich UI components for GoHighLevel MCP Server
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
/**
* MCP Apps Manager class
* Registers app tools and handles structuredContent responses
*/
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve UI build path - works regardless of working directory
function getUIBuildPath() {
// When compiled, this file is at dist/apps/index.js
// UI files are at dist/app-ui/
const fromDist = path.resolve(__dirname, '..', 'app-ui');
if (fs.existsSync(fromDist)) {
return fromDist;
}
// Fallback: try process.cwd() based paths
const appUiPath = path.join(process.cwd(), 'dist', 'app-ui');
if (fs.existsSync(appUiPath)) {
return appUiPath;
}
// Default fallback
return fromDist;
}
export class MCPAppsManager {
ghlClient;
resourceHandlers = new Map();
uiBuildPath;
constructor(ghlClient) {
this.ghlClient = ghlClient;
this.uiBuildPath = getUIBuildPath();
process.stderr.write(`[MCP Apps] UI build path: ${this.uiBuildPath}\n`);
this.registerResourceHandlers();
}
/**
* Register all UI resource handlers
*/
registerResourceHandlers() {
const resources = [
// All 11 MCP Apps
{ uri: 'ui://ghl/mcp-app', file: 'mcp-app.html' },
{ uri: 'ui://ghl/pipeline-board', file: 'pipeline-board.html' },
{ uri: 'ui://ghl/quick-book', file: 'quick-book.html' },
{ uri: 'ui://ghl/opportunity-card', file: 'opportunity-card.html' },
{ uri: 'ui://ghl/contact-grid', file: 'contact-grid.html' },
{ uri: 'ui://ghl/calendar-view', file: 'calendar-view.html' },
{ uri: 'ui://ghl/invoice-preview', file: 'invoice-preview.html' },
{ uri: 'ui://ghl/campaign-stats', file: 'campaign-stats.html' },
{ uri: 'ui://ghl/agent-stats', file: 'agent-stats.html' },
{ uri: 'ui://ghl/contact-timeline', file: 'contact-timeline.html' },
{ uri: 'ui://ghl/workflow-status', file: 'workflow-status.html' },
];
for (const resource of resources) {
this.resourceHandlers.set(resource.uri, {
uri: resource.uri,
mimeType: 'text/html;profile=mcp-app',
getContent: () => this.loadUIResource(resource.file),
});
}
}
/**
* Load UI resource from build directory
*/
loadUIResource(filename) {
const filePath = path.join(this.uiBuildPath, filename);
try {
return fs.readFileSync(filePath, 'utf-8');
}
catch (error) {
process.stderr.write(`[MCP Apps] UI resource not found: ${filePath}\n`);
return this.getFallbackHTML(filename);
}
}
/**
* Generate fallback HTML when UI resource is not built
*/
getFallbackHTML(filename) {
const componentName = filename.replace('.html', '');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GHL ${componentName}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; }
.fallback { text-align: center; color: #666; }
</style>
</head>
<body>
<div class="fallback">
<p>UI component "${componentName}" is loading...</p>
<p>Run <code>npm run build:ui</code> to build UI components.</p>
</div>
<script>
window.addEventListener('message', (e) => {
if (e.data?.type === 'mcp-app-init') {
console.log('MCP App data:', e.data.data);
}
});
</script>
</body>
</html>
`.trim();
}
/**
* Get tool definitions for all app tools
*/
getToolDefinitions() {
return [
// 1. Contact Grid - search and display contacts
{
name: 'view_contact_grid',
description: 'Display contact search results in a data grid with sorting and pagination. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query string' },
limit: { type: 'number', description: 'Maximum results (default: 25)' }
}
},
_meta: {
ui: { resourceUri: 'ui://ghl/contact-grid' }
}
},
// 2. Pipeline Board - Kanban view of opportunities
{
name: 'view_pipeline_board',
description: 'Display a pipeline as an interactive Kanban board with opportunities. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
pipelineId: { type: 'string', description: 'Pipeline ID to display' }
},
required: ['pipelineId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/pipeline-board' }
}
},
// 3. Quick Book - appointment booking
{
name: 'view_quick_book',
description: 'Display a quick booking interface for scheduling appointments. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
calendarId: { type: 'string', description: 'Calendar ID for booking' },
contactId: { type: 'string', description: 'Optional contact ID to pre-fill' }
},
required: ['calendarId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/quick-book' }
}
},
// 4. Opportunity Card - single opportunity details
{
name: 'view_opportunity_card',
description: 'Display a single opportunity with details, value, and stage info. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
opportunityId: { type: 'string', description: 'Opportunity ID to display' }
},
required: ['opportunityId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/opportunity-card' }
}
},
// 5. Calendar View - calendar with events
{
name: 'view_calendar',
description: 'Display a calendar with events and appointments. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
calendarId: { type: 'string', description: 'Calendar ID to display' },
startDate: { type: 'string', description: 'Start date (ISO format)' },
endDate: { type: 'string', description: 'End date (ISO format)' }
},
required: ['calendarId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/calendar-view' }
}
},
// 6. Invoice Preview - invoice details
{
name: 'view_invoice',
description: 'Display an invoice preview with line items and payment status. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
invoiceId: { type: 'string', description: 'Invoice ID to display' }
},
required: ['invoiceId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/invoice-preview' }
}
},
// 7. Campaign Stats - campaign performance metrics
{
name: 'view_campaign_stats',
description: 'Display campaign statistics and performance metrics. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
campaignId: { type: 'string', description: 'Campaign ID to display stats for' }
},
required: ['campaignId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/campaign-stats' }
}
},
// 8. Agent Stats - agent/user performance
{
name: 'view_agent_stats',
description: 'Display agent/user performance statistics and metrics. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
userId: { type: 'string', description: 'User/Agent ID to display stats for' },
dateRange: { type: 'string', description: 'Date range (e.g., "last7days", "last30days")' }
}
},
_meta: {
ui: { resourceUri: 'ui://ghl/agent-stats' }
}
},
// 9. Contact Timeline - activity history for a contact
{
name: 'view_contact_timeline',
description: 'Display a contact\'s activity timeline with all interactions. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
contactId: { type: 'string', description: 'Contact ID to display timeline for' }
},
required: ['contactId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/contact-timeline' }
}
},
// 10. Workflow Status - workflow execution status
{
name: 'view_workflow_status',
description: 'Display workflow execution status and history. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
workflowId: { type: 'string', description: 'Workflow ID to display status for' }
},
required: ['workflowId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/workflow-status' }
}
},
// 11. MCP App - generic/main dashboard
{
name: 'view_dashboard',
description: 'Display the main GHL dashboard overview. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {}
},
_meta: {
ui: { resourceUri: 'ui://ghl/mcp-app' }
}
},
// 12. Update Opportunity - action tool for UI to update opportunities
{
name: 'update_opportunity',
description: 'Update an opportunity (move to stage, change value, status, etc.)',
inputSchema: {
type: 'object',
properties: {
opportunityId: { type: 'string', description: 'Opportunity ID to update' },
pipelineStageId: { type: 'string', description: 'New stage ID (for moving)' },
name: { type: 'string', description: 'Opportunity name' },
monetaryValue: { type: 'number', description: 'Monetary value' },
status: { type: 'string', enum: ['open', 'won', 'lost', 'abandoned'], description: 'Opportunity status' }
},
required: ['opportunityId']
}
}
];
}
/**
* Get app tool names for routing
*/
getAppToolNames() {
return [
'view_contact_grid',
'view_pipeline_board',
'view_quick_book',
'view_opportunity_card',
'view_calendar',
'view_invoice',
'view_campaign_stats',
'view_agent_stats',
'view_contact_timeline',
'view_workflow_status',
'view_dashboard',
'update_opportunity'
];
}
/**
* Check if a tool is an app tool
*/
isAppTool(toolName) {
return this.getAppToolNames().includes(toolName);
}
/**
* Execute an app tool
*/
async executeTool(toolName, args) {
process.stderr.write(`[MCP Apps] Executing app tool: ${toolName}\n`);
switch (toolName) {
case 'view_contact_grid':
return await this.viewContactGrid(args.query, args.limit);
case 'view_pipeline_board':
return await this.viewPipelineBoard(args.pipelineId);
case 'view_quick_book':
return await this.viewQuickBook(args.calendarId, args.contactId);
case 'view_opportunity_card':
return await this.viewOpportunityCard(args.opportunityId);
case 'view_calendar':
return await this.viewCalendar(args.calendarId, args.startDate, args.endDate);
case 'view_invoice':
return await this.viewInvoice(args.invoiceId);
case 'view_campaign_stats':
return await this.viewCampaignStats(args.campaignId);
case 'view_agent_stats':
return await this.viewAgentStats(args.userId, args.dateRange);
case 'view_contact_timeline':
return await this.viewContactTimeline(args.contactId);
case 'view_workflow_status':
return await this.viewWorkflowStatus(args.workflowId);
case 'view_dashboard':
return await this.viewDashboard();
case 'update_opportunity':
return await this.updateOpportunity(args);
default:
throw new Error(`Unknown app tool: ${toolName}`);
}
}
/**
* View contact grid (search results)
*/
async viewContactGrid(query, limit) {
const response = await this.ghlClient.searchContacts({
locationId: this.ghlClient.getConfig().locationId,
query: query,
limit: limit || 25
});
if (!response.success) {
throw new Error(response.error?.message || 'Failed to search contacts');
}
const data = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/contact-grid');
return this.createAppResult(`Found ${data?.contacts?.length || 0} contacts`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View pipeline board (Kanban)
*/
async viewPipelineBoard(pipelineId) {
const [pipelinesResponse, opportunitiesResponse] = await Promise.all([
this.ghlClient.getPipelines(),
this.ghlClient.searchOpportunities({
location_id: this.ghlClient.getConfig().locationId,
pipeline_id: pipelineId
})
]);
if (!pipelinesResponse.success) {
throw new Error(pipelinesResponse.error?.message || 'Failed to get pipeline');
}
const pipeline = pipelinesResponse.data?.pipelines?.find((p) => p.id === pipelineId);
const opportunities = opportunitiesResponse.data?.opportunities || [];
// Simplify opportunity data to only include fields the UI needs (reduces payload size)
const simplifiedOpportunities = opportunities.map((opp) => ({
id: opp.id,
name: opp.name || 'Untitled',
pipelineStageId: opp.pipelineStageId,
status: opp.status || 'open',
monetaryValue: opp.monetaryValue || 0,
contact: opp.contact ? {
name: opp.contact.name || 'Unknown',
email: opp.contact.email,
phone: opp.contact.phone
} : { name: 'Unknown' },
updatedAt: opp.updatedAt || opp.createdAt,
createdAt: opp.createdAt,
source: opp.source
}));
const data = {
pipeline,
opportunities: simplifiedOpportunities,
stages: pipeline?.stages || []
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/pipeline-board');
return this.createAppResult(`Pipeline: ${pipeline?.name || 'Unknown'} (${opportunities.length} opportunities)`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View quick book interface
*/
async viewQuickBook(calendarId, contactId) {
const [calendarResponse, contactResponse] = await Promise.all([
this.ghlClient.getCalendar(calendarId),
contactId ? this.ghlClient.getContact(contactId) : Promise.resolve({ success: true, data: null })
]);
if (!calendarResponse.success) {
throw new Error(calendarResponse.error?.message || 'Failed to get calendar');
}
const data = {
calendar: calendarResponse.data,
contact: contactResponse.data,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/quick-book');
return this.createAppResult(`Quick booking for calendar: ${calendarResponse.data?.name || calendarId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View opportunity card
*/
async viewOpportunityCard(opportunityId) {
const response = await this.ghlClient.getOpportunity(opportunityId);
if (!response.success) {
throw new Error(response.error?.message || 'Failed to get opportunity');
}
const opportunity = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/opportunity-card');
return this.createAppResult(`Opportunity: ${opportunity?.name || opportunityId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), opportunity);
}
/**
* View calendar
*/
async viewCalendar(calendarId, startDate, endDate) {
const now = new Date();
const start = startDate || new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const end = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString();
const [calendarResponse, eventsResponse] = await Promise.all([
this.ghlClient.getCalendar(calendarId),
this.ghlClient.getCalendarEvents({
calendarId: calendarId,
startTime: start,
endTime: end,
locationId: this.ghlClient.getConfig().locationId
})
]);
if (!calendarResponse.success) {
throw new Error(calendarResponse.error?.message || 'Failed to get calendar');
}
const calendar = calendarResponse.data;
const data = {
calendar: calendarResponse.data,
events: eventsResponse.data?.events || [],
startDate: start,
endDate: end
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/calendar-view');
return this.createAppResult(`Calendar: ${calendar?.name || 'Unknown'} (${data.events.length} events)`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View campaign stats
*/
async viewCampaignStats(campaignId) {
// Get email campaigns
const response = await this.ghlClient.getEmailCampaigns({});
const campaigns = response.data?.schedules || [];
const campaign = campaigns.find((c) => c.id === campaignId) || { id: campaignId };
const data = {
campaign,
campaigns,
campaignId,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/campaign-stats');
return this.createAppResult(`Campaign stats: ${campaign?.name || campaignId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View agent stats
*/
async viewAgentStats(userId, dateRange) {
// Get location info which may include user data
const locationResponse = await this.ghlClient.getLocationById(this.ghlClient.getConfig().locationId);
const data = {
userId,
dateRange: dateRange || 'last30days',
location: locationResponse.data,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/agent-stats');
return this.createAppResult(userId ? `Agent stats: ${userId}` : 'Agent overview', resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View contact timeline
*/
async viewContactTimeline(contactId) {
const [contactResponse, notesResponse, tasksResponse] = await Promise.all([
this.ghlClient.getContact(contactId),
this.ghlClient.getContactNotes(contactId),
this.ghlClient.getContactTasks(contactId)
]);
if (!contactResponse.success) {
throw new Error(contactResponse.error?.message || 'Failed to get contact');
}
const contact = contactResponse.data;
const data = {
contact: contactResponse.data,
notes: notesResponse.data || [],
tasks: tasksResponse.data || []
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/contact-timeline');
return this.createAppResult(`Timeline for ${contact?.firstName || ''} ${contact?.lastName || ''}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View workflow status
*/
async viewWorkflowStatus(workflowId) {
const response = await this.ghlClient.getWorkflows({
locationId: this.ghlClient.getConfig().locationId
});
const workflows = response.data?.workflows || [];
const workflow = workflows.find((w) => w.id === workflowId) || { id: workflowId };
const data = {
workflow,
workflows,
workflowId,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/workflow-status');
return this.createAppResult(`Workflow: ${workflow?.name || workflowId}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View main dashboard
*/
async viewDashboard() {
const [contactsResponse, pipelinesResponse, calendarsResponse] = await Promise.all([
this.ghlClient.searchContacts({ locationId: this.ghlClient.getConfig().locationId, limit: 10 }),
this.ghlClient.getPipelines(),
this.ghlClient.getCalendars()
]);
const data = {
recentContacts: contactsResponse.data?.contacts || [],
pipelines: pipelinesResponse.data?.pipelines || [],
calendars: calendarsResponse.data?.calendars || [],
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/mcp-app');
return this.createAppResult('GHL Dashboard Overview', resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), data);
}
/**
* View invoice
*/
async viewInvoice(invoiceId) {
const response = await this.ghlClient.getInvoice(invoiceId, {
altId: this.ghlClient.getConfig().locationId,
altType: 'location'
});
if (!response.success) {
throw new Error(response.error?.message || 'Failed to get invoice');
}
const invoice = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/invoice-preview');
return this.createAppResult(`Invoice #${invoice?.invoiceNumber || invoiceId} - ${invoice?.status || 'Unknown status'}`, resourceHandler.uri, resourceHandler.mimeType, resourceHandler.getContent(), invoice);
}
/**
* Update opportunity (action tool for UI)
*/
async updateOpportunity(args) {
const { opportunityId, ...updates } = args;
// Build the update payload
const updatePayload = {};
if (updates.pipelineStageId)
updatePayload.pipelineStageId = updates.pipelineStageId;
if (updates.name)
updatePayload.name = updates.name;
if (updates.monetaryValue !== undefined)
updatePayload.monetaryValue = updates.monetaryValue;
if (updates.status)
updatePayload.status = updates.status;
process.stderr.write(`[MCP Apps] Updating opportunity ${opportunityId}: ${JSON.stringify(updatePayload)}\n`);
const response = await this.ghlClient.updateOpportunity(opportunityId, updatePayload);
if (!response.success) {
throw new Error(response.error?.message || 'Failed to update opportunity');
}
const opportunity = response.data;
return {
content: [{ type: 'text', text: `Updated opportunity: ${opportunity?.name || opportunityId}` }],
structuredContent: {
success: true,
opportunity: {
id: opportunity?.id,
name: opportunity?.name,
pipelineStageId: opportunity?.pipelineStageId,
monetaryValue: opportunity?.monetaryValue,
status: opportunity?.status
}
}
};
}
/**
* Create app tool result with structuredContent
*/
createAppResult(textSummary, resourceUri, mimeType, htmlContent, data) {
// structuredContent is the data object that gets passed to ontoolresult
// The UI accesses it via result.structuredContent
return {
content: [{ type: 'text', text: textSummary }],
structuredContent: data
};
}
/**
* Inject data into HTML as a script tag
*/
injectDataIntoHTML(html, data) {
const dataScript = `<script>window.__MCP_APP_DATA__ = ${JSON.stringify(data)};</script>`;
// Insert before </head> or at the beginning of <body>
if (html.includes('</head>')) {
return html.replace('</head>', `${dataScript}</head>`);
}
else if (html.includes('<body>')) {
return html.replace('<body>', `<body>${dataScript}`);
}
else {
return dataScript + html;
}
}
/**
* Get resource handler by URI
*/
getResourceHandler(uri) {
return this.resourceHandlers.get(uri);
}
/**
* Get all registered resource URIs
*/
getResourceURIs() {
return Array.from(this.resourceHandlers.keys());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
/**
* GoHighLevel MCP Apps Server (Slimmed Down)
* Only includes MCP Apps - no other tools
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, McpError } from '@modelcontextprotocol/sdk/types.js';
import * as dotenv from 'dotenv';
import { GHLApiClient } from './clients/ghl-api-client.js';
import { MCPAppsManager } from './apps/index.js';
// Load environment variables
dotenv.config();
/**
* MCP Apps Only Server
*/
class GHLMCPAppsServer {
server;
ghlClient;
mcpAppsManager;
constructor() {
// Initialize MCP server with capabilities
this.server = new Server({
name: 'ghl-mcp-apps-only',
version: '1.0.0',
}, {
capabilities: {
tools: {},
resources: {},
},
});
// Initialize GHL API client
this.ghlClient = this.initializeGHLClient();
// Initialize MCP Apps Manager
this.mcpAppsManager = new MCPAppsManager(this.ghlClient);
this.setupHandlers();
this.setupErrorHandling();
}
/**
* Initialize the GHL API client with config from environment
*/
initializeGHLClient() {
const config = {
accessToken: process.env.GHL_API_KEY || '',
locationId: process.env.GHL_LOCATION_ID || '',
baseUrl: process.env.GHL_BASE_URL || 'https://services.leadconnectorhq.com',
version: '2021-07-28'
};
if (!config.accessToken) {
process.stderr.write('Warning: GHL_API_KEY not set in environment\n');
}
if (!config.locationId) {
process.stderr.write('Warning: GHL_LOCATION_ID not set in environment\n');
}
return new GHLApiClient(config);
}
/**
* Setup request handlers
*/
setupHandlers() {
// List tools - only MCP App tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
const appTools = this.mcpAppsManager.getToolDefinitions();
process.stderr.write(`[MCP Apps Only] Listing ${appTools.length} app tools\n`);
return { tools: appTools };
});
// List resources - MCP App UI resources
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resourceUris = this.mcpAppsManager.getResourceURIs();
const resources = resourceUris.map(uri => {
const handler = this.mcpAppsManager.getResourceHandler(uri);
return {
uri: uri,
name: uri,
mimeType: handler?.mimeType || 'text/html;profile=mcp-app'
};
});
process.stderr.write(`[MCP Apps Only] Listing ${resources.length} UI resources\n`);
return { resources };
});
// Read resource - serve UI HTML
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
process.stderr.write(`[MCP Apps Only] Reading resource: ${uri}\n`);
const handler = this.mcpAppsManager.getResourceHandler(uri);
if (!handler) {
throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${uri}`);
}
return {
contents: [{
uri: uri,
mimeType: handler.mimeType,
text: handler.getContent()
}]
};
});
// Call tool - execute MCP App tools
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
process.stderr.write(`[MCP Apps Only] Calling tool: ${name}\n`);
if (!this.mcpAppsManager.isAppTool(name)) {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
try {
const result = await this.mcpAppsManager.executeTool(name, args || {});
return result;
}
catch (error) {
process.stderr.write(`[MCP Apps Only] Tool error: ${error.message}\n`);
throw new McpError(ErrorCode.InternalError, error.message);
}
});
}
/**
* Setup error handling
*/
setupErrorHandling() {
this.server.onerror = (error) => {
process.stderr.write(`[MCP Apps Only] Server error: ${error}\n`);
};
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
/**
* Start the server
*/
async run() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
process.stderr.write('[MCP Apps Only] Server started - Apps only mode\n');
}
}
// Start server
const server = new GHLMCPAppsServer();
server.run().catch((error) => {
process.stderr.write(`Failed to start server: ${error}\n`);
process.exit(1);
});

View File

@ -0,0 +1,5 @@
/**
* TypeScript interfaces for GoHighLevel API integration
* Based on official OpenAPI specifications v2021-07-28 (Contacts) and v2021-04-15 (Conversations)
*/
export {};

View File

@ -0,0 +1,22 @@
{
"name": "ghl-mcp-apps-only",
"version": "1.0.0",
"description": "GoHighLevel MCP Apps Only - Slimmed down for testing",
"main": "dist/server.js",
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "tsx src/server.ts"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"axios": "^1.9.0",
"dotenv": "^16.5.0"
},
"devDependencies": {
"@types/node": "^22.15.29",
"tsx": "^4.7.0",
"typescript": "^5.8.3"
}
}

View File

@ -0,0 +1,824 @@
/**
* MCP Apps Manager
* Manages rich UI components for GoHighLevel MCP Server
*/
import { Tool } from '@modelcontextprotocol/sdk/types.js';
import { GHLApiClient } from '../clients/ghl-api-client.js';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
export interface AppToolResult {
content: Array<{ type: 'text'; text: string }>;
structuredContent?: Record<string, unknown>;
[key: string]: unknown;
}
export interface AppResourceHandler {
uri: string;
mimeType: string;
getContent: () => string;
}
/**
* MCP Apps Manager class
* Registers app tools and handles structuredContent responses
*/
// ESM equivalent of __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Resolve UI build path - works regardless of working directory
function getUIBuildPath(): string {
// When compiled, this file is at dist/apps/index.js
// UI files are at dist/app-ui/
const fromDist = path.resolve(__dirname, '..', 'app-ui');
if (fs.existsSync(fromDist)) {
return fromDist;
}
// Fallback: try process.cwd() based paths
const appUiPath = path.join(process.cwd(), 'dist', 'app-ui');
if (fs.existsSync(appUiPath)) {
return appUiPath;
}
// Default fallback
return fromDist;
}
export class MCPAppsManager {
private ghlClient: GHLApiClient;
private resourceHandlers: Map<string, AppResourceHandler> = new Map();
private uiBuildPath: string;
constructor(ghlClient: GHLApiClient) {
this.ghlClient = ghlClient;
this.uiBuildPath = getUIBuildPath();
process.stderr.write(`[MCP Apps] UI build path: ${this.uiBuildPath}\n`);
this.registerResourceHandlers();
}
/**
* Register all UI resource handlers
*/
private registerResourceHandlers(): void {
const resources: Array<{ uri: string; file: string }> = [
// All 11 MCP Apps
{ uri: 'ui://ghl/mcp-app', file: 'mcp-app.html' },
{ uri: 'ui://ghl/pipeline-board', file: 'pipeline-board.html' },
{ uri: 'ui://ghl/quick-book', file: 'quick-book.html' },
{ uri: 'ui://ghl/opportunity-card', file: 'opportunity-card.html' },
{ uri: 'ui://ghl/contact-grid', file: 'contact-grid.html' },
{ uri: 'ui://ghl/calendar-view', file: 'calendar-view.html' },
{ uri: 'ui://ghl/invoice-preview', file: 'invoice-preview.html' },
{ uri: 'ui://ghl/campaign-stats', file: 'campaign-stats.html' },
{ uri: 'ui://ghl/agent-stats', file: 'agent-stats.html' },
{ uri: 'ui://ghl/contact-timeline', file: 'contact-timeline.html' },
{ uri: 'ui://ghl/workflow-status', file: 'workflow-status.html' },
];
for (const resource of resources) {
this.resourceHandlers.set(resource.uri, {
uri: resource.uri,
mimeType: 'text/html;profile=mcp-app',
getContent: () => this.loadUIResource(resource.file),
});
}
}
/**
* Load UI resource from build directory
*/
private loadUIResource(filename: string): string {
const filePath = path.join(this.uiBuildPath, filename);
try {
return fs.readFileSync(filePath, 'utf-8');
} catch (error) {
process.stderr.write(`[MCP Apps] UI resource not found: ${filePath}\n`);
return this.getFallbackHTML(filename);
}
}
/**
* Generate fallback HTML when UI resource is not built
*/
private getFallbackHTML(filename: string): string {
const componentName = filename.replace('.html', '');
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GHL ${componentName}</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 20px; }
.fallback { text-align: center; color: #666; }
</style>
</head>
<body>
<div class="fallback">
<p>UI component "${componentName}" is loading...</p>
<p>Run <code>npm run build:ui</code> to build UI components.</p>
</div>
<script>
window.addEventListener('message', (e) => {
if (e.data?.type === 'mcp-app-init') {
console.log('MCP App data:', e.data.data);
}
});
</script>
</body>
</html>
`.trim();
}
/**
* Get tool definitions for all app tools
*/
getToolDefinitions(): Tool[] {
return [
// 1. Contact Grid - search and display contacts
{
name: 'view_contact_grid',
description: 'Display contact search results in a data grid with sorting and pagination. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
query: { type: 'string', description: 'Search query string' },
limit: { type: 'number', description: 'Maximum results (default: 25)' }
}
},
_meta: {
ui: { resourceUri: 'ui://ghl/contact-grid' }
}
},
// 2. Pipeline Board - Kanban view of opportunities
{
name: 'view_pipeline_board',
description: 'Display a pipeline as an interactive Kanban board with opportunities. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
pipelineId: { type: 'string', description: 'Pipeline ID to display' }
},
required: ['pipelineId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/pipeline-board' }
}
},
// 3. Quick Book - appointment booking
{
name: 'view_quick_book',
description: 'Display a quick booking interface for scheduling appointments. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
calendarId: { type: 'string', description: 'Calendar ID for booking' },
contactId: { type: 'string', description: 'Optional contact ID to pre-fill' }
},
required: ['calendarId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/quick-book' }
}
},
// 4. Opportunity Card - single opportunity details
{
name: 'view_opportunity_card',
description: 'Display a single opportunity with details, value, and stage info. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
opportunityId: { type: 'string', description: 'Opportunity ID to display' }
},
required: ['opportunityId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/opportunity-card' }
}
},
// 5. Calendar View - calendar with events
{
name: 'view_calendar',
description: 'Display a calendar with events and appointments. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
calendarId: { type: 'string', description: 'Calendar ID to display' },
startDate: { type: 'string', description: 'Start date (ISO format)' },
endDate: { type: 'string', description: 'End date (ISO format)' }
},
required: ['calendarId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/calendar-view' }
}
},
// 6. Invoice Preview - invoice details
{
name: 'view_invoice',
description: 'Display an invoice preview with line items and payment status. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
invoiceId: { type: 'string', description: 'Invoice ID to display' }
},
required: ['invoiceId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/invoice-preview' }
}
},
// 7. Campaign Stats - campaign performance metrics
{
name: 'view_campaign_stats',
description: 'Display campaign statistics and performance metrics. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
campaignId: { type: 'string', description: 'Campaign ID to display stats for' }
},
required: ['campaignId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/campaign-stats' }
}
},
// 8. Agent Stats - agent/user performance
{
name: 'view_agent_stats',
description: 'Display agent/user performance statistics and metrics. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
userId: { type: 'string', description: 'User/Agent ID to display stats for' },
dateRange: { type: 'string', description: 'Date range (e.g., "last7days", "last30days")' }
}
},
_meta: {
ui: { resourceUri: 'ui://ghl/agent-stats' }
}
},
// 9. Contact Timeline - activity history for a contact
{
name: 'view_contact_timeline',
description: 'Display a contact\'s activity timeline with all interactions. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
contactId: { type: 'string', description: 'Contact ID to display timeline for' }
},
required: ['contactId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/contact-timeline' }
}
},
// 10. Workflow Status - workflow execution status
{
name: 'view_workflow_status',
description: 'Display workflow execution status and history. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {
workflowId: { type: 'string', description: 'Workflow ID to display status for' }
},
required: ['workflowId']
},
_meta: {
ui: { resourceUri: 'ui://ghl/workflow-status' }
}
},
// 11. MCP App - generic/main dashboard
{
name: 'view_dashboard',
description: 'Display the main GHL dashboard overview. Returns a visual UI component.',
inputSchema: {
type: 'object',
properties: {}
},
_meta: {
ui: { resourceUri: 'ui://ghl/mcp-app' }
}
},
// 12. Update Opportunity - action tool for UI to update opportunities
{
name: 'update_opportunity',
description: 'Update an opportunity (move to stage, change value, status, etc.)',
inputSchema: {
type: 'object',
properties: {
opportunityId: { type: 'string', description: 'Opportunity ID to update' },
pipelineStageId: { type: 'string', description: 'New stage ID (for moving)' },
name: { type: 'string', description: 'Opportunity name' },
monetaryValue: { type: 'number', description: 'Monetary value' },
status: { type: 'string', enum: ['open', 'won', 'lost', 'abandoned'], description: 'Opportunity status' }
},
required: ['opportunityId']
}
}
];
}
/**
* Get app tool names for routing
*/
getAppToolNames(): string[] {
return [
'view_contact_grid',
'view_pipeline_board',
'view_quick_book',
'view_opportunity_card',
'view_calendar',
'view_invoice',
'view_campaign_stats',
'view_agent_stats',
'view_contact_timeline',
'view_workflow_status',
'view_dashboard',
'update_opportunity'
];
}
/**
* Check if a tool is an app tool
*/
isAppTool(toolName: string): boolean {
return this.getAppToolNames().includes(toolName);
}
/**
* Execute an app tool
*/
async executeTool(toolName: string, args: Record<string, any>): Promise<AppToolResult> {
process.stderr.write(`[MCP Apps] Executing app tool: ${toolName}\n`);
switch (toolName) {
case 'view_contact_grid':
return await this.viewContactGrid(args.query, args.limit);
case 'view_pipeline_board':
return await this.viewPipelineBoard(args.pipelineId);
case 'view_quick_book':
return await this.viewQuickBook(args.calendarId, args.contactId);
case 'view_opportunity_card':
return await this.viewOpportunityCard(args.opportunityId);
case 'view_calendar':
return await this.viewCalendar(args.calendarId, args.startDate, args.endDate);
case 'view_invoice':
return await this.viewInvoice(args.invoiceId);
case 'view_campaign_stats':
return await this.viewCampaignStats(args.campaignId);
case 'view_agent_stats':
return await this.viewAgentStats(args.userId, args.dateRange);
case 'view_contact_timeline':
return await this.viewContactTimeline(args.contactId);
case 'view_workflow_status':
return await this.viewWorkflowStatus(args.workflowId);
case 'view_dashboard':
return await this.viewDashboard();
case 'update_opportunity':
return await this.updateOpportunity(args as {
opportunityId: string;
pipelineStageId?: string;
name?: string;
monetaryValue?: number;
status?: 'open' | 'won' | 'lost' | 'abandoned';
});
default:
throw new Error(`Unknown app tool: ${toolName}`);
}
}
/**
* View contact grid (search results)
*/
private async viewContactGrid(query?: string, limit?: number): Promise<AppToolResult> {
const response = await this.ghlClient.searchContacts({
locationId: this.ghlClient.getConfig().locationId,
query: query,
limit: limit || 25
});
if (!response.success) {
throw new Error(response.error?.message || 'Failed to search contacts');
}
const data = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/contact-grid')!;
return this.createAppResult(
`Found ${data?.contacts?.length || 0} contacts`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View pipeline board (Kanban)
*/
private async viewPipelineBoard(pipelineId: string): Promise<AppToolResult> {
const [pipelinesResponse, opportunitiesResponse] = await Promise.all([
this.ghlClient.getPipelines(),
this.ghlClient.searchOpportunities({
location_id: this.ghlClient.getConfig().locationId,
pipeline_id: pipelineId
})
]);
if (!pipelinesResponse.success) {
throw new Error(pipelinesResponse.error?.message || 'Failed to get pipeline');
}
const pipeline = pipelinesResponse.data?.pipelines?.find((p: any) => p.id === pipelineId);
const opportunities = opportunitiesResponse.data?.opportunities || [];
// Simplify opportunity data to only include fields the UI needs (reduces payload size)
const simplifiedOpportunities = opportunities.map((opp: any) => ({
id: opp.id,
name: opp.name || 'Untitled',
pipelineStageId: opp.pipelineStageId,
status: opp.status || 'open',
monetaryValue: opp.monetaryValue || 0,
contact: opp.contact ? {
name: opp.contact.name || 'Unknown',
email: opp.contact.email,
phone: opp.contact.phone
} : { name: 'Unknown' },
updatedAt: opp.updatedAt || opp.createdAt,
createdAt: opp.createdAt,
source: opp.source
}));
const data = {
pipeline,
opportunities: simplifiedOpportunities,
stages: pipeline?.stages || []
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/pipeline-board')!;
return this.createAppResult(
`Pipeline: ${pipeline?.name || 'Unknown'} (${opportunities.length} opportunities)`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View quick book interface
*/
private async viewQuickBook(calendarId: string, contactId?: string): Promise<AppToolResult> {
const [calendarResponse, contactResponse] = await Promise.all([
this.ghlClient.getCalendar(calendarId),
contactId ? this.ghlClient.getContact(contactId) : Promise.resolve({ success: true, data: null })
]);
if (!calendarResponse.success) {
throw new Error(calendarResponse.error?.message || 'Failed to get calendar');
}
const data = {
calendar: calendarResponse.data,
contact: contactResponse.data,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/quick-book')!;
return this.createAppResult(
`Quick booking for calendar: ${(calendarResponse.data as any)?.name || calendarId}`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View opportunity card
*/
private async viewOpportunityCard(opportunityId: string): Promise<AppToolResult> {
const response = await this.ghlClient.getOpportunity(opportunityId);
if (!response.success) {
throw new Error(response.error?.message || 'Failed to get opportunity');
}
const opportunity = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/opportunity-card')!;
return this.createAppResult(
`Opportunity: ${(opportunity as any)?.name || opportunityId}`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
opportunity
);
}
/**
* View calendar
*/
private async viewCalendar(calendarId: string, startDate?: string, endDate?: string): Promise<AppToolResult> {
const now = new Date();
const start = startDate || new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const end = endDate || new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString();
const [calendarResponse, eventsResponse] = await Promise.all([
this.ghlClient.getCalendar(calendarId),
this.ghlClient.getCalendarEvents({
calendarId: calendarId,
startTime: start,
endTime: end,
locationId: this.ghlClient.getConfig().locationId
})
]);
if (!calendarResponse.success) {
throw new Error(calendarResponse.error?.message || 'Failed to get calendar');
}
const calendar = calendarResponse.data as any;
const data = {
calendar: calendarResponse.data,
events: eventsResponse.data?.events || [],
startDate: start,
endDate: end
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/calendar-view')!;
return this.createAppResult(
`Calendar: ${calendar?.name || 'Unknown'} (${data.events.length} events)`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View campaign stats
*/
private async viewCampaignStats(campaignId: string): Promise<AppToolResult> {
// Get email campaigns
const response = await this.ghlClient.getEmailCampaigns({});
const campaigns = response.data?.schedules || [];
const campaign = campaigns.find((c: any) => c.id === campaignId) || { id: campaignId };
const data = {
campaign,
campaigns,
campaignId,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/campaign-stats')!;
return this.createAppResult(
`Campaign stats: ${(campaign as any)?.name || campaignId}`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View agent stats
*/
private async viewAgentStats(userId?: string, dateRange?: string): Promise<AppToolResult> {
// Get location info which may include user data
const locationResponse = await this.ghlClient.getLocationById(this.ghlClient.getConfig().locationId);
const data = {
userId,
dateRange: dateRange || 'last30days',
location: locationResponse.data,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/agent-stats')!;
return this.createAppResult(
userId ? `Agent stats: ${userId}` : 'Agent overview',
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View contact timeline
*/
private async viewContactTimeline(contactId: string): Promise<AppToolResult> {
const [contactResponse, notesResponse, tasksResponse] = await Promise.all([
this.ghlClient.getContact(contactId),
this.ghlClient.getContactNotes(contactId),
this.ghlClient.getContactTasks(contactId)
]);
if (!contactResponse.success) {
throw new Error(contactResponse.error?.message || 'Failed to get contact');
}
const contact = contactResponse.data as any;
const data = {
contact: contactResponse.data,
notes: notesResponse.data || [],
tasks: tasksResponse.data || []
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/contact-timeline')!;
return this.createAppResult(
`Timeline for ${contact?.firstName || ''} ${contact?.lastName || ''}`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View workflow status
*/
private async viewWorkflowStatus(workflowId: string): Promise<AppToolResult> {
const response = await this.ghlClient.getWorkflows({
locationId: this.ghlClient.getConfig().locationId
});
const workflows = response.data?.workflows || [];
const workflow = workflows.find((w: any) => w.id === workflowId) || { id: workflowId };
const data = {
workflow,
workflows,
workflowId,
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/workflow-status')!;
return this.createAppResult(
`Workflow: ${(workflow as any)?.name || workflowId}`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View main dashboard
*/
private async viewDashboard(): Promise<AppToolResult> {
const [contactsResponse, pipelinesResponse, calendarsResponse] = await Promise.all([
this.ghlClient.searchContacts({ locationId: this.ghlClient.getConfig().locationId, limit: 10 }),
this.ghlClient.getPipelines(),
this.ghlClient.getCalendars()
]);
const data = {
recentContacts: contactsResponse.data?.contacts || [],
pipelines: pipelinesResponse.data?.pipelines || [],
calendars: calendarsResponse.data?.calendars || [],
locationId: this.ghlClient.getConfig().locationId
};
const resourceHandler = this.resourceHandlers.get('ui://ghl/mcp-app')!;
return this.createAppResult(
'GHL Dashboard Overview',
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
data
);
}
/**
* View invoice
*/
private async viewInvoice(invoiceId: string): Promise<AppToolResult> {
const response = await this.ghlClient.getInvoice(invoiceId, {
altId: this.ghlClient.getConfig().locationId,
altType: 'location'
});
if (!response.success) {
throw new Error(response.error?.message || 'Failed to get invoice');
}
const invoice = response.data;
const resourceHandler = this.resourceHandlers.get('ui://ghl/invoice-preview')!;
return this.createAppResult(
`Invoice #${invoice?.invoiceNumber || invoiceId} - ${invoice?.status || 'Unknown status'}`,
resourceHandler.uri,
resourceHandler.mimeType,
resourceHandler.getContent(),
invoice
);
}
/**
* Update opportunity (action tool for UI)
*/
private async updateOpportunity(args: {
opportunityId: string;
pipelineStageId?: string;
name?: string;
monetaryValue?: number;
status?: 'open' | 'won' | 'lost' | 'abandoned';
}): Promise<AppToolResult> {
const { opportunityId, ...updates } = args;
// Build the update payload
const updatePayload: any = {};
if (updates.pipelineStageId) updatePayload.pipelineStageId = updates.pipelineStageId;
if (updates.name) updatePayload.name = updates.name;
if (updates.monetaryValue !== undefined) updatePayload.monetaryValue = updates.monetaryValue;
if (updates.status) updatePayload.status = updates.status;
process.stderr.write(`[MCP Apps] Updating opportunity ${opportunityId}: ${JSON.stringify(updatePayload)}\n`);
const response = await this.ghlClient.updateOpportunity(opportunityId, updatePayload);
if (!response.success) {
throw new Error(response.error?.message || 'Failed to update opportunity');
}
const opportunity = response.data;
return {
content: [{ type: 'text', text: `Updated opportunity: ${opportunity?.name || opportunityId}` }],
structuredContent: {
success: true,
opportunity: {
id: opportunity?.id,
name: opportunity?.name,
pipelineStageId: opportunity?.pipelineStageId,
monetaryValue: opportunity?.monetaryValue,
status: opportunity?.status
}
}
};
}
/**
* Create app tool result with structuredContent
*/
private createAppResult(
textSummary: string,
resourceUri: string,
mimeType: string,
htmlContent: string,
data: any
): AppToolResult {
// structuredContent is the data object that gets passed to ontoolresult
// The UI accesses it via result.structuredContent
return {
content: [{ type: 'text', text: textSummary }],
structuredContent: data
};
}
/**
* Inject data into HTML as a script tag
*/
private injectDataIntoHTML(html: string, data: any): string {
const dataScript = `<script>window.__MCP_APP_DATA__ = ${JSON.stringify(data)};</script>`;
// Insert before </head> or at the beginning of <body>
if (html.includes('</head>')) {
return html.replace('</head>', `${dataScript}</head>`);
} else if (html.includes('<body>')) {
return html.replace('<body>', `<body>${dataScript}`);
} else {
return dataScript + html;
}
}
/**
* Get resource handler by URI
*/
getResourceHandler(uri: string): AppResourceHandler | undefined {
return this.resourceHandlers.get(uri);
}
/**
* Get all registered resource URIs
*/
getResourceURIs(): string[] {
return Array.from(this.resourceHandlers.keys());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,172 @@
/**
* GoHighLevel MCP Apps Server (Slimmed Down)
* Only includes MCP Apps - no other tools
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ErrorCode,
ListToolsRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
McpError
} from '@modelcontextprotocol/sdk/types.js';
import * as dotenv from 'dotenv';
import { GHLApiClient } from './clients/ghl-api-client.js';
import { MCPAppsManager } from './apps/index.js';
import { GHLConfig } from './types/ghl-types.js';
// Load environment variables
dotenv.config();
/**
* MCP Apps Only Server
*/
class GHLMCPAppsServer {
private server: Server;
private ghlClient: GHLApiClient;
private mcpAppsManager: MCPAppsManager;
constructor() {
// Initialize MCP server with capabilities
this.server = new Server(
{
name: 'ghl-mcp-apps-only',
version: '1.0.0',
},
{
capabilities: {
tools: {},
resources: {},
},
}
);
// Initialize GHL API client
this.ghlClient = this.initializeGHLClient();
// Initialize MCP Apps Manager
this.mcpAppsManager = new MCPAppsManager(this.ghlClient);
this.setupHandlers();
this.setupErrorHandling();
}
/**
* Initialize the GHL API client with config from environment
*/
private initializeGHLClient(): GHLApiClient {
const config: GHLConfig = {
accessToken: process.env.GHL_API_KEY || '',
locationId: process.env.GHL_LOCATION_ID || '',
baseUrl: process.env.GHL_BASE_URL || 'https://services.leadconnectorhq.com',
version: '2021-07-28'
};
if (!config.accessToken) {
process.stderr.write('Warning: GHL_API_KEY not set in environment\n');
}
if (!config.locationId) {
process.stderr.write('Warning: GHL_LOCATION_ID not set in environment\n');
}
return new GHLApiClient(config);
}
/**
* Setup request handlers
*/
private setupHandlers(): void {
// List tools - only MCP App tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
const appTools = this.mcpAppsManager.getToolDefinitions();
process.stderr.write(`[MCP Apps Only] Listing ${appTools.length} app tools\n`);
return { tools: appTools };
});
// List resources - MCP App UI resources
this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
const resourceUris = this.mcpAppsManager.getResourceURIs();
const resources = resourceUris.map(uri => {
const handler = this.mcpAppsManager.getResourceHandler(uri);
return {
uri: uri,
name: uri,
mimeType: handler?.mimeType || 'text/html;profile=mcp-app'
};
});
process.stderr.write(`[MCP Apps Only] Listing ${resources.length} UI resources\n`);
return { resources };
});
// Read resource - serve UI HTML
this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
const uri = request.params.uri;
process.stderr.write(`[MCP Apps Only] Reading resource: ${uri}\n`);
const handler = this.mcpAppsManager.getResourceHandler(uri);
if (!handler) {
throw new McpError(ErrorCode.InvalidRequest, `Resource not found: ${uri}`);
}
return {
contents: [{
uri: uri,
mimeType: handler.mimeType,
text: handler.getContent()
}]
};
});
// Call tool - execute MCP App tools
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
process.stderr.write(`[MCP Apps Only] Calling tool: ${name}\n`);
if (!this.mcpAppsManager.isAppTool(name)) {
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${name}`);
}
try {
const result = await this.mcpAppsManager.executeTool(name, args || {});
return result;
} catch (error: any) {
process.stderr.write(`[MCP Apps Only] Tool error: ${error.message}\n`);
throw new McpError(ErrorCode.InternalError, error.message);
}
});
}
/**
* Setup error handling
*/
private setupErrorHandling(): void {
this.server.onerror = (error) => {
process.stderr.write(`[MCP Apps Only] Server error: ${error}\n`);
};
process.on('SIGINT', async () => {
await this.server.close();
process.exit(0);
});
}
/**
* Start the server
*/
async run(): Promise<void> {
const transport = new StdioServerTransport();
await this.server.connect(transport);
process.stderr.write('[MCP Apps Only] Server started - Apps only mode\n');
}
}
// Start server
const server = new GHLMCPAppsServer();
server.run().catch((error) => {
process.stderr.write(`Failed to start server: ${error}\n`);
process.exit(1);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{j as e,A as h,g as n,f as d,R as m,a as g}from"./styles-CphAgR3l.js";function o({contact:s}){const a=s.name||`${s.firstName||""} ${s.lastName||""}`.trim()||"Unknown Contact",i=n(s.firstName,s.lastName),r=()=>{const l=[s.address1,s.city,s.state,s.postalCode,s.country].filter(Boolean);return l.length>0?l.join(", "):null};return e.jsx("div",{className:"ghl-app",children:e.jsxs("div",{className:"ghl-card",children:[e.jsx("div",{className:"ghl-card-header",children:e.jsxs("div",{className:"ghl-flex ghl-items-center ghl-gap-4",children:[e.jsx("div",{className:"ghl-avatar ghl-avatar-lg",style:{background:x(a)},children:i}),e.jsxs("div",{children:[e.jsx("h2",{style:{fontSize:20,fontWeight:600,marginBottom:4},children:a}),s.companyName&&e.jsx("p",{className:"ghl-text-secondary",children:s.companyName})]})]})}),e.jsxs("div",{className:"ghl-card-body",children:[e.jsxs("div",{className:"ghl-grid ghl-grid-2",style:{gap:16},children:[s.email&&e.jsxs("div",{children:[e.jsx("label",{className:"ghl-text-sm ghl-text-muted",style:{display:"block",marginBottom:4},children:"Email"}),e.jsx("a",{href:`mailto:${s.email}`,style:{color:"var(--ghl-primary)",textDecoration:"none"},children:s.email})]}),s.phone&&e.jsxs("div",{children:[e.jsx("label",{className:"ghl-text-sm ghl-text-muted",style:{display:"block",marginBottom:4},children:"Phone"}),e.jsx("a",{href:`tel:${s.phone}`,style:{color:"var(--ghl-primary)",textDecoration:"none"},children:s.phone})]}),s.website&&e.jsxs("div",{children:[e.jsx("label",{className:"ghl-text-sm ghl-text-muted",style:{display:"block",marginBottom:4},children:"Website"}),e.jsx("a",{href:s.website,target:"_blank",rel:"noopener noreferrer",style:{color:"var(--ghl-primary)",textDecoration:"none"},children:s.website})]}),s.source&&e.jsxs("div",{children:[e.jsx("label",{className:"ghl-text-sm ghl-text-muted",style:{display:"block",marginBottom:4},children:"Source"}),e.jsx("span",{children:s.source})]}),r()&&e.jsxs("div",{style:{gridColumn:"span 2"},children:[e.jsx("label",{className:"ghl-text-sm ghl-text-muted",style:{display:"block",marginBottom:4},children:"Address"}),e.jsx("span",{children:r()})]})]}),s.tags&&s.tags.length>0&&e.jsxs("div",{style:{marginTop:16},children:[e.jsx("label",{className:"ghl-text-sm ghl-text-muted",style:{display:"block",marginBottom:8},children:"Tags"}),e.jsx("div",{className:"ghl-flex ghl-gap-2",style:{flexWrap:"wrap"},children:s.tags.map((l,t)=>e.jsx("span",{className:"ghl-badge ghl-badge-primary",children:l},t))})]}),s.customFields&&s.customFields.length>0&&e.jsxs("div",{style:{marginTop:16},children:[e.jsx("label",{className:"ghl-text-sm ghl-text-muted",style:{display:"block",marginBottom:8},children:"Custom Fields"}),e.jsx("div",{className:"ghl-grid ghl-grid-2",style:{gap:12},children:s.customFields.slice(0,6).map(l=>e.jsxs("div",{children:[e.jsxs("span",{className:"ghl-text-sm ghl-text-muted",children:[l.key,": "]}),e.jsx("span",{children:String(l.value)})]},l.id))})]})]}),e.jsx("div",{className:"ghl-card-footer",children:e.jsxs("div",{className:"ghl-flex ghl-justify-between ghl-text-sm ghl-text-muted",children:[e.jsxs("span",{children:["Added: ",d(s.dateAdded)]}),e.jsxs("span",{children:["Updated: ",d(s.dateUpdated)]})]})})]})})}function x(s){let a=0;for(let r=0;r<s.length;r++)a=s.charCodeAt(r)+((a<<5)-a);const i=["#4f46e5","#7c3aed","#2563eb","#0891b2","#059669","#d97706","#dc2626","#db2777","#9333ea","#0d9488"];return i[Math.abs(a)%i.length]}function c(){return e.jsx(h,{children:s=>e.jsx(o,{contact:s})})}m.createRoot(document.getElementById("root")).render(g.createElement(c));

View File

@ -0,0 +1 @@
import{j as e,A as k,r as g,g as w,f as A,R as $,a as z}from"./styles-CphAgR3l.js";function D({data:a}){const[l,o]=g.useState("name"),[n,y]=g.useState("asc"),[c,b]=g.useState(1),[d,x]=g.useState(new Set),j=10,p=a.contacts||[],v=[...p].sort((s,t)=>{let i,r;switch(l){case"name":i=s.name||`${s.firstName||""} ${s.lastName||""}`.trim(),r=t.name||`${t.firstName||""} ${t.lastName||""}`.trim();break;case"email":i=s.email||"",r=t.email||"";break;case"dateAdded":i=s.dateAdded||"",r=t.dateAdded||"";break;default:return 0}const m=i.localeCompare(r);return n==="asc"?m:-m}),u=Math.ceil(v.length/j),h=v.slice((c-1)*j,c*j),N=s=>{l===s?y(n==="asc"?"desc":"asc"):(o(s),y("asc"))},C=s=>{const t=new Set(d);t.has(s)?t.delete(s):t.add(s),x(t)},S=()=>{d.size===h.length?x(new Set):x(new Set(h.map(s=>s.id)))},f=({field:s})=>e.jsx("span",{style:{marginLeft:4,opacity:l===s?1:.3},children:l===s&&n==="desc"?"↓":"↑"});return p.length===0?e.jsx("div",{className:"ghl-app",children:e.jsxs("div",{className:"ghl-empty",children:[e.jsx("div",{className:"ghl-empty-icon",children:"👥"}),e.jsx("p",{children:"No contacts found"})]})}):e.jsx("div",{className:"ghl-app",children:e.jsxs("div",{className:"ghl-card",children:[e.jsx("div",{className:"ghl-card-header",children:e.jsxs("div",{className:"ghl-flex ghl-justify-between ghl-items-center",children:[e.jsxs("div",{children:[e.jsx("h2",{style:{fontSize:18,fontWeight:600},children:"Contacts"}),e.jsxs("p",{className:"ghl-text-sm ghl-text-muted",children:[a.total||p.length," total contacts"]})]}),d.size>0&&e.jsx("div",{className:"ghl-flex ghl-gap-2",children:e.jsxs("span",{className:"ghl-text-sm ghl-text-secondary",children:[d.size," selected"]})})]})}),e.jsx("div",{style:{overflowX:"auto"},children:e.jsxs("table",{className:"ghl-table",children:[e.jsx("thead",{children:e.jsxs("tr",{children:[e.jsx("th",{style:{width:40},children:e.jsx("input",{type:"checkbox",checked:d.size===h.length&&h.length>0,onChange:S})}),e.jsxs("th",{onClick:()=>N("name"),style:{cursor:"pointer"},children:["Contact ",e.jsx(f,{field:"name"})]}),e.jsxs("th",{onClick:()=>N("email"),style:{cursor:"pointer"},children:["Email ",e.jsx(f,{field:"email"})]}),e.jsx("th",{children:"Phone"}),e.jsx("th",{children:"Tags"}),e.jsxs("th",{onClick:()=>N("dateAdded"),style:{cursor:"pointer"},children:["Added ",e.jsx(f,{field:"dateAdded"})]})]})}),e.jsx("tbody",{children:h.map(s=>{const t=s.name||`${s.firstName||""} ${s.lastName||""}`.trim()||"Unknown",i=w(s.firstName,s.lastName);return e.jsxs("tr",{children:[e.jsx("td",{children:e.jsx("input",{type:"checkbox",checked:d.has(s.id),onChange:()=>C(s.id)})}),e.jsx("td",{children:e.jsxs("div",{className:"ghl-flex ghl-items-center ghl-gap-2",children:[e.jsx("div",{className:"ghl-avatar ghl-avatar-sm",style:{background:P(t)},children:i}),e.jsxs("div",{children:[e.jsx("div",{className:"ghl-font-medium",children:t}),s.companyName&&e.jsx("div",{className:"ghl-text-sm ghl-text-muted",children:s.companyName})]})]})}),e.jsx("td",{children:s.email?e.jsx("a",{href:`mailto:${s.email}`,style:{color:"var(--ghl-primary)",textDecoration:"none"},children:s.email}):e.jsx("span",{className:"ghl-text-muted",children:"-"})}),e.jsx("td",{children:s.phone?e.jsx("a",{href:`tel:${s.phone}`,style:{color:"var(--ghl-primary)",textDecoration:"none"},children:s.phone}):e.jsx("span",{className:"ghl-text-muted",children:"-"})}),e.jsx("td",{children:e.jsxs("div",{className:"ghl-flex ghl-gap-2",style:{flexWrap:"wrap",maxWidth:200},children:[(s.tags||[]).slice(0,3).map((r,m)=>e.jsx("span",{className:"ghl-badge ghl-badge-primary",children:r},m)),(s.tags||[]).length>3&&e.jsxs("span",{className:"ghl-badge",children:["+",s.tags.length-3]})]})}),e.jsx("td",{className:"ghl-text-muted",children:A(s.dateAdded)})]},s.id)})})]})}),u>1&&e.jsx("div",{className:"ghl-card-footer",children:e.jsxs("div",{className:"ghl-flex ghl-justify-between ghl-items-center",children:[e.jsxs("span",{className:"ghl-text-sm ghl-text-muted",children:["Page ",c," of ",u]}),e.jsxs("div",{className:"ghl-flex ghl-gap-2",children:[e.jsx("button",{className:"ghl-btn ghl-btn-secondary ghl-btn-sm",disabled:c===1,onClick:()=>b(s=>s-1),children:"Previous"}),e.jsx("button",{className:"ghl-btn ghl-btn-secondary ghl-btn-sm",disabled:c===u,onClick:()=>b(s=>s+1),children:"Next"})]})]})})]})})}function P(a){let l=0;for(let n=0;n<a.length;n++)l=a.charCodeAt(n)+((l<<5)-l);const o=["#4f46e5","#7c3aed","#2563eb","#0891b2","#059669","#d97706","#dc2626","#db2777","#9333ea","#0d9488"];return o[Math.abs(l)%o.length]}function E(){return e.jsx(k,{children:a=>e.jsx(D,{data:a})})}$.createRoot(document.getElementById("root")).render(z.createElement(E));

View File

@ -0,0 +1 @@
import{j as e,A as x,r as h,R as g,a as u}from"./styles-CphAgR3l.js";function m({data:t}){const{conversation:n,messages:c}=t,l=h.useRef(null),d=n.contactName||n.contact?.name||`${n.contact?.firstName||""} ${n.contact?.lastName||""}`.trim()||"Unknown Contact";h.useEffect(()=>{l.current?.scrollIntoView({behavior:"smooth"})},[]);const r=[...c||[]].sort((a,s)=>{const o=a.dateAdded?new Date(a.dateAdded).getTime():0,p=s.dateAdded?new Date(s.dateAdded).getTime():0;return o-p}),i=new Map;for(const a of r){const s=a.dateAdded?new Date(a.dateAdded).toLocaleDateString("en-US",{month:"short",day:"numeric",year:"numeric"}):"Unknown Date";i.has(s)||i.set(s,[]),i.get(s).push(a)}return e.jsx("div",{className:"ghl-app",style:{height:"100%",display:"flex",flexDirection:"column"},children:e.jsxs("div",{className:"ghl-card",style:{flex:1,display:"flex",flexDirection:"column",maxHeight:600},children:[e.jsx("div",{className:"ghl-card-header",children:e.jsxs("div",{className:"ghl-flex ghl-items-center ghl-gap-4",children:[e.jsx("div",{style:{width:44,height:44,borderRadius:"50%",background:"var(--ghl-primary)",display:"flex",alignItems:"center",justifyContent:"center",color:"white",fontWeight:600},children:d.charAt(0).toUpperCase()}),e.jsxs("div",{children:[e.jsx("h2",{style:{fontSize:16,fontWeight:600,marginBottom:2},children:d}),e.jsxs("div",{className:"ghl-flex ghl-items-center ghl-gap-2",children:[n.contact?.phone&&e.jsx("span",{className:"ghl-text-sm ghl-text-muted",children:n.contact.phone}),n.type&&e.jsx("span",{className:"ghl-badge",children:n.type})]})]})]})}),e.jsx("div",{style:{flex:1,overflowY:"auto",padding:16,background:"var(--ghl-bg-secondary)"},children:r.length===0?e.jsxs("div",{className:"ghl-empty",children:[e.jsx("div",{className:"ghl-empty-icon",children:"💬"}),e.jsx("p",{children:"No messages in this conversation"})]}):e.jsxs(e.Fragment,{children:[Array.from(i.entries()).map(([a,s])=>e.jsxs("div",{children:[e.jsxs("div",{style:{textAlign:"center",margin:"16px 0",position:"relative"},children:[e.jsx("span",{style:{background:"var(--ghl-bg-secondary)",padding:"4px 12px",fontSize:12,color:"var(--ghl-text-muted)",position:"relative",zIndex:1},children:a}),e.jsx("div",{style:{position:"absolute",top:"50%",left:0,right:0,height:1,background:"var(--ghl-border)",zIndex:0}})]}),s.map(o=>e.jsx(f,{message:o},o.id))]},a)),e.jsx("div",{ref:l})]})}),e.jsx("div",{className:"ghl-card-footer",children:e.jsxs("div",{className:"ghl-flex ghl-items-center ghl-gap-2",children:[e.jsxs("div",{style:{flex:1,padding:"10px 14px",background:"var(--ghl-bg)",border:"1px solid var(--ghl-border)",borderRadius:20,fontSize:14,color:"var(--ghl-text-muted)"},children:["Reply to ",d,"..."]}),e.jsx("button",{className:"ghl-btn ghl-btn-primary",style:{borderRadius:20,padding:"10px 20px"},children:"Send"})]})})]})})}function f({message:t}){const n=t.direction==="outbound",c=r=>r?new Date(r).toLocaleTimeString("en-US",{hour:"numeric",minute:"2-digit"}):"",l=r=>{switch(r){case"delivered":return"✓✓";case"sent":return"✓";case"read":return"✓✓";case"failed":return"✕";default:return""}},d=r=>{switch(r){case"SMS":return"📱";case"Email":return"📧";case"WhatsApp":return"💬";case"FB":return"👤";case"GMB":return"🏢";case"Call":return"📞";default:return""}};return e.jsx("div",{style:{display:"flex",justifyContent:n?"flex-end":"flex-start",marginBottom:8},children:e.jsxs("div",{style:{maxWidth:"75%",background:n?"var(--ghl-primary)":"var(--ghl-bg)",color:n?"white":"var(--ghl-text)",padding:"10px 14px",borderRadius:n?"18px 18px 4px 18px":"18px 18px 18px 4px",boxShadow:"var(--ghl-shadow)"},children:[t.type&&e.jsxs("div",{style:{fontSize:10,marginBottom:4,opacity:.7},children:[d(t.type)," ",t.type]}),e.jsx("div",{style:{wordBreak:"break-word",whiteSpace:"pre-wrap"},children:t.body||e.jsx("span",{style:{opacity:.6,fontStyle:"italic"},children:"[No content]"})}),t.attachments&&t.attachments.length>0&&e.jsx("div",{style:{marginTop:8},children:t.attachments.map((r,i)=>e.jsx("div",{style:{padding:"6px 10px",background:n?"rgba(255,255,255,0.1)":"var(--ghl-bg-secondary)",borderRadius:8,fontSize:12,marginTop:4},children:"📎 Attachment"},i))}),e.jsxs("div",{style:{display:"flex",alignItems:"center",justifyContent:"flex-end",gap:6,marginTop:6,fontSize:11,opacity:.7},children:[e.jsx("span",{children:c(t.dateAdded)}),n&&t.status&&e.jsx("span",{style:{color:t.status==="failed"?"#ef4444":"inherit"},children:l(t.status)})]})]})})}function y(){return e.jsx(x,{children:t=>e.jsx(m,{data:t})})}g.createRoot(document.getElementById("root")).render(u.createElement(y));

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
import{j as e,A as u,b as g,f as p,R as v,a as j}from"./styles-CphAgR3l.js";function f({data:s}){const{pipeline:n,opportunities:l,stages:h}=s,c=[...h||[]].sort((t,a)=>(t.position||0)-(a.position||0)),r=new Map;for(const t of c)r.set(t.id,[]);for(const t of l||[]){const a=r.get(t.pipelineStageId||"");a&&a.push(t)}const m=t=>{const a=r.get(t)||[],i=a.length,d=a.reduce((o,x)=>o+(x.monetaryValue||0),0);return{count:i,value:d}};return n?e.jsxs("div",{className:"ghl-app",children:[e.jsxs("div",{style:{marginBottom:16},children:[e.jsx("h2",{style:{fontSize:20,fontWeight:600,marginBottom:4},children:n.name}),e.jsxs("p",{className:"ghl-text-sm ghl-text-muted",children:[l?.length||0," opportunities | ",c.length," stages"]})]}),e.jsx("div",{style:{display:"flex",gap:16,overflowX:"auto",paddingBottom:16},children:c.map(t=>{const{count:a,value:i}=m(t.id),d=r.get(t.id)||[];return e.jsxs("div",{style:{flex:"0 0 280px",display:"flex",flexDirection:"column",maxHeight:500},children:[e.jsxs("div",{style:{padding:"12px 16px",background:"var(--ghl-bg-secondary)",borderRadius:"var(--ghl-radius) var(--ghl-radius) 0 0",border:"1px solid var(--ghl-border)",borderBottom:"none"},children:[e.jsxs("div",{className:"ghl-flex ghl-justify-between ghl-items-center",children:[e.jsx("span",{className:"ghl-font-semibold",children:t.name}),e.jsx("span",{className:"ghl-badge",children:a})]}),i>0&&e.jsx("div",{className:"ghl-text-sm ghl-text-muted",style:{marginTop:4},children:g(i)})]}),e.jsx("div",{style:{flex:1,padding:8,background:"var(--ghl-bg-tertiary)",borderRadius:"0 0 var(--ghl-radius) var(--ghl-radius)",border:"1px solid var(--ghl-border)",borderTop:"none",overflowY:"auto",minHeight:200},children:d.length===0?e.jsx("div",{style:{padding:20,textAlign:"center",color:"var(--ghl-text-muted)",fontSize:13},children:"No opportunities"}):e.jsx("div",{style:{display:"flex",flexDirection:"column",gap:8},children:d.map(o=>e.jsx(b,{opportunity:o},o.id))})})]},t.id)})}),e.jsx("div",{className:"ghl-card",style:{marginTop:16},children:e.jsx("div",{className:"ghl-card-body",children:e.jsxs("div",{className:"ghl-flex ghl-justify-between",children:[e.jsxs("div",{children:[e.jsx("span",{className:"ghl-text-muted",children:"Total Opportunities"}),e.jsx("div",{className:"ghl-font-semibold",style:{fontSize:20},children:l?.length||0})]}),e.jsxs("div",{style:{textAlign:"right"},children:[e.jsx("span",{className:"ghl-text-muted",children:"Total Pipeline Value"}),e.jsx("div",{className:"ghl-font-semibold",style:{fontSize:20,color:"var(--ghl-success)"},children:g(l?.reduce((t,a)=>t+(a.monetaryValue||0),0)||0)})]})]})})})]}):e.jsx("div",{className:"ghl-app",children:e.jsxs("div",{className:"ghl-empty",children:[e.jsx("div",{className:"ghl-empty-icon",children:"📊"}),e.jsx("p",{children:"Pipeline not found"})]})})}function b({opportunity:s}){const n=s.contact?.name||`${s.contact?.firstName||""} ${s.contact?.lastName||""}`.trim()||"Unknown Contact",l={open:"var(--ghl-primary)",won:"var(--ghl-success)",lost:"var(--ghl-danger)",abandoned:"var(--ghl-warning)"};return e.jsxs("div",{style:{background:"var(--ghl-bg)",borderRadius:"var(--ghl-radius)",padding:12,boxShadow:"var(--ghl-shadow)",border:"1px solid var(--ghl-border)"},children:[e.jsx("div",{className:"ghl-font-medium",style:{marginBottom:8},children:s.name}),e.jsxs("div",{className:"ghl-flex ghl-items-center ghl-gap-2",style:{marginBottom:8},children:[e.jsx("span",{style:{width:24,height:24,borderRadius:"50%",background:"var(--ghl-bg-tertiary)",display:"flex",alignItems:"center",justifyContent:"center",fontSize:10},children:"👤"}),e.jsx("span",{className:"ghl-text-sm",children:n})]}),e.jsxs("div",{className:"ghl-flex ghl-justify-between ghl-items-center",children:[s.monetaryValue?e.jsx("span",{className:"ghl-font-semibold",style:{color:"var(--ghl-success)"},children:g(s.monetaryValue)}):e.jsx("span",{className:"ghl-text-muted ghl-text-sm",children:"No value"}),s.status&&e.jsx("span",{className:"ghl-badge",style:{background:`${l[s.status]||"var(--ghl-bg-tertiary)"}20`,color:l[s.status]||"var(--ghl-text-muted)"},children:s.status})]}),s.dateAdded&&e.jsxs("div",{className:"ghl-text-sm ghl-text-muted",style:{marginTop:8},children:["Added ",p(s.dateAdded)]})]})}function y(){return e.jsx(u,{children:s=>e.jsx(f,{data:s})})}v.createRoot(document.getElementById("root")).render(j.createElement(y));

View File

@ -0,0 +1 @@
:root{--ghl-primary: #4f46e5;--ghl-primary-hover: #4338ca;--ghl-success: #22c55e;--ghl-warning: #f59e0b;--ghl-danger: #ef4444;--ghl-info: #3b82f6;--ghl-bg: #ffffff;--ghl-bg-secondary: #f9fafb;--ghl-bg-tertiary: #f3f4f6;--ghl-text: #111827;--ghl-text-secondary: #6b7280;--ghl-text-muted: #9ca3af;--ghl-border: #e5e7eb;--ghl-border-dark: #d1d5db;--ghl-shadow: 0 1px 3px rgba(0, 0, 0, .1);--ghl-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1);--ghl-radius: 8px;--ghl-radius-lg: 12px}*{box-sizing:border-box;margin:0;padding:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:14px;line-height:1.5;color:var(--ghl-text);background:var(--ghl-bg)}.ghl-app{padding:16px;max-width:100%;overflow-x:hidden}.ghl-card{background:var(--ghl-bg);border:1px solid var(--ghl-border);border-radius:var(--ghl-radius-lg);box-shadow:var(--ghl-shadow);overflow:hidden}.ghl-card-header{padding:16px;border-bottom:1px solid var(--ghl-border);background:var(--ghl-bg-secondary)}.ghl-card-body{padding:16px}.ghl-card-footer{padding:12px 16px;border-top:1px solid var(--ghl-border);background:var(--ghl-bg-secondary)}.ghl-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:8px 16px;font-size:14px;font-weight:500;border-radius:var(--ghl-radius);border:1px solid transparent;cursor:pointer;transition:all .15s ease}.ghl-btn-primary{background:var(--ghl-primary);color:#fff}.ghl-btn-primary:hover{background:var(--ghl-primary-hover)}.ghl-btn-secondary{background:var(--ghl-bg);color:var(--ghl-text);border-color:var(--ghl-border)}.ghl-btn-secondary:hover{background:var(--ghl-bg-secondary)}.ghl-btn-sm{padding:4px 10px;font-size:12px}.ghl-badge{display:inline-flex;align-items:center;padding:2px 8px;font-size:12px;font-weight:500;border-radius:9999px;background:var(--ghl-bg-tertiary);color:var(--ghl-text-secondary)}.ghl-badge-primary{background:#4f46e51a;color:var(--ghl-primary)}.ghl-badge-success{background:#22c55e1a;color:var(--ghl-success)}.ghl-badge-warning{background:#f59e0b1a;color:var(--ghl-warning)}.ghl-badge-danger{background:#ef44441a;color:var(--ghl-danger)}.ghl-avatar{display:inline-flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%;background:var(--ghl-primary);color:#fff;font-weight:600;font-size:16px}.ghl-avatar-lg{width:64px;height:64px;font-size:24px}.ghl-avatar-sm{width:32px;height:32px;font-size:12px}.ghl-table{width:100%;border-collapse:collapse}.ghl-table th,.ghl-table td{padding:12px;text-align:left;border-bottom:1px solid var(--ghl-border)}.ghl-table th{font-weight:600;background:var(--ghl-bg-secondary);color:var(--ghl-text-secondary);font-size:12px;text-transform:uppercase;letter-spacing:.05em}.ghl-table tr:hover{background:var(--ghl-bg-secondary)}.ghl-grid{display:grid;gap:16px}.ghl-grid-2{grid-template-columns:repeat(2,1fr)}.ghl-grid-3{grid-template-columns:repeat(3,1fr)}.ghl-grid-4{grid-template-columns:repeat(4,1fr)}.ghl-flex{display:flex}.ghl-flex-col{flex-direction:column}.ghl-items-center{align-items:center}.ghl-justify-between{justify-content:space-between}.ghl-gap-2{gap:8px}.ghl-gap-4{gap:16px}.ghl-text-sm{font-size:12px}.ghl-text-lg{font-size:18px}.ghl-text-muted{color:var(--ghl-text-muted)}.ghl-text-secondary{color:var(--ghl-text-secondary)}.ghl-font-medium{font-weight:500}.ghl-font-semibold{font-weight:600}.ghl-status-paid{color:var(--ghl-success)}.ghl-status-pending{color:var(--ghl-warning)}.ghl-status-overdue{color:var(--ghl-danger)}.ghl-status-draft{color:var(--ghl-text-muted)}.ghl-loading{display:flex;align-items:center;justify-content:center;padding:40px;color:var(--ghl-text-muted)}.ghl-spinner{width:24px;height:24px;border:2px solid var(--ghl-border);border-top-color:var(--ghl-primary);border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.ghl-empty{text-align:center;padding:40px;color:var(--ghl-text-muted)}.ghl-empty-icon{font-size:48px;margin-bottom:16px;opacity:.5}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Calendar Widget - GHL MCP</title>
<script type="module" crossorigin src="/assets/calendar-widget-CUbShwNj.js"></script>
<link rel="modulepreload" crossorigin href="/assets/styles-CphAgR3l.js">
<link rel="stylesheet" crossorigin href="/assets/style-BaFxk78P.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Card - GHL MCP</title>
<script type="module" crossorigin src="/assets/contact-card-CFJe96SR.js"></script>
<link rel="modulepreload" crossorigin href="/assets/styles-CphAgR3l.js">
<link rel="stylesheet" crossorigin href="/assets/style-BaFxk78P.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contact Grid - GHL MCP</title>
<script type="module" crossorigin src="/assets/contact-grid-C_Uxn-WJ.js"></script>
<link rel="modulepreload" crossorigin href="/assets/styles-CphAgR3l.js">
<link rel="stylesheet" crossorigin href="/assets/style-BaFxk78P.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conversation Thread - GHL MCP</title>
<script type="module" crossorigin src="/assets/conversation-thread-DBGTC45D.js"></script>
<link rel="modulepreload" crossorigin href="/assets/styles-CphAgR3l.js">
<link rel="stylesheet" crossorigin href="/assets/style-BaFxk78P.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice Preview - GHL MCP</title>
<script type="module" crossorigin src="/assets/invoice-preview-DphRvjHB.js"></script>
<link rel="modulepreload" crossorigin href="/assets/styles-CphAgR3l.js">
<link rel="stylesheet" crossorigin href="/assets/style-BaFxk78P.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pipeline Kanban - GHL MCP</title>
<script type="module" crossorigin src="/assets/opportunity-kanban-CSzRcmdW.js"></script>
<link rel="modulepreload" crossorigin href="/assets/styles-CphAgR3l.js">
<link rel="stylesheet" crossorigin href="/assets/style-BaFxk78P.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests/**/*"]
}

33
memory/2026-01-30.md Normal file
View File

@ -0,0 +1,33 @@
# Memory Log - 2026-01-30
## Work Completed
### MCP Animation Framework (Remotion)
- **Status:** Dolly camera version completed using canvas viewport technique
- **Technical approach:** Camera zooms into typing area, follows text as typed, zooms out when done
- **Features:** Category-specific questions per software type for MCP marketing bulk generation
- **Current state:** Waiting for Jake's feedback on camera movement to iterate
### Recently Active Projects
- **fortura-assets:** 60 native components in component library
- **memory system:** Ongoing improvements to daily memory/logging system
## Decisions Made
- Using canvas viewport technique for dolly camera effect in Remotion (cleaner than direct camera transforms)
- Category-specific questions strategy for different software types in MCP marketing videos
## Next Steps
- Awaiting feedback on MCP Animation Framework dolly camera movement
- Based on feedback, may need to iterate on camera smoothness, timing, or framing
- Once approved, move forward with bulk animation generation pipeline
## Notable Context for Future Me
- MCP Animation Framework is a bulk animation generator for MCP marketing
- The core challenge is making typing animations feel dynamic and engaging
- Dolly camera approach adds visual interest without being distracting
- Different software categories need tailored questions to make videos relevant
## System Updates
- Daily memory checkpoint system is working (cron job triggered successfully)
- HEARTBEAT.md being updated with current task state
- Git backup routine in place

View File

@ -1,111 +1,53 @@
# Burton Method Research Intel
# Burton Method Competitor Research Intel
> **How this works:** Current week's in-depth intel lives at the top. Each week, I compress the previous week into 1-3 sentences and move it to the archive at the bottom. Reference this file when asked about competitor moves, EdTech trends, or strategic action items.
## Week of January 26 - February 1, 2026
### 7Sage
- **New Promo:** $10,000 giveaway contest for students who track applications via "My Schools" feature (deadline Jan 30, 2026)
- **Site Update:** New mobile-friendly design; app in development but no release date announced
- **Pricing:** Core ($69/mo), Live ($129/mo), Coach ($299/mo) - all require $120/yr LSAC LawHub Advantage
- **Key Differentiator:** "Insanely granular" test analytics, 924 video lessons, 7,500+ Reddit upvotes mentioned
### LSAT Demon
- **Pricing:** $95/month (lowest among major competitors)
- **Live Classes:** Daily Zoom-based classes covering all LSAT sections
- **Content:** 10,000+ explanations, official LSAT drilling, "Ask" feature with 24-hour response time
- **Founders:** Ben Olson & Nathan Fox (Thinking LSAT Podcast)
### Blueprint LSAT
- **Focus:** Heavy emphasis on 1:1 private tutoring
- **Social Proof:** Numerous 5-star reviews citing 9+ point score improvements
- **Notable:** Strong instructor personalization mentioned in reviews (Bobby, Dylan, Hannah, Larissa)
### PowerScore
- **Dave Killoran departed** (HUGE personnel change) - Jon Denning continuing solo
- **MAJOR UPDATE:** New 2025-2026 products include "The Law School Admissions Bible" written by Spivey Consulting
- **Strategic Pivot:** Moving beyond pure LSAT prep into law school admissions consulting space (reinforced Jan 30)
- **Live Classes:** Extensive daily schedule with topic-specific sessions (RC, LR, LG question types)
- **Strategic read:** Industry veteran leaving created uncertainty, but new Spivey partnership signals aggressive admissions push. Burton's visual/multimodal approach could differentiate in market where LSAT alone matters less.
### Magoosh LSAT
- **Content:** Active blog with study resources, percentile calculators
- **Less Visible:** Homepage minimal; main value appears to be blog content
### LSAC Official
- **January 2026 LSAT:** Score release 1/28/2026
- **Testing Disruption:** Mainland China testing unavailable for January 2026 LSAT
- **International:** International administration available, but China exception notable
### Industry Trends (EdCircuit, Buffalo Law)
- **Rising Competition:** Law school applications and LSAT scores both increasing for 2025-2026 cycle
- **Diminishing Differentiation:** High LSAT scores becoming less differentiating in applicant pools (reinforced Jan 30)
- **Access Issue:** Financial backing and unpaid prep time creating advantages for wealthy students
- **Jan 30 Update:** Applications + LSAT scores both rising → high scores becoming less differentiating. Need efficiency and unique methodology to stand out.
---
## 📊 Current Week Intel (Week of Jan 27 - Feb 2, 2026)
### Blogwatcher Status
- **13 feeds tracked**, but most competitor blogs lack working RSS/scrapers
- **No new articles this week** (as of Jan 30)
- **Action needed:** Manually configure or find alternative data sources for key competitor blogs
### 🚨 CRITICAL: LSAC Format Change
**Reading Comp removed comparative passages** in January 2026 administration. Confirmed by Blueprint and PowerScore.
- **Impact:** Any RC curriculum teaching comparative passage strategy is now outdated
- **Opportunity:** First to fully adapt = trust signal to students
---
### Competitor Movements
**7Sage**
- Full site redesign launched (better analytics, cleaner UI)
- **NEW FREE FEATURE:** Application tracker showing interview/accept/reject/waitlist outcomes
- $10,000 giveaway promotion tied to tracker
- Heavy ABA 509 report coverage
- ADHD accommodations content series + 1L survival guides
- **Pricing (Jan 27):** Core $69/mo | Live $129/mo | Coach $299/mo (all require LawHub $120/yr)
- **Scale:** 60+ live classes/week, 3,000+ recorded classes
- **Strategic read:** Pushing hard into admissions territory, not just LSAT. Creates stickiness + data network effects.
**LSAT Demon**
- **"Ugly Mode"** (Jan 19) — transforms interface to match exact official LSAT layout
- Tuition Roll Call on scholarship estimator — visualizes what students actually paid
- Veteran outreach program with dedicated liaison
- **Pricing (Jan 27):** Entry at $95/month — competitive with 7Sage Core
- **Strategic read:** Daily podcast creates parasocial relationships. Demon is personality-driven; Burton is methodology-driven. Different lanes.
**PowerScore**
- **Dave Killoran departed** (HUGE personnel change)
- Jon Denning continuing solo, covering January LSAT chaos extensively
- Crystal Ball webinars still running
- **Strategic read:** Industry veteran leaving creates uncertainty. Watch for quality/content changes.
**Blueprint**
- First to report RC comparative passages removal
- Non-traditional student content (LSAT at 30/40/50+)
- Score plateau breakthrough guides
- 2025-26 admissions cycle predictions
- **Jan 27 Update:** Heavy push on 1:1 tutoring testimonials; "170+ course" positioning; reviews emphasizing "plateau breakthroughs" and test anxiety management
- **Strategic read:** Solid content machine, "fun" brand positioning. Going premium/high-touch with tutoring.
**Kaplan**
- $200 off all LSAT prep **extended through Jan 26** (expires TODAY)
- Applies to On Demand, Live Online, In Person, and Standard Tutoring
- Bar prep also discounted ($750 off through Feb 27)
- New 2026 edition book with "99th percentile instructor videos"
- **Strategic read:** Mass-market, price-conscious positioning continues. Heavy discounting signals competitive pressure.
**Magoosh**
- Updated for post-Logic Games LSAT
- Budget positioning continues
- LSAC remote proctoring option coverage
- **Jan 28 Update:** Landing page now shows blog-only content — no active LSAT prep product visible. Appears to have scaled back or exited market. Potential market consolidation signal.
**LSAC (Official)**
- February 2026 scheduling opened Jan 20
- January registration closed; score release Jan 28
- Mainland China testing unavailable for Jan 2026
- Reminder to disable grammar-checking programs for Argumentative Writing
---
### EdTech Trends (Jan 27 Scan)
| Story | Score | Key Insight |
|-------|-------|-------------|
| **AdeptLR: LSAT AI Competitor** | 9/10 | Direct LSAT LR adaptive AI platform; validates market, narrow focus (LR only) |
| **Blueprint: AI Could Mess Up LSAT Prep** | 8/10 | GPT-4 scored 163; AI gives confident wrong answers; training data outdated |
| **UB Law: Apps Up 22%** | 7/10 | Highest applicant volume in decade; 160-180 scores climbing; career changers entering |
| **TCS: EdTech Trends 2026** | 7/10 | AI adaptive models proven; "agentic AI" emerging; outcomes > features now |
| **LSAC: 2026 Cycle Strong** | 7/10 | Official confirmation LSAT volumes high; broad applicant base |
| **eSchool: 49 EdTech Predictions** | 6/10 | "Shiny AI era over" — measurable outcomes required to win |
### Previous Scan (Jan 25)
| Story | Score | Key Insight |
|-------|-------|-------------|
| AI Can Deepen Learning | 8/10 | AI mistakes spark deeper learning; productive friction > shortcuts |
| Beyond Memorization: Redefining Rigor | 8/10 | LSAT-relevant: adaptability + critical thinking > memorization |
| Teaching Machines to Spot Human Errors | 7/10 | Eedi Labs predicting student misconceptions; human-in-the-loop AI tutoring |
| Learning As Addictive As TikTok? 7/10 | Dopamine science for engagement; make progress feel attainable |
| What Students Want From Edtech | 6/10 | UX research: clarity > gimmicks; meaningful gamification only |
---
### 📌 Identified Action Items
0. **🚨 TIME-SENSITIVE (Jan 28):** January LSAT scores release TODAY — prime acquisition window for students who missed target. Consider outreach campaign for 1/28-1/31.
1. **URGENT:** Update RC content to remove/deprioritize comparative passage strategy
2. **Content opportunity:** Blog post "What the RC Changes Mean for Your Score" — be fast, be definitive
3. **Positioning clarity:** 7Sage → admissions features, Demon → personality, Burton → systematic methodology that transcends format changes
4. **Product opportunity:** Consider "productive friction" AI features that make students think, not just answer
5. **Watch:** PowerScore post-Killoran quality — potential talent acquisition or market share opportunity
6. **NEW - Competitive research:** Study AdeptLR UX for adaptive LSAT AI patterns (what works, what doesn't)
7. **NEW - Differentiation angle:** Build AI that admits uncertainty (address Blueprint's "confident wrong answers" critique)
8. **NEW - Marketing:** Track & publish score improvement data to meet "outcomes over features" bar
9. **NEW - Market validation:** 22% app surge + decade-high volumes = sustained demand; career changers = self-study friendly audience
---
## 📚 Previous Weeks Archive
*(No previous weeks yet — this section will grow as weeks pass)*
## Previous Weeks
*No prior weeks tracked - file created 2026-01-30*

View File

@ -10,3 +10,5 @@
2026-01-26: Make today count! Pickle moment: Why are pickles such good friends? They're always there when you're in a jam...or jar.
2026-01-27: Your time is now! Pickles are wild: What's a pickle's favorite day of the week? Fri-dill of course.
2026-01-28: You're unstoppable! Quick pickle story: Why are pickles so resilient? They've been through a lot - literally submerged and came out crunchier.
2026-01-29: Dream big, work hard! Pickle knowledge drop: What do you call a pickle that's really stressed? A dill-lemma.
2026-01-30: Consistency is key! Never forget about pickles: Why did the pickle go to therapy? It had some unresolved jar issues.