Phase 4: Proactive Intelligence
Timeline: Week 7-9
Prerequisites: Phase 3 complete
Objective: Make the system generate content proactively, not just reactively.
4.1 — Website Monitoring
Tasks
- [ ] Create
watched_urlsdatabase table - [ ] Implement
POST /client/:id/watch_urlendpoint - [ ] Build content change detection (hash comparison)
- [ ] Create monitoring cron job
- [ ] Add sitemap parser for auto-discovery
- [ ] Trigger GoClaw agent when changes detected
Watched URLs Table
Create migration front-end/supabase/migrations/20260310_watched_urls.sql:
CREATE TABLE IF NOT EXISTS watched_urls (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customer_customer(id) ON DELETE CASCADE,
url TEXT NOT NULL,
url_type TEXT DEFAULT 'page' CHECK (url_type IN ('page', 'sitemap', 'blog', 'news')),
last_hash TEXT,
last_checked_at TIMESTAMPTZ,
last_changed_at TIMESTAMPTZ,
check_frequency_hours INTEGER DEFAULT 6,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
metadata JSONB DEFAULT '{}'::jsonb
);
CREATE UNIQUE INDEX idx_watched_urls_unique ON watched_urls(customer_id, url);
CREATE INDEX idx_watched_urls_customer ON watched_urls(customer_id);
CREATE INDEX idx_watched_urls_next_check ON watched_urls(last_checked_at, is_active);
Watch URL Endpoint
Add to api/src/index.ts:
const WatchUrlSchema = Type.Object({
url: Type.String({ format: 'uri' }),
url_type: Type.Optional(Type.Union([
Type.Literal('page'),
Type.Literal('sitemap'),
Type.Literal('blog'),
Type.Literal('news')
])),
check_frequency_hours: Type.Optional(Type.Number({ minimum: 1, maximum: 168 }))
});
app.post<{
Params: { id: string },
Body: typeof WatchUrlSchema.static
}>('/client/:id/watch_url', {
schema: {
body: WatchUrlSchema
}
}, async (request, reply) => {
const { id } = request.params;
const { url, url_type = 'page', check_frequency_hours = 6 } = request.body;
// Fetch initial content and hash
const { markdown } = await scrapeUrl(url);
const hash = crypto.createHash('sha256').update(markdown).digest('hex');
const { data, error } = await supabase
.from('watched_urls')
.upsert({
customer_id: parseInt(id),
url,
url_type,
last_hash: hash,
last_checked_at: new Date().toISOString(),
check_frequency_hours,
is_active: true
}, {
onConflict: 'customer_id,url'
})
.select()
.single();
if (error) {
return reply.code(500).send({ error: error.message });
}
return data;
});
Content Change Detection
Create api/src/utils/url-monitor.ts:
import crypto from 'crypto';
import { scrapeUrl } from './scraper.js';
import { createSupabaseClient } from './supabase.js';
const supabase = createSupabaseClient();
export async function checkUrlForChanges(watchedUrl: any): Promise<boolean> {
try {
const { markdown } = await scrapeUrl(watchedUrl.url);
const newHash = crypto.createHash('sha256').update(markdown).digest('hex');
if (newHash !== watchedUrl.last_hash) {
// Content changed!
await supabase
.from('watched_urls')
.update({
last_hash: newHash,
last_changed_at: new Date().toISOString(),
last_checked_at: new Date().toISOString()
})
.eq('id', watchedUrl.id);
return true; // Changed
} else {
// No change
await supabase
.from('watched_urls')
.update({ last_checked_at: new Date().toISOString() })
.eq('id', watchedUrl.id);
return false;
}
} catch (error) {
console.error(`Error checking URL ${watchedUrl.url}:`, error);
return false;
}
}
export async function checkAllWatchedUrls() {
const now = new Date();
// Get URLs that need checking
const { data: urls, error } = await supabase
.from('watched_urls')
.select('*')
.eq('is_active', true)
.or(`last_checked_at.is.null,last_checked_at.lt.${new Date(now.getTime() - 6 * 60 * 60 * 1000).toISOString()}`);
if (error || !urls) {
console.error('Error fetching watched URLs:', error);
return;
}
const changedUrls: any[] = [];
for (const url of urls) {
const changed = await checkUrlForChanges(url);
if (changed) {
changedUrls.push(url);
}
}
return changedUrls;
}
Monitoring Cron Job
Replace Bree with a proper Node cron or use GoClaw's cron system.
Option A: Node-based (API-side cron)
Install: pnpm add node-cron
Create api/src/scheduler/url-monitor.ts:
import cron from 'node-cron';
import { checkAllWatchedUrls } from '../utils/url-monitor.js';
import { env } from '../config/env.js';
export function startUrlMonitoring() {
// Run every hour
cron.schedule('0 * * * *', async () => {
console.log('Running URL monitoring check...');
const changedUrls = await checkAllWatchedUrls();
if (changedUrls && changedUrls.length > 0) {
console.log(`Found ${changedUrls.length} changed URLs`);
// Trigger GoClaw agent for each change
for (const url of changedUrls) {
await triggerContentGeneration(url);
}
}
});
}
async function triggerContentGeneration(watchedUrl: any) {
// Call GoClaw API to trigger creator-agent
const goclawUrl = `${env.GOCLAW_API_URL}/sessions/message`;
// Get telegram_chat_id for this customer
const { data: mapping } = await supabase
.from('telegram_client_mapping')
.select('telegram_chat_id')
.eq('customer_id', watchedUrl.customer_id)
.single();
if (!mapping) return;
await fetch(goclawUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channel: 'telegram',
chat_id: mapping.telegram_chat_id,
message: `I noticed your website was updated: ${watchedUrl.url}. Would you like me to create content about the changes?`,
agent_id: 'liaison-agent'
})
});
}
Start in api/src/server.ts:
import { startUrlMonitoring } from './scheduler/url-monitor.js';
startUrlMonitoring();
Option B: GoClaw-side cron (recommended)
Create GoClaw cron job that calls API to check URLs:
{
"name": "website-monitor",
"schedule": {
"kind": "every",
"everyMs": 3600000
},
"message": "Check all watched URLs for content changes. For any changes found, notify the client and offer to create content.",
"agentId": "liaison-agent"
}
Liaison agent handles:
## Website Monitoring (Hourly Cron)
When triggered by cron:
1. Use tool: web_fetch GET http://api:8000/watched-urls/check
2. API returns list of URLs that changed
3. For each changed URL:
- Fetch the customer's telegram_chat_id
- Send message: "I noticed [url] was updated. Would you like me to create content about it?"
- Wait for response
- If yes: fetch_client_context, delegate to creator-agent
Add endpoint:
app.get('/watched-urls/check', async (request, reply) => {
const changedUrls = await checkAllWatchedUrls();
return changedUrls || [];
});
Sitemap Parser
Create api/src/utils/sitemap-parser.ts:
import { XMLParser } from 'fast-xml-parser';
export async function parseSitemap(sitemapUrl: string): Promise<string[]> {
const response = await fetch(sitemapUrl);
const xml = await response.text();
const parser = new XMLParser();
const parsed = parser.parse(xml);
const urls: string[] = [];
// Handle standard sitemap
if (parsed.urlset?.url) {
const urlEntries = Array.isArray(parsed.urlset.url)
? parsed.urlset.url
: [parsed.urlset.url];
for (const entry of urlEntries) {
urls.push(entry.loc);
}
}
// Handle sitemap index
if (parsed.sitemapindex?.sitemap) {
const sitemaps = Array.isArray(parsed.sitemapindex.sitemap)
? parsed.sitemapindex.sitemap
: [parsed.sitemapindex.sitemap];
for (const sm of sitemaps) {
const subUrls = await parseSitemap(sm.loc);
urls.push(...subUrls);
}
}
return urls;
}
Endpoint to auto-discover from sitemap:
app.post<{ Params: { id: string }, Body: { sitemap_url: string } }>(
'/client/:id/watch_sitemap',
async (request, reply) => {
const { id } = request.params;
const { sitemap_url } = request.body;
const urls = await parseSitemap(sitemap_url);
// Watch all URLs found in sitemap
const results = [];
for (const url of urls) {
const { data } = await supabase
.from('watched_urls')
.upsert({
customer_id: parseInt(id),
url,
url_type: 'blog',
is_active: true,
check_frequency_hours: 24
}, {
onConflict: 'customer_id,url'
})
.select();
results.push(data);
}
return {
discovered: urls.length,
watching: results.length
};
}
);
4.2 — Event Calendar Integration
Tasks
- [ ] Populate
marketing_calendarwith holidays and events - [ ] Create
GET /calendar/upcomingendpoint - [ ] Build GoClaw cron for weekly content planning
- [ ] Integrate calendar into creator-agent workflow
Populate Calendar
Create seed file front-end/supabase/seeds/marketing_calendar.sql:
INSERT INTO marketing_calendar (event_name, event_date, event_type, industries, description, is_global) VALUES
('New Year''s Day', '2026-01-01', 'holiday', '{}', 'Start of the year celebrations', true),
('Valentine''s Day', '2026-02-14', 'holiday', '{retail,food,hospitality}', 'Romance and gift-giving', false),
('International Women''s Day', '2026-03-08', 'awareness_day', '{}', 'Celebrating women''s achievements', true),
('Earth Day', '2026-04-22', 'awareness_day', '{sustainability,environment}', 'Environmental awareness', false),
('Mother''s Day', '2026-05-10', 'holiday', '{retail,food,hospitality}', 'Honoring mothers', false),
('Father''s Day', '2026-06-21', 'holiday', '{retail,food,hospitality}', 'Honoring fathers', false),
('Back to School', '2026-08-15', 'custom', '{retail,education}', 'School season begins', false),
('Halloween', '2026-10-31', 'holiday', '{retail,food,entertainment}', 'Spooky celebrations', false),
('Black Friday', '2026-11-27', 'custom', '{retail,ecommerce}', 'Major shopping day', false),
('Cyber Monday', '2026-11-30', 'custom', '{ecommerce,technology}', 'Online shopping deals', false),
('Christmas', '2026-12-25', 'holiday', '{}', 'Major winter holiday', true),
('Small Business Saturday', '2026-11-28', 'custom', '{retail,small_business}', 'Support local businesses', false)
ON CONFLICT DO NOTHING;
Run: npx supabase db seed
Upcoming Events Endpoint
const UpcomingEventsQuerySchema = Type.Object({
industries: Type.Optional(Type.Array(Type.String())),
days: Type.Optional(Type.Number({ minimum: 1, maximum: 90 }))
});
app.get<{ Querystring: typeof UpcomingEventsQuerySchema.static }>(
'/calendar/upcoming',
{
schema: {
querystring: UpcomingEventsQuerySchema
}
},
async (request, reply) => {
const { industries = [], days = 14 } = request.query;
const endDate = new Date();
endDate.setDate(endDate.getDate() + days);
let query = supabase
.from('marketing_calendar')
.select('*')
.gte('event_date', new Date().toISOString().split('T')[0])
.lte('event_date', endDate.toISOString().split('T')[0])
.order('event_date', { ascending: true });
// Filter by industries if provided
if (industries.length > 0) {
query = query.or(
`industries.cs.{${industries.join(',')}},is_global.eq.true`
);
}
const { data, error } = await query;
if (error) {
return reply.code(500).send({ error: error.message });
}
return data || [];
}
);
Weekly Content Planning Cron
GoClaw cron job (Monday 9 AM):
{
"name": "weekly-content-planning",
"schedule": {
"kind": "cron",
"expr": "0 9 * * 1",
"timezone": "America/New_York"
},
"message": "Check upcoming events/holidays for the next 14 days. For each active client, generate themed content batch and present for approval.",
"agentId": "creator-agent",
"deliver": false
}
Creator-agent skill addition:
## Weekly Content Planning (Cron)
When triggered by Monday cron:
1. Fetch all active customers
2. For each customer:
- Call fetch_client_context
- Call web_fetch GET /calendar/upcoming?industries=[client industries]&days=14
- If events found:
- Generate themed content for top 2-3 relevant events
- Delegate results to liaison-agent with context
- Liaison presents to client: "I noticed [Event] is coming up. Here are some post ideas..."
4.3 — Analytics Feedback Loop
Tasks
- [ ] Build analytics collectors per platform
- [ ] Create monthly analytics aggregation cron
- [ ] Insert learnings into GoClaw memory
- [ ] Update creator-agent to query memory before generation
Analytics Collectors
Install platform API clients:
cd api
pnpm add @mailchimp/mailchimp_transactional
Create api/src/integrations/analytics/:
Twitter Analytics (twitter-analytics.ts):
import { TwitterApi } from 'twitter-api-v2';
export async function fetchTwitterAnalytics(
client: TwitterApi,
tweetId: string
): Promise<{
likes: number;
retweets: number;
replies: number;
impressions: number;
}> {
const tweet = await client.v2.singleTweet(tweetId, {
'tweet.fields': ['public_metrics']
});
return {
likes: tweet.data.public_metrics?.like_count || 0,
retweets: tweet.data.public_metrics?.retweet_count || 0,
replies: tweet.data.public_metrics?.reply_count || 0,
impressions: tweet.data.public_metrics?.impression_count || 0
};
}
Mailchimp Email Analytics (mailchimp-analytics.ts):
import mailchimp from '@mailchimp/mailchimp_transactional';
export async function fetchEmailAnalytics(
apiKey: string,
messageId: string
): Promise<{
opens: number;
clicks: number;
open_rate: number;
click_rate: number;
}> {
const client = mailchimp(apiKey);
const info = await client.messages.info({ id: messageId });
return {
opens: info.opens,
clicks: info.clicks,
open_rate: info.opens / Math.max(info.sends, 1),
click_rate: info.clicks / Math.max(info.opens, 1)
};
}
Monthly Analytics Aggregation
GoClaw cron (first day of month):
{
"name": "monthly-analytics-review",
"schedule": {
"kind": "cron",
"expr": "0 2 1 * *"
},
"message": "Aggregate last month's post performance. Extract insights and update client memory with learnings.",
"agentId": "liaison-agent",
"deliver": false
}
API endpoint to aggregate:
app.post<{ Params: { id: string } }>(
'/client/:id/analytics/aggregate',
async (request, reply) => {
const { id } = request.params;
// Fetch all published posts from last 30 days with analytics
const { data: posts, error } = await supabase
.from('customer_platform_post')
.select(`
id,
platform,
content,
customer_post:customer_posts!inner(customer_id),
analytics:post_analytics(metric_type, metric_value)
`)
.eq('customer_post.customer_id', parseInt(id))
.gte('customer_post.created_at', new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString());
if (error || !posts) {
return reply.code(500).send({ error: error?.message || 'No data' });
}
// Analyze patterns
const insights = analyzePostPerformance(posts);
return insights;
}
);
function analyzePostPerformance(posts: any[]): Record<string, any> {
// Group by platform
const byPlatform = posts.reduce((acc, post) => {
if (!acc[post.platform]) acc[post.platform] = [];
acc[post.platform].push(post);
return acc;
}, {} as Record<string, any[]>);
const insights: Record<string, any> = {};
for (const [platform, platformPosts] of Object.entries(byPlatform)) {
// Calculate averages
const avgLikes = platformPosts.reduce((sum, p) =>
sum + (p.analytics.find((a: any) => a.metric_type === 'likes')?.metric_value || 0), 0
) / platformPosts.length;
// Find top performer
const topPost = platformPosts.sort((a, b) => {
const aLikes = a.analytics.find((m: any) => m.metric_type === 'likes')?.metric_value || 0;
const bLikes = b.analytics.find((m: any) => m.metric_type === 'likes')?.metric_value || 0;
return bLikes - aLikes;
})[0];
insights[platform] = {
post_count: platformPosts.length,
avg_likes: avgLikes,
top_post_content: topPost?.content.substring(0, 100),
top_post_performance: topPost?.analytics
};
}
return insights;
}
Insert Learnings into GoClaw Memory
Liaison agent handles monthly cron:
## Monthly Analytics Review (Cron)
When triggered:
1. For each active client:
- Call web_fetch POST /client/:id/analytics/aggregate
- Receive insights object
- Formulate actionable rules:
```
"For client X (id: 123):
- Twitter posts with questions get 40% more likes than statements
- Instagram carousel posts outperform single images 2:1
- Email subject lines under 40 chars have 25% higher open rates
- LinkedIn posts with industry stats get 3x more shares"
```
- Use the `memory_search` tool to store these as memories with tags:
```
memory_save(
content: [rules above],
tags: ["analytics", "client_123", "best_practices"],
importance: "high"
)
```
2. Send summary to client via Telegram
Creator Agent Memory Integration
Update content-creation/SKILL.md:
## Before You Write (Updated)
1. **ALWAYS call `fetch_client_context`**
2. **Search memory for past learnings**:
memory_search(query: "client_[id] analytics best_practices", limit:10)
3. **Apply learnings to your content**:
- If memory says "questions perform better on Twitter" → use question format
- If memory says "short emails work better" → keep it under 200 words
- If memory says "industry stats boost LinkedIn" → include relevant stat
4.4 — Scheduler Overhaul
Tasks
- [ ] Remove Bree dependency
- [ ] Document all GoClaw cron jobs in one place
- [ ] Set up per-client scheduling preferences
- [ ] Enforce "max 3 unsent posts" rule
Remove Bree
cd api
pnpm remove bree
rm -rf scheduler/bree.ts
GoClaw Cron Consolidation
Create goclaw/cron-jobs.json documenting all jobs:
{
"cron_jobs": [
{
"name": "approval-reminders",
"schedule": "0 10 * * *",
"description": "Daily 10 AM: remind clients about pending approvals",
"agent": "liaison-agent"
},
{
"name": "weekly-summary",
"schedule": "0 9 * * 1",
"description": "Monday 9 AM: send weekly summary to clients",
"agent": "liaison-agent"
},
{
"name": "weekly-content-planning",
"schedule": "0 9 * * 1",
"description": "Monday 9 AM: check events, generate themed content",
"agent": "creator-agent"
},
{
"name": "website-monitor",
"schedule": "every 1 hour",
"description": "Hourly: check watched URLs for changes",
"agent": "liaison-agent"
},
{
"name": "monthly-analytics-review",
"schedule": "0 2 1 * *",
"description": "1st of month 2 AM: aggregate analytics, update memory",
"agent": "liaison-agent"
}
]
}
Load these into GoClaw on startup or via admin API.
Per-Client Scheduling
Add to customer_customer table:
ALTER TABLE customer_customer
ADD COLUMN scheduling_preferences JSONB DEFAULT '{
"cadence": "weekly",
"preferred_days": ["Monday", "Wednesday", "Friday"],
"max_unsent_posts": 3,
"auto_publish": false,
"timezone": "America/New_York"
}'::jsonb;
Max Unsent Posts Rule
Update generate_post endpoint:
// Before creating new post, check unsent count
const { count, error: countError } = await supabase
.from('customer_posts')
.select('*', { count: 'exact', head: true })
.eq('customer_id', customer_id)
.in('status', ['new', 'pending_review', 'approved']);
const preferences = customerData.scheduling_preferences || { max_unsent_posts: 3 };
if (count && count >= preferences.max_unsent_posts) {
return reply.code(429).send({
error: `Maximum of ${preferences.max_unsent_posts} unsent posts reached. Please approve or reject existing posts first.`
});
}
New Tooling Required
node-cronorfast-xml-parser— Scheduling and sitemap parsingcrypto(built-in) — Content hashing- Analytics API clients (Twitter, Mailchimp)
- Remove
breedependency
Verification Checklist
- [ ] Watched URLs are monitored and changes trigger notifications
- [ ] Sitemap parser discovers and watches all blog posts
- [ ] Calendar returns upcoming events filtered by industry
- [ ] Weekly content planning cron generates themed posts
- [ ] Analytics collectors fetch real data from platforms
- [ ] Monthly analytics cron updates GoClaw memory
- [ ] Creator-agent queries memory before generating content
- [ ] Bree is removed, all scheduling via GoClaw cron
- [ ] Max unsent posts rule prevents queue overflow
Next Steps
Proceed to Phase 5: Dashboard Evolution to transform the Vue dashboard into an admin/analytics panel.