DEV Community

Shivam Kumar
Shivam Kumar

Posted on

Prompts as Contracts: How the SEO Master Agent Turns an LLM Into a Typed Function

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"),
}
Enter fullscreen mode Exit fullscreen mode

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."""
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

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)