Goal: Bring the four AI content features from toppingafrica into seojama, re-architected so the AI can auto-generate blog content per workspace.
Features being ported:
- AI Content Generator — generate a full blog post from a topic/idea
- AI SEO Analyzer — score & optimize a post across 5 dimensions
- Content Lab — AI-researched trending topic ideas, queued for generation
- Content Calendar — month view + autonomous agent that schedules & publishes
Locked decisions (2026-05-30)
- Providers: Anthropic + Perplexity. Claude for generation/SEO; Perplexity (
PERPLEXITY_API_KEY) for live trend research in Content Lab.- Hosting: Drafts-to-export for now. In Phase 3 (API build), posts will be scheduled and published to users' connected websites via an API — so design
BlogPoststatus/scheduling and the agent with remote publishing in mind, but don't host a public blog in SeoJama.- Post types: Articles + Listicles for MVP (drop quiz/poll/trivia/spotlight).
- Scope now: Plan only — no code yet; awaiting review of this doc.
Note: the original source's "Content Calendar" was Phase 4; the user's "Phase 3 API build" refers to the connected-website publishing API, which becomes the new Phase 5 below (remote publish). These are tracked together under §5.
1. Key architectural difference: single-tenant → per-workspace multi-tenant
This is the central re-architecture. The source (toppingafrica) is single-tenant:
- One global blog. Config lives in a global
config/blog.php+ a key-valueSettingtable. - Niches, tone, agent rules are global to the whole site.
Post,ContentIdea,SeoAnalysishave no tenant column.
SeoJama is multi-tenant around Workspace (app/Models/Workspace.php). One workspace = one website/brand. Therefore everything must be scoped by workspace_id:
| Source (global) | SeoJama (per-workspace) |
|---|---|
| posts table | blog_posts with workspace_id |
| content_ideas table | content_ideas with workspace_id |
| seo_analyses table | post_seo_analyses with workspace_id (denormalized) |
| settings key-value | workspace_content_settings row per workspace |
| global niches list | per-workspace niches, seeded from Workspace.niche + keywords |
| global agent config | per-workspace agent config row |
Why this matters for prompts: the source injects a hardcoded "Topping Africa" brand voice and a fixed list of categories/internal-link targets. In SeoJama those must come from the workspace's own profile — name, domain, url, niche, keywords, description (all already on the Workspace model). Each workspace gets content written about its niche, in its voice, linking to its own domain.
What SeoJama already has (great news)
The codebase is pre-wired for this "Phase 2":
Workspace.canGenerateBlogPost(): boolandWorkspace.dailyBlogPostLimit(): intalready exist (Workspace.php:208-219).config/subscription.phpalready hasdaily_blog_postslimits per tier (free 0, pro 1, agency 5).- Brand profile fields already on
Workspace:niche,keywords(JSON array),description,name,domain,url. .env.examplealready hasANTHROPIC_API_KEYandCLAUDE_MODEL=claude-sonnet-4-20250514.- Queue is
database-driven; jobs pattern established in app/Jobs/RunSeoScan.php. - Livewire 3 + Volt + Tailwind conventions established;
layouts.panel,x-components,#[Layout]/#[Title]attributes. - A
WorkspaceProfilerservice already derives a brand profile from scan data — reuse it to enrich prompts.
Provider decision — LOCKED: Anthropic + Perplexity
- Generation + SEO optimization → Anthropic Claude
claude-sonnet-4-20250514(already the configured default; matches existing stack). - Trend research (Content Lab) → Perplexity
sonar-proviaPERPLEXITY_API_KEY, for genuinely live/trending topics (Claude has no live web search). - Both go through dedicated client wrappers (
AnthropicClient,PerplexityClient) that log toAiUsageLog.
2. Source feature reference (what we're copying)
Condensed from toppingafrica. File paths are in that repo.
2.1 AI Content Generator
- Service:
app/Services/Blog/PostGeneratorService.phporchestrates: pick provider → build request → call AI → markdown→HTML safety net (ensureHtml) → truncate fields → compute reading time → suggest internal links → generate social text → map categories → returnPostDataDTO. - AI client:
app/Services/AI/OpenAIBlogService.php—Http::withHeaders(...)->timeout(180)->post(...),response_format: json_object, temperature 0.7. System prompt = brand voice + strict HTML/SEO/readability rules. User prompt = topic, length, tone, keyword, trending section, post-type section, and an exact JSON output schema (title, body, excerpt, meta_title, meta_description, focus_keyword, tags[], categories[], featured_image_query, type_data). - DTOs:
PostGenerationRequest(input),PostData(output). - Usage tracking:
TracksAiUsagetrait →AiUsageLogmodel (provider, model, tokens, cost, duration, success). - Post types: article/quiz/trivia/poll/listicle/etc. with type-specific
type_dataJSON.
2.2 AI SEO Analyzer
- Service:
app/Services/Blog/Seo/SeoIntelligenceService.phpruns 5 analyzers and weights them:- ContentQuality 30% · TechnicalSeo 25% · Readability 20% · UserEngagement 15% · OnPageElements 10%.
- Sub-analyzers compute keyword density/placement, meta lengths, header hierarchy, Flesch score, passive voice %, transition words, links, lists, images.
- Overall score → letter grade (A+…F).
- AI step: "Apply recommendations" calls Claude to rewrite meta + weave in keywords/links/readability fixes, returning
{meta_title, meta_description, optimized_content, content_changes}. - Model/table:
SeoAnalysis/seo_analyses— per-dimension scores + JSON details + strengths/improvements/applied_recommendations.
2.3 Content Lab
- Job:
app/Jobs/ResearchContentIdeasJob.php— per niche, calls Perplexity for 5-10 trending ideas; cleans up expired/dismissed; 7-day TTL. - Model/table:
ContentIdea/content_ideas— title, description, niche, suggested_{keyword,tone,length,post_type}, seo_score, status (pending/approved/generated/dismissed), expires_at, generated_post_id. - Livewire:
ContentLab.php— fetch-now, niche toggles, research context, idea browser (filter/search/sort), approve/generate/dismiss actions.
2.4 Content Calendar
- Livewire:
ContentCalendar.php— month grid, drag-to-reschedule, idea-picker modal, agent settings panel, dry-run preview, activity feed. - Agent job:
app/Jobs/DailyContentAgentJob.php+ContentAgentService.php— weighted idea selection (seo_score + approved bonus + emphasize/avoid topics + spotlight cadence + diversity & dedup penalties + historical performance), spreads N posts across a daily time window, dispatches generation jobs scheduled for target dates. - Generation job:
app/Jobs/GenerateContentIdeaPostJob.php— idea →PostGenerationRequest→PostGeneratorService→ createPost(draft) → sync categories/tags →idea->markAsGenerated($post->id). This is the auto-generation core.
3. Target design in SeoJama
3.1 New database tables (migrations)
All timestamped after the existing 2026_05_24_* migrations.
blog_posts
id, uuid (route key), workspace_id (FK, indexed), author_id (FK users, nullable)
title, slug (unique per workspace), content (longText, HTML), excerpt (text)
meta_title, meta_description, focus_keyword
og_meta (json), social_sharing (json)
status (enum: draft|scheduled|published), published_at, scheduled_at
reading_time (int), featured_image_url (nullable)
ai_provider, generation_params (json)
content_idea_id (FK nullable — provenance)
latest_seo_score (int nullable), latest_seo_grade (string nullable) // denormalized for list views
timestamps, softDeletes
unique(workspace_id, slug)
content_ideas
id, workspace_id (FK, indexed)
title, description, niche, suggested_keyword
suggested_tone, suggested_length, suggested_post_type
seo_score (int), relevance_score (nullable), source, source_url (nullable)
status (enum: pending|approved|generating|generated|dismissed)
dismissal_reason (json nullable), generated_post_id (FK blog_posts nullable)
generation_error (nullable), generation_started_by (FK users nullable)
researched_at, responded_at, expires_at
timestamps
index(workspace_id, status, expires_at)
post_seo_analyses
id, blog_post_id (FK, indexed), workspace_id (FK, indexed)
overall_score, grade
content_quality_score, technical_seo_score, readability_score,
user_engagement_score, on_page_elements_score
content_quality_details (json), technical_seo_details (json),
readability_details (json), user_engagement_details (json), on_page_elements_details (json)
strengths (json), improvements (json), critical_issues (json),
keyword_suggestions (json), content_recommendations (json), applied_recommendations (json)
word_count_at_analysis, focus_keyword_at_analysis
timestamps
workspace_content_settings (one row per workspace — replaces source's key-value Setting)
id, workspace_id (FK unique)
// Content Lab
research_niches (json — defaults from Workspace.niche + keywords), research_context (text), lab_enabled (bool)
// Content Agent (calendar)
agent_enabled (bool, default false)
posts_per_day_min (int, default 1), posts_per_day_max (int, default 1)
window_start_hour (int 0-23, default 8), window_end_hour (int, default 20)
min_gap_hours (float), max_gap_hours (float)
run_time (string "HH:MM")
min_seo_score (int, default 70), max_improve_attempts (int, default 2)
agent_instructions (text), avoid_topics (text), emphasize_topics (text)
last_run_at (datetime nullable), last_run_summary (json nullable)
default_tone, default_length // defaults for generation
timestamps
ai_usage_logs (per-workspace cost tracking — important for billing/abuse control)
id, workspace_id (FK nullable, indexed), user_id (nullable)
provider, model, feature (content-generation|seo-analysis|idea-research)
input_tokens, output_tokens, estimated_cost (decimal)
success (bool), duration_ms, error (text nullable), timestamps
3.2 New models (app/Models/)
BlogPost— belongsTo Workspace; hasMany PostSeoAnalysis; belongsTo ContentIdea;latestSeoAnalysis()hasOne latestOfMany; route keyuuid;booted()auto-uuid + per-workspace unique slug (mirror Workspace'suniqueSlug).ContentIdea— belongsTo Workspace; belongsTo BlogPost (generated_post_id); scopespending(),actionable()(pending/approved & not expired);markAsGenerated(),markAsGenerating(),dismiss().PostSeoAnalysis— belongsTo BlogPost + Workspace.WorkspaceContentSetting— belongsTo Workspace;forWorkspace(Workspace)accessor with sane defaults seeded from the workspace profile. AddWorkspace.contentSettings(): HasOne+blogPosts(): HasMany+contentIdeas(): HasMany.AiUsageLog— belongsTo Workspace.
3.3 New services (app/Services/Content/)
Namespace App\Services\Content (sibling to existing App\Services SEO code).
WorkspaceBrandContext— the key new abstraction. Given aWorkspace, produces the brand/voice/context block injected into every prompt: site name, domain, niche, keywords, description, and (optionally) signals from the latestSeoScanvia the existingWorkspaceProfiler. Replaces toppingafrica's hardcoded "Topping Africa" system-prompt block. Also produces the per-workspace internal-link base URL (https://{domain}).AnthropicClient— thin wrapper aroundHttpforPOST https://api.anthropic.com/v1/messages(headersx-api-key,anthropic-version, model from config,max_tokens, system + user message). Returns decoded JSON. Logs toAiUsageLogvia aTracksAiUsagetrait (port from source). Single place all features call.ContentGenerator— port ofPostGeneratorService+OpenAIBlogService.buildBlogPrompt. Takes aPostGenerationRequestDTO (now carryingWorkspace), builds system+user prompt usingWorkspaceBrandContext, callsAnthropicClient, runsensureHtmlmarkdown safety net (needsleague/commonmark— add via composer), truncates fields, computes reading time, returnsPostDataDTO.SeoIntelligence— port ofSeoIntelligenceService+ the 5 analyzers (these are pure PHP text analysis, copy nearly verbatim) + the Claude "apply recommendations" call. Persists aPostSeoAnalysisand updatesBlogPost.latest_seo_score/grade.PerplexityClient—Httpwrapper forPOST https://api.perplexity.ai/chat/completions(sonar-pro), used byIdeaResearcheronly. Includes the source's control-character/JSON-escaping safety for Perplexity responses. Logs toAiUsageLog.IdeaResearcher— port of research logic, callingPerplexityClientwith the workspace's niches/keywords (fromWorkspaceBrandContext) for live trending topics.ContentAgent— port ofContentAgentServiceweighted-selection + scheduling, scoped to one workspace's ideas andWorkspaceContentSetting.- Helper services to port:
ReadingTimeCalculator,InternalLinkSuggester(point links at the workspace's own domain), optionallySocialSharingGenerator.
3.4 New DTOs (app/DataTransferObjects/Content/)
PostGenerationRequest(workspace, topic, length, tone, targetKeyword, postType, additionalContext[]).PostData(all generated fields).- Port from source, drop creator-specific fields (no
Creatormodel here) — replace "featured creators" with nothing for MVP.
3.5 New jobs (app/Jobs/)
GenerateBlogPost— the auto-generation core. Signature(int $workspaceId, ?int $ideaId, ?int $userId, array $overrides = []). Loads workspace, enforces daily quota (Workspace.dailyBlogPostLimit()vs posts created today), builds request, callsContentGenerator, createsBlogPost(draft or scheduled), optionally runsSeoIntelligence, links theContentIdea.timeout=300, tries=1(match source + existingRunSeoScan).ResearchContentIdeas— per workspace; callsIdeaResearcher; cleans expired ideas; writescontent_ideas. Dispatchable on-demand from Content Lab and on schedule.RunContentAgent— per workspace; callsContentAgentto select ideas, then dispatchesGenerateBlogPost(withscheduled_at) for each. Writeslast_run_summary.
3.6 Scheduling (routes/console.php / bootstrap schedule)
ResearchContentIdeasper active workspace withlab_enabled— daily (e.g., 5:00).RunContentAgentper active workspace withagent_enabled— hourly check that fires each workspace's configuredrun_time.- Guard both behind
Workspace.canGenerateBlogPost()andis_active.
3.7 Livewire components (app/Livewire/Content/) + views (resources/views/livewire/content/)
All under the existing workspace middleware group (auth + workspace), owner/admin gated, using #[Layout('layouts.panel')].
Content\Generator— manual generation form (topic, length, tone, keyword, type) → dispatchesGenerateBlogPost, polls for the draft (reuse thewire:pollpattern fromDashboard).Content\Posts\Index+Content\Posts\Edit— list/edit blog posts; showlatest_seo_grade; trigger SEO analysis; WYSIWYG body editor (Tailwind + a lightweight editor like a textarea + preview for MVP).Content\SeoPanel— runsSeoIntelligence, shows the 5 sub-scores + grade + recommendations + "Apply recommendations" (port ofSeoAnalysisPanel).Content\Lab— niche toggles (seeded from workspace), research context, "Research now" button (dispatchResearchContentIdeas), idea browser with approve/generate/dismiss (port ofContentLab).Content\Calendar— month grid, idea-picker modal, agent settings panel, dry-run preview, activity feed (port ofContentCalendar).
3.8 Routes (routes/web.php)
Add inside the existing auth + workspace group:
/workspace/content → Content\Posts\Index
/workspace/content/generate → Content\Generator
/workspace/content/posts/{post} → Content\Posts\Edit (scoped by workspace)
/workspace/content/lab → Content\Lab
/workspace/content/calendar → Content\Calendar
Public (later): /{workspace:slug}/blog/{post:slug} if you want published posts served by SeoJama. Likely posts are exported/published to the workspace's own site rather than hosted here — confirm intent (§9). MVP: keep posts internal (draft store + copy/export).
3.9 Config + env
- New
config/content.php:providers(anthropic primary),lengths,tones,seoweights & thresholds (portseo-intelligence.php), idea TTL days, default agent settings. .env: reuseANTHROPIC_API_KEY,CLAUDE_MODEL; addPERPLEXITY_API_KEYonly if going with Perplexity research.- Composer: add
league/commonmark(markdown→HTML safety net) and optionallymews/purifieror similar to sanitize AI-produced HTML before storing.
3.10 Authorization, quota & safety
- Policies:
BlogPostPolicy,ContentIdeaPolicy— only workspace owners/admins;BlogPost.workspace_idmust equal current workspace. Add a global scope or always query through$currentWorkspace->blogPosts(). - Quota:
GenerateBlogPostre-checksdailyBlogPostLimit()at run time (not just UI) — countblog_postscreated today for the workspace. Free tier = 0 → feature hidden/blocked. - Cost guard:
AiUsageLogper workspace; optionally a monthly token ceiling per tier. - HTML sanitization: purify AI
contentbefore persisting (XSS — content is user-facing). - Prompt-injection note: workspace
description/keywordsare user-controlled and flow into prompts; keep them in the user message, never let them override system instructions.
4. Mapping table (source → target)
| toppingafrica | seojama |
|---|---|
| config/blog.php, Setting table | config/content.php + workspace_content_settings |
| Post / posts | BlogPost / blog_posts (+workspace_id) |
| ContentIdea / content_ideas | ContentIdea / content_ideas (+workspace_id) |
| SeoAnalysis / seo_analyses | PostSeoAnalysis / post_seo_analyses (+workspace_id) |
| AiUsageLog | AiUsageLog (+workspace_id) |
| PostGeneratorService | Services\Content\ContentGenerator |
| OpenAIBlogService / PerplexityBlogService | Services\Content\AnthropicClient (+ optional Perplexity for research) |
| hardcoded "Topping Africa" system prompt | Services\Content\WorkspaceBrandContext (per-workspace) |
| SeoIntelligenceService + 5 analyzers | Services\Content\SeoIntelligence + analyzers (verbatim) |
| ResearchContentIdeasJob | Jobs\ResearchContentIdeas (per workspace) |
| GenerateContentIdeaPostJob | Jobs\GenerateBlogPost (per workspace, quota-gated) |
| DailyContentAgentJob / ContentAgentService | Jobs\RunContentAgent / Services\Content\ContentAgent |
| ContentLab Livewire | Content\Lab Livewire |
| ContentCalendar Livewire | Content\Calendar Livewire |
| SeoAnalysisPanel Livewire | Content\SeoPanel Livewire |
| PostGenerator Livewire | Content\Generator Livewire |
| Spatie Media (featured image) | featured_image_url string (MVP); add media lib later |
| Creator spotlight features | dropped for MVP (no creator model) |
5. Phased delivery
Phase 0 — Foundations (1 PR)
- Migrations (all 5 tables) + models + relationships on
Workspace. config/content.php, composerleague/commonmark.AnthropicClient+TracksAiUsage+AiUsageLog.WorkspaceBrandContext.
Phase 1 — Manual generation (MVP) (1 PR)
- DTOs,
ContentGenerator,ReadingTimeCalculator, HTML sanitizer. - Post types limited to article + listicle (prompt + UI).
GenerateBlogPostjob with quota enforcement.Content\Generator+Content\Posts\Index/EditLivewire + views + routes.- Outcome: owner enters a topic → gets a draft blog post scoped to their workspace. This alone satisfies "auto generate blog content per workspace."
Phase 2 — SEO Analyzer (1 PR)
- Port 5 analyzers +
SeoIntelligence+ Claude apply-recommendations. PostSeoAnalysispersistence +Content\SeoPanel+ grade badges in list.
Phase 3 — Content Lab (1 PR)
PerplexityClient+IdeaResearcher(live trends),ResearchContentIdeasjob,Content\LabUI.- Approve/generate/dismiss → reuse
GenerateBlogPost.
Phase 4 — Content Calendar + autonomous agent (1 PR)
ContentAgentselection logic,RunContentAgentjob, scheduler entries.Content\Calendarmonth view, agent settings, dry-run, activity feed.- Outcome: fully autonomous per-workspace daily content generation & scheduling (drafts/scheduled).
Phase 5 — Connected-website publishing API (the user's "Phase 3 API")
- API + integration to publish/schedule generated posts to users' connected external websites (e.g. WordPress REST, webhook, or a SeoJama publish endpoint).
BlogPost.status = scheduled+scheduled_atdrive a publish job that pushes to the connected site at the due time; record remote post ID/URL.- Per-workspace site connection credentials (new
workspace_site_connectionstable). Design tables/status now (Phase 0) to avoid rework.
Phase 6 — Polish
- Media library / image source for featured images, per-tier monthly token caps, fuller test suite.
6. Testing
- Feature tests mirroring
tests/Feature/SmokePageTest.php: each new route loads for an owner, 403 for non-members. GenerateBlogPostquota test: free tier blocked, pro tier 1/day enforced.AnthropicClientwithHttp::fake()— assert prompt contains workspace brand context, response parses,AiUsageLogwritten.- Analyzer unit tests on fixed HTML (Flesch/keyword-density determinism).
- Multi-tenant isolation test: workspace A cannot see/edit workspace B's posts/ideas.
7. Risks & decisions
- Live trends: Claude can't search the web → Content Lab "trending" is weaker without Perplexity. Decide MVP scope.
- HTML safety: AI content is rendered → must sanitize.
- Cost/abuse: per-workspace token logging + tier caps recommended before enabling for paid users.
- Where posts live: hosted in SeoJama vs exported to the workspace's real site. Affects whether we need public blog routes/themes.
- Slug uniqueness: per-workspace, not global.
8. Effort estimate (rough)
- Phase 0: ~0.5 day · Phase 1: ~1.5 days · Phase 2: ~1.5 days (analyzers port well) · Phase 3: ~1 day · Phase 4: ~2 days · Phase 5: variable. ~1.5 weeks for full parity MVP.
9. Decisions & remaining questions
Decided (2026-05-30):
- Providers: Anthropic + Perplexity.
- Hosting: drafts-to-export now; remote publish to connected sites in Phase 5.
- Post types: articles + listicles.
- Featured images: skip for MVP (
featured_image_urlonly).
Still open (can answer at Phase 1 / Phase 5):
- Who can generate: workspace owners only, or owners + admins (members read-only)? (default assumption: owners + admins)
- Quota: keep the existing
daily_blog_poststier limits (free 0 / pro 1 / agency 5) as-is? (default assumption: yes) - Phase 5 connected sites: which platforms first — WordPress REST API, generic webhook, or a custom SeoJama publish endpoint?