SEO Master Agent runs five SEO workflows — on-page optimization, link-building outreach, Google Business Profile copy, content repurposing, geo SEO — through one multi-LLM gateway (OpenAI, Anthropic, Gemini, GLM, Ollama). FastAPI backend, Next.js frontend, async jobs. The design decision I want to talk about is in get_system_prompt(): every task type forces the LLM to return only a raw JSON object with fixed keys. No markdown fences. That's a prompt written as a type signature.
The five task types
TASK_TYPES = ("onpage", "offpage", "gmb", "content", "geo")
REQUIRED_INPUTS = {
"onpage": ("url", "keyword"),
"offpage": ("niche",),
"gmb": ("business_name", "services"),
"content": ("blog_url",),
"geo": ("city", "service"),
}
Missing inputs → 422, not a hallucinated guess. Each task's system prompt then pins the output shape. The geo prompt, for example, demands exactly geo_title, geo_meta_description, local_landmarks_to_mention, localized_body_copy. The on-page prompt demands optimized_title, optimized_meta_description, h1_suggestion, keyword_density_analysis, content_recommendations. The LLM stops being a chatbot and becomes a function with a typed return — downstream code and the frontend can render each workflow's output as real UI instead of dumping a transcript.
Multi-LLM routing with honest key handling
def resolve_llm_config(model_id, per_request_keys):
"""Per-request headers win; env is the fallback.
Per-request LLM keys are used only in-memory and never persisted."""
Prefix-based routing (gpt, claude, gemini, zhipu/glm, ollama) through LiteLLM. One caveat the README states plainly: POST /api/settings/keys stores keys in local SQLite as a self-host convenience — flagged as not-a-secrets-manager, env vars recommended for production. That kind of honesty in docs is rarer than it should be.
The lifecycle
POST /api/execute → validate → enqueue (202 + task_id) → background worker fetches the site (for onpage/content), builds the task prompt, calls the LLM → ExecutionLog moves pending → running → success/error → GET /api/tasks/{task_id} returns the structured result. SSRF guard on the fetcher, fail-closed X-API-Key auth (server refuses to boot without SEO_API_KEY), structured JSON errors, no stack traces.
The smoke test boots its own server
tests/smoke_test.py spawns uvicorn itself on port 18099 with a hermetic env (inherited LLM keys stripped), waits for /health, then asserts the contract: 401 without key, 422 on bad task types/inputs/URLs, valid execute → task_id, task retrieval, structured errors. python tests/smoke_test.py, exit 0 = pass, no manual setup.
Run it
cp .env.example .env # set SEO_API_KEY to a generated secret
pip install -r backend/requirements.txt
uvicorn backend.server:app --port 8000
# fire a task:
curl -X POST localhost:8000/api/execute -H "X-API-Key: $SEO_API_KEY" \
-d '{"task_type":"geo","inputs":{"city":"Chennai","service":"Dentist"},"model_id":"gpt-4o"}'
Two honest caveats from reading the code: "rank tracking" is in the repo description but has no workflow yet, and the GMB task generates copy — it doesn't post to Google. Both are stated, not hidden. Working, self-hostable, worth a look.
Top comments (0)