Loading...
Loading...
Open-source marketing operations control center for AI agent teams with CRM, outreach, content ops, and analytics powered by OpenClaw + SQLite
npx skill4agent add aradotso/marketing-skills hermes-marketing-dashboardSkill by ara.so — Marketing Skills collection.
npm install -g pnpmcorepack enablegit clone https://github.com/builderz-labs/marketing-dashboard.git
cd marketing-dashboard
pnpm install
pnpm env:bootstrap
pnpm devhttp://localhost:3000.env.local# Authentication (required)
AUTH_USER=admin
AUTH_PASS=your-secure-password-min-10-chars
API_KEY=your-api-key-for-programmatic-access
# Cookie security (false for HTTP local, true for HTTPS production)
AUTH_COOKIE_SECURE=false
# Database (auto-created in ./state)
DATABASE_URL=./state/hermes.db# OpenClaw home directory
HERMES_OPENCLAW_HOME=/path/to/openclaw
# Default instance name
HERMES_DEFAULT_INSTANCE=main
# Multi-instance support (optional JSON array)
HERMES_OPENCLAW_INSTANCES='[{"name":"prod","path":"/openclaw/prod"},{"name":"dev","path":"/openclaw/dev"}]'# Host access lock (default: local-only)
HERMES_HOST_LOCK=local # or 'off' or 'host1,host2'
# Writeback protection (keep false unless explicitly needed)
HERMES_ALLOW_POLICY_WRITE=false
HERMES_ALLOW_CRON_WRITE=false
HERMES_ALLOW_WORKSPACE_WRITE=false# Plausible Analytics
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=yourdomain.com
PLAUSIBLE_API_KEY=your-plausible-api-key
# Google Analytics 4
NEXT_PUBLIC_GA4_ID=G-XXXXXXXXXXHERMES_1PASSWORD_MODE=auto # off|auto|required
HERMES_OP_ENV_FILE=/etc/hermes-dashboard/hermes-dashboard.op.env# Start development server
pnpm dev
# Build for production
pnpm build
# Start production server
pnpm start
# Type checking
pnpm typecheck
# Linting
pnpm lint
# Run tests
pnpm test
# Run end-to-end tests
pnpm test:e2e# Bootstrap environment and database
pnpm env:bootstrap
# Reset database (warning: destructive)
pnpm db:reset# Audit template for sensitive data
./scripts/template-audit.sh
# Export clean template
./scripts/template-export.sh /path/to/output// app/api/crm/leads/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getDatabase } from '@/lib/db';
export async function GET(request: NextRequest) {
const db = getDatabase();
const leads = db.prepare(`
SELECT id, email, name, source, status, created_at
FROM crm_leads
WHERE status = ?
ORDER BY created_at DESC
`).all('active');
return NextResponse.json({ leads });
}
export async function POST(request: NextRequest) {
const db = getDatabase();
const { email, name, source, metadata } = await request.json();
const result = db.prepare(`
INSERT INTO crm_leads (email, name, source, metadata, status)
VALUES (?, ?, ?, ?, 'new')
`).run(email, name, source, JSON.stringify(metadata));
return NextResponse.json({ id: result.lastInsertRowid }, { status: 201 });
}// lib/outreach/sequence.ts
import { getDatabase } from '@/lib/db';
export interface OutreachSequence {
id: number;
name: string;
steps: OutreachStep[];
status: 'active' | 'paused' | 'archived';
}
export interface OutreachStep {
order: number;
delay_hours: number;
template_id: string;
channel: 'email' | 'linkedin' | 'twitter';
}
export function createSequence(
name: string,
steps: OutreachStep[]
): number {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO outreach_sequences (name, steps, status)
VALUES (?, ?, 'active')
`).run(name, JSON.stringify(steps));
return result.lastInsertRowid as number;
}
export function enrollInSequence(
leadId: number,
sequenceId: number
): void {
const db = getDatabase();
db.prepare(`
INSERT INTO outreach_enrollments (lead_id, sequence_id, current_step, status)
VALUES (?, ?, 0, 'active')
`).run(leadId, sequenceId);
}
export function pauseSequence(sequenceId: number): void {
const db = getDatabase();
db.prepare(`
UPDATE outreach_sequences
SET status = 'paused'
WHERE id = ?
`).run(sequenceId);
}// lib/content/calendar.ts
import { getDatabase } from '@/lib/db';
export interface ContentItem {
id: number;
title: string;
type: 'blog' | 'social' | 'email' | 'video';
status: 'draft' | 'scheduled' | 'published';
scheduled_at?: Date;
published_at?: Date;
metadata: Record<string, any>;
}
export function getContentCalendar(
startDate: Date,
endDate: Date
): ContentItem[] {
const db = getDatabase();
const items = db.prepare(`
SELECT id, title, type, status, scheduled_at, published_at, metadata
FROM content_items
WHERE scheduled_at BETWEEN ? AND ?
ORDER BY scheduled_at ASC
`).all(startDate.toISOString(), endDate.toISOString());
return items.map(item => ({
...item,
metadata: JSON.parse(item.metadata as string),
scheduled_at: item.scheduled_at ? new Date(item.scheduled_at) : undefined,
published_at: item.published_at ? new Date(item.published_at) : undefined,
}));
}
export function createContentItem(item: Omit<ContentItem, 'id'>): number {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO content_items (title, type, status, scheduled_at, metadata)
VALUES (?, ?, ?, ?, ?)
`).run(
item.title,
item.type,
item.status,
item.scheduled_at?.toISOString(),
JSON.stringify(item.metadata)
);
return result.lastInsertRowid as number;
}// lib/openclaw/agents.ts
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
export interface OpenClawAgent {
name: string;
description: string;
type: 'agent' | 'squad';
capabilities: string[];
config: Record<string, any>;
}
export async function discoverAgents(
openclawHome: string
): Promise<OpenClawAgent[]> {
const agentsDir = join(openclawHome, 'agents');
const agents: OpenClawAgent[] = [];
try {
const entries = await readdir(agentsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const configPath = join(agentsDir, entry.name, 'agent.json');
try {
const configData = await readFile(configPath, 'utf-8');
const config = JSON.parse(configData);
agents.push({
name: entry.name,
description: config.description || '',
type: config.type || 'agent',
capabilities: config.capabilities || [],
config,
});
} catch (err) {
console.warn(`Could not load agent config for ${entry.name}`);
}
}
}
} catch (err) {
console.error('Failed to discover agents:', err);
}
return agents;
}// lib/cron/scheduler.ts
import { getDatabase } from '@/lib/db';
export interface CronJob {
id: number;
name: string;
schedule: string; // cron expression, 'every 1h', or 'at 09:00'
agent: string;
task: string;
enabled: boolean;
last_run?: Date;
}
export function createCronJob(job: Omit<CronJob, 'id'>): number {
const db = getDatabase();
const result = db.prepare(`
INSERT INTO cron_jobs (name, schedule, agent, task, enabled)
VALUES (?, ?, ?, ?, ?)
`).run(job.name, job.schedule, job.agent, job.task, job.enabled ? 1 : 0);
return result.lastInsertRowid as number;
}
export function getCronJobs(): CronJob[] {
const db = getDatabase();
const jobs = db.prepare(`
SELECT id, name, schedule, agent, task, enabled, last_run
FROM cron_jobs
ORDER BY name ASC
`).all();
return jobs.map(job => ({
...job,
enabled: Boolean(job.enabled),
last_run: job.last_run ? new Date(job.last_run) : undefined,
}));
}
export function updateLastRun(jobId: number): void {
const db = getDatabase();
db.prepare(`
UPDATE cron_jobs
SET last_run = ?
WHERE id = ?
`).run(new Date().toISOString(), jobId);
}// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const session = request.cookies.get('hermes-session');
if (!session && request.nextUrl.pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return NextResponse.next();
}
export const config = {
matcher: '/api/:path*',
};// lib/auth/api-key.ts
import { NextRequest } from 'next/server';
export function validateApiKey(request: NextRequest): boolean {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
const token = authHeader.substring(7);
return token === process.env.API_KEY;
}
// Usage in API route
export async function GET(request: NextRequest) {
if (!validateApiKey(request)) {
return NextResponse.json({ error: 'Invalid API key' }, { status: 401 });
}
// Continue with request...
}// lib/analytics/events.ts
import { getDatabase } from '@/lib/db';
export interface AnalyticsEvent {
event_type: string;
properties: Record<string, any>;
user_id?: string;
session_id?: string;
}
export function trackEvent(event: AnalyticsEvent): void {
const db = getDatabase();
db.prepare(`
INSERT INTO analytics_events (event_type, properties, user_id, session_id, timestamp)
VALUES (?, ?, ?, ?, ?)
`).run(
event.event_type,
JSON.stringify(event.properties),
event.user_id,
event.session_id,
new Date().toISOString()
);
}
export function getKPIs(startDate: Date, endDate: Date) {
const db = getDatabase();
return {
leads: db.prepare(`
SELECT COUNT(*) as count FROM crm_leads
WHERE created_at BETWEEN ? AND ?
`).get(startDate.toISOString(), endDate.toISOString()),
outreach_sent: db.prepare(`
SELECT COUNT(*) as count FROM outreach_messages
WHERE sent_at BETWEEN ? AND ?
`).get(startDate.toISOString(), endDate.toISOString()),
content_published: db.prepare(`
SELECT COUNT(*) as count FROM content_items
WHERE published_at BETWEEN ? AND ?
`).get(startDate.toISOString(), endDate.toISOString()),
};
}// components/dashboard/LeadsFunnel.tsx
'use client';
import { useEffect, useState } from 'react';
interface FunnelData {
stage: string;
count: number;
}
export function LeadsFunnel() {
const [data, setData] = useState<FunnelData[]>([]);
useEffect(() => {
fetch('/api/crm/funnel')
.then(res => res.json())
.then(setData);
}, []);
return (
<div className="funnel-widget">
<h3>Pipeline Funnel</h3>
{data.map(stage => (
<div key={stage.stage} className="funnel-stage">
<span>{stage.stage}</span>
<span>{stage.count}</span>
</div>
))}
</div>
);
}// lib/db.ts
import Database from 'better-sqlite3';
let db: Database.Database | null = null;
export function getDatabase(): Database.Database {
if (!db) {
db = new Database(process.env.DATABASE_URL || './state/hermes.db');
db.pragma('journal_mode = WAL'); // Write-Ahead Logging for better concurrency
db.pragma('busy_timeout = 5000'); // 5 second timeout
}
return db;
}HERMES_OPENCLAW_HOME# Verify path
ls $HERMES_OPENCLAW_HOME/agents
# Check permissions
chmod -R u+r $HERMES_OPENCLAW_HOME/agentsAUTH_COOKIE_SECURE=trueAUTH_COOKIE_SECURE=false# Disable (not recommended for production)
HERMES_HOST_LOCK=off
# Allow specific hosts
HERMES_HOST_LOCK=localhost,192.168.1.100,mydomain.comcurl -H "Authorization: Bearer $API_KEY" http://localhost:3000/api/crm/leads// In API route
if (!process.env.API_KEY) {
console.error('API_KEY not configured');
}AUTH_USER=your-admin-username
AUTH_PASS=strong-password-min-10-chars
API_KEY=cryptographically-secure-keyAUTH_COOKIE_SECURE=trueHERMES_HOST_LOCK=yourdomain.comHERMES_ALLOW_POLICY_WRITE=false
HERMES_ALLOW_CRON_WRITE=false
HERMES_ALLOW_WORKSPACE_WRITE=falsepnpm build
pnpm startFROM node:18-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]// lib/openclaw/instances.ts
export function getInstances(): Array<{name: string, path: string}> {
const instancesEnv = process.env.HERMES_OPENCLAW_INSTANCES;
if (instancesEnv) {
return JSON.parse(instancesEnv);
}
return [{
name: process.env.HERMES_DEFAULT_INSTANCE || 'main',
path: process.env.HERMES_OPENCLAW_HOME || './openclaw'
}];
}// lib/audit/logger.ts
import { getDatabase } from '@/lib/db';
export function logAuditEvent(
action: string,
userId: string,
metadata: Record<string, any>
): void {
const db = getDatabase();
db.prepare(`
INSERT INTO audit_log (action, user_id, metadata, timestamp)
VALUES (?, ?, ?, ?)
`).run(action, userId, JSON.stringify(metadata), new Date().toISOString());
}