S SeoJama

AI Content Generation — Implementation Plan for SeoJama

Updated June 18, 2026

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:

  1. AI Content Generator — generate a full blog post from a topic/idea
  2. AI SEO Analyzer — score & optimize a post across 5 dimensions
  3. Content Lab — AI-researched trending topic ideas, queued for generation
  4. Content Calendar — month view + autonomous agent that schedules & publishes

Locked decisions (2026-05-30)

  1. Providers: Anthropic + Perplexity. Claude for generation/SEO; Perplexity (PERPLEXITY_API_KEY) for live trend research in Content Lab.
  2. 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 BlogPost status/scheduling and the agent with remote publishing in mind, but don't host a public blog in SeoJama.
  3. Post types: Articles + Listicles for MVP (drop quiz/poll/trivia/spotlight).
  4. 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-value Setting table.
  • Niches, tone, agent rules are global to the whole site.
  • Post, ContentIdea, SeoAnalysis have 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 profilename, 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(): bool and Workspace.dailyBlogPostLimit(): int already exist (Workspace.php:208-219).
  • config/subscription.php already has daily_blog_posts limits per tier (free 0, pro 1, agency 5).
  • Brand profile fields already on Workspace: niche, keywords (JSON array), description, name, domain, url.
  • .env.example already has ANTHROPIC_API_KEY and CLAUDE_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 WorkspaceProfiler service 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-pro via PERPLEXITY_API_KEY, for genuinely live/trending topics (Claude has no live web search).
  • Both go through dedicated client wrappers (AnthropicClient, PerplexityClient) that log to AiUsageLog.

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.php orchestrates: pick provider → build request → call AI → markdown→HTML safety net (ensureHtml) → truncate fields → compute reading time → suggest internal links → generate social text → map categories → return PostData DTO.
  • AI client: app/Services/AI/OpenAIBlogService.phpHttp::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: TracksAiUsage trait → AiUsageLog model (provider, model, tokens, cost, duration, success).
  • Post types: article/quiz/trivia/poll/listicle/etc. with type-specific type_data JSON.

2.2 AI SEO Analyzer

  • Service: app/Services/Blog/Seo/SeoIntelligenceService.php runs 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 → PostGenerationRequestPostGeneratorService → create Post (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 key uuid; booted() auto-uuid + per-workspace unique slug (mirror Workspace's uniqueSlug).
  • ContentIdea — belongsTo Workspace; belongsTo BlogPost (generated_post_id); scopes pending(), 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. Add Workspace.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).

  • WorkspaceBrandContextthe key new abstraction. Given a Workspace, produces the brand/voice/context block injected into every prompt: site name, domain, niche, keywords, description, and (optionally) signals from the latest SeoScan via the existing WorkspaceProfiler. Replaces toppingafrica's hardcoded "Topping Africa" system-prompt block. Also produces the per-workspace internal-link base URL (https://{domain}).
  • AnthropicClient — thin wrapper around Http for POST https://api.anthropic.com/v1/messages (headers x-api-key, anthropic-version, model from config, max_tokens, system + user message). Returns decoded JSON. Logs to AiUsageLog via a TracksAiUsage trait (port from source). Single place all features call.
  • ContentGenerator — port of PostGeneratorService + OpenAIBlogService.buildBlogPrompt. Takes a PostGenerationRequest DTO (now carrying Workspace), builds system+user prompt using WorkspaceBrandContext, calls AnthropicClient, runs ensureHtml markdown safety net (needs league/commonmark — add via composer), truncates fields, computes reading time, returns PostData DTO.
  • SeoIntelligence — port of SeoIntelligenceService + the 5 analyzers (these are pure PHP text analysis, copy nearly verbatim) + the Claude "apply recommendations" call. Persists a PostSeoAnalysis and updates BlogPost.latest_seo_score/grade.
  • PerplexityClientHttp wrapper for POST https://api.perplexity.ai/chat/completions (sonar-pro), used by IdeaResearcher only. Includes the source's control-character/JSON-escaping safety for Perplexity responses. Logs to AiUsageLog.
  • IdeaResearcher — port of research logic, calling PerplexityClient with the workspace's niches/keywords (from WorkspaceBrandContext) for live trending topics.
  • ContentAgent — port of ContentAgentService weighted-selection + scheduling, scoped to one workspace's ideas and WorkspaceContentSetting.
  • Helper services to port: ReadingTimeCalculator, InternalLinkSuggester (point links at the workspace's own domain), optionally SocialSharingGenerator.

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 Creator model 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, calls ContentGenerator, creates BlogPost (draft or scheduled), optionally runs SeoIntelligence, links the ContentIdea. timeout=300, tries=1 (match source + existing RunSeoScan).
  • ResearchContentIdeas — per workspace; calls IdeaResearcher; cleans expired ideas; writes content_ideas. Dispatchable on-demand from Content Lab and on schedule.
  • RunContentAgent — per workspace; calls ContentAgent to select ideas, then dispatches GenerateBlogPost (with scheduled_at) for each. Writes last_run_summary.

3.6 Scheduling (routes/console.php / bootstrap schedule)

  • ResearchContentIdeas per active workspace with lab_enabled — daily (e.g., 5:00).
  • RunContentAgent per active workspace with agent_enabled — hourly check that fires each workspace's configured run_time.
  • Guard both behind Workspace.canGenerateBlogPost() and is_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) → dispatches GenerateBlogPost, polls for the draft (reuse the wire:poll pattern from Dashboard).
  • Content\Posts\Index + Content\Posts\Edit — list/edit blog posts; show latest_seo_grade; trigger SEO analysis; WYSIWYG body editor (Tailwind + a lightweight editor like a textarea + preview for MVP).
  • Content\SeoPanel — runs SeoIntelligence, shows the 5 sub-scores + grade + recommendations + "Apply recommendations" (port of SeoAnalysisPanel).
  • Content\Lab — niche toggles (seeded from workspace), research context, "Research now" button (dispatch ResearchContentIdeas), idea browser with approve/generate/dismiss (port of ContentLab).
  • Content\Calendar — month grid, idea-picker modal, agent settings panel, dry-run preview, activity feed (port of ContentCalendar).

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, seo weights & thresholds (port seo-intelligence.php), idea TTL days, default agent settings.
  • .env: reuse ANTHROPIC_API_KEY, CLAUDE_MODEL; add PERPLEXITY_API_KEY only if going with Perplexity research.
  • Composer: add league/commonmark (markdown→HTML safety net) and optionally mews/purifier or similar to sanitize AI-produced HTML before storing.

3.10 Authorization, quota & safety

  • Policies: BlogPostPolicy, ContentIdeaPolicy — only workspace owners/admins; BlogPost.workspace_id must equal current workspace. Add a global scope or always query through $currentWorkspace->blogPosts().
  • Quota: GenerateBlogPost re-checks dailyBlogPostLimit() at run time (not just UI) — count blog_posts created today for the workspace. Free tier = 0 → feature hidden/blocked.
  • Cost guard: AiUsageLog per workspace; optionally a monthly token ceiling per tier.
  • HTML sanitization: purify AI content before persisting (XSS — content is user-facing).
  • Prompt-injection note: workspace description/keywords are 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, composer league/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).
  • GenerateBlogPost job with quota enforcement.
  • Content\Generator + Content\Posts\Index/Edit Livewire + 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.
  • PostSeoAnalysis persistence + Content\SeoPanel + grade badges in list.

Phase 3 — Content Lab (1 PR)

  • PerplexityClient + IdeaResearcher (live trends), ResearchContentIdeas job, Content\Lab UI.
  • Approve/generate/dismiss → reuse GenerateBlogPost.

Phase 4 — Content Calendar + autonomous agent (1 PR)

  • ContentAgent selection logic, RunContentAgent job, scheduler entries.
  • Content\Calendar month 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_at drive 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_connections table). 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.
  • GenerateBlogPost quota test: free tier blocked, pro tier 1/day enforced.
  • AnthropicClient with Http::fake() — assert prompt contains workspace brand context, response parses, AiUsageLog written.
  • 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_url only).

Still open (can answer at Phase 1 / Phase 5):

  1. Who can generate: workspace owners only, or owners + admins (members read-only)? (default assumption: owners + admins)
  2. Quota: keep the existing daily_blog_posts tier limits (free 0 / pro 1 / agency 5) as-is? (default assumption: yes)
  3. Phase 5 connected sites: which platforms first — WordPress REST API, generic webhook, or a custom SeoJama publish endpoint?