backboard-cmo 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ .env
2
+ .venv
3
+ uv.lock
@@ -0,0 +1,5 @@
1
+ BACKBOARD_API_KEY=
2
+ BACKBOARD_ASSISTANT_CONTENT=
3
+ BACKBOARD_ASSISTANT_COMPETITOR=
4
+ BACKBOARD_ASSISTANT_BRAND=
5
+ BACKBOARD_ASSISTANT_AUDIT=
@@ -0,0 +1,6 @@
1
+ .env
2
+ .venv
3
+ uv.lock
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: backboard-cmo
3
+ Version: 0.1.1
4
+ Summary: AI CMO Terminal — Backboard agents for traffic and users
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: backboard-sdk>=1.5.0
7
+ Requires-Dist: fastapi>=0.115.0
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: pydantic>=2.0
10
+ Requires-Dist: python-dotenv>=1.0
11
+ Requires-Dist: sse-starlette>=2.0
12
+ Requires-Dist: uvicorn[standard]>=0.32.0
@@ -0,0 +1,216 @@
1
+ <p align="center">
2
+ <img src="web/public/openokara.png" alt="Backboard CMO" width="480" />
3
+ </p>
4
+
5
+ <h1 align="center">Backboard CMO</h1>
6
+
7
+ <p align="center">
8
+ <strong>Drop in a URL. Get a Chief Marketing Officer.</strong>
9
+ <br />
10
+ AI agents that audit your site, map your competitors, and find your brand voice — in seconds.
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://backboard.io"><img src="https://img.shields.io/badge/Powered%20by-Backboard.io-6366f1?style=flat-square" alt="Powered by Backboard" /></a>
15
+ <img src="https://img.shields.io/badge/Python-3.11%2B-3776AB?style=flat-square&logo=python&logoColor=white" alt="Python 3.11+" />
16
+ <img src="https://img.shields.io/badge/Next.js-15-black?style=flat-square&logo=next.js" alt="Next.js 15" />
17
+ <img src="https://img.shields.io/badge/FastAPI-0.115-009688?style=flat-square&logo=fastapi&logoColor=white" alt="FastAPI" />
18
+ <img src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat-square" alt="MIT License" />
19
+ </p>
20
+
21
+ ---
22
+
23
+ ## What is this?
24
+
25
+ Hiring a CMO costs $200k/year. Good ones take months to ramp up.
26
+
27
+ **Backboard CMO does it in 60 seconds.**
28
+
29
+ Type in any URL. Four AI agents fire up in parallel — they read your site, search the web, and hand back:
30
+
31
+ - A **site audit** with real scores (performance, SEO, accessibility, best practices)
32
+ - A **competitor landscape** with categories and pricing
33
+ - A **brand voice profile** so your copy sounds like *you*
34
+ - A ranked list of **SEO fixes** with step-by-step instructions
35
+ - An **AI chat** interface so you can keep drilling into next steps
36
+
37
+ No prompt engineering. No setup. Just a URL.
38
+
39
+ ---
40
+
41
+ ## Demo
42
+
43
+ > Type a URL → watch the terminal run → read your marketing brief
44
+
45
+ ```
46
+ > Checking what content and documents you have...
47
+ > Content and documents summarized.
48
+ > Now let me check out your competition...
49
+ > Searching: yoursite.com competitors alternative
50
+ > Evaluating competitor's positioning strategy...
51
+ > Competitor analysis complete.
52
+ > Now let me figure out your brand voice...
53
+ > Brand voice guide ready
54
+ > Running website audit...
55
+ > Scanning page structure and metadata...
56
+ > Page speed and core web vitals measured
57
+ > Found 7 SEO optimization opportunities (score: 64/100)
58
+ ```
59
+
60
+ ---
61
+
62
+ ## Features
63
+
64
+ | | |
65
+ |---|---|
66
+ | **Website Audit** | Performance, SEO, Accessibility, and Best Practices scores with specific failing checks and how-to-fix guides |
67
+ | **Competitor Intel** | Direct vs. secondary competitors with pricing — sourced live from the web |
68
+ | **Brand Voice** | 2–3 sentence brand profile your team can actually use |
69
+ | **SEO Fix Queue** | Ranked issues (Critical → High → Medium) with numbered remediation steps |
70
+ | **AI CMO Chat** | Ask follow-up questions about your audit — it remembers context |
71
+ | **Live Terminal** | Real-time SSE stream so you see agents working, not a spinner |
72
+
73
+ ---
74
+
75
+ ## Stack
76
+
77
+ | Layer | Tech |
78
+ |---|---|
79
+ | AI Agents | [Backboard.io](https://backboard.io) — threads, assistants, web search |
80
+ | API | FastAPI + Uvicorn, Server-Sent Events |
81
+ | Frontend | Next.js 15, Tailwind CSS |
82
+ | Runtime | Python 3.11+ via `uv`, Node 18+ |
83
+ | Schemas | Pydantic v2 |
84
+
85
+ ---
86
+
87
+ ## Quick Start
88
+
89
+ ### 1. Clone & install
90
+
91
+ ```bash
92
+ git clone https://github.com/Backboard-io/openokara.git
93
+ cd backboard-cmo
94
+
95
+ # Python deps
96
+ uv sync
97
+
98
+ # Frontend deps
99
+ cd web && npm install && cd ..
100
+ ```
101
+
102
+ ### 2. Configure `.env`
103
+
104
+ ```bash
105
+ cp .env.example .env
106
+ ```
107
+
108
+ ```env
109
+ BACKBOARD_API_KEY=your_key_here
110
+
111
+ # Assistant IDs — created once, reused forever (never delete these)
112
+ BACKBOARD_ASSISTANT_CONTENT=asst_...
113
+ BACKBOARD_ASSISTANT_COMPETITOR=asst_...
114
+ BACKBOARD_ASSISTANT_BRAND=asst_...
115
+ BACKBOARD_ASSISTANT_AUDIT=asst_...
116
+ ```
117
+
118
+ > Get a free Backboard API key at [backboard.io](https://backboard.io). Create your four assistants once and paste the IDs here.
119
+
120
+ ### 3. Run
121
+
122
+ ```bash
123
+ ./start.sh
124
+ ```
125
+
126
+ API → `http://localhost:8000`
127
+ App → `http://localhost:3000`
128
+
129
+ ---
130
+
131
+ ## How It Works
132
+
133
+ ```
134
+ User enters URL
135
+
136
+
137
+ FastAPI /runs ──────────────────────────────────────────────┐
138
+ │ │
139
+ │ SSE stream → browser terminal │
140
+ ▼ │
141
+ ┌─────────────────────────────────────────────────────────┐ │
142
+ │ Orchestrator Pipeline │ │
143
+ │ │ │
144
+ │ 1. Content Agent → summarize site + docs │ │
145
+ │ 2. Competitor Agent → find rivals, pricing │ │
146
+ │ 3. Brand Agent → extract voice & tone │ │
147
+ │ 4. Audit Agent → scores + SEO checks + fixes │ │
148
+ └─────────────────────────────────────────────────────────┘ │
149
+ │ │
150
+ └──────────────── RunStatus (Pydantic) ─────────────────┘
151
+
152
+ GET /runs/{id}
153
+
154
+ Next.js dashboard
155
+ (audit · competitors · chat)
156
+ ```
157
+
158
+ Each agent is a [Backboard](https://backboard.io) assistant with live web search enabled — no stale training data, no hallucinated competitors.
159
+
160
+ ---
161
+
162
+ ## Project Structure
163
+
164
+ ```
165
+ backboard-cmo/
166
+ ├── app/
167
+ │ ├── main.py # FastAPI routes + SSE
168
+ │ ├── orchestrator.py # Agent pipeline
169
+ │ └── schemas.py # Pydantic models
170
+ ├── web/
171
+ │ ├── src/app/ # Next.js pages
172
+ │ └── src/components/ # UI components
173
+ ├── scripts/ # Smoke tests
174
+ ├── pyproject.toml # uv/hatch config
175
+ └── start.sh # Clean start script
176
+ ```
177
+
178
+ ---
179
+
180
+ ## API
181
+
182
+ ```http
183
+ POST /runs body: { website_url } → { run_id }
184
+ GET /runs/{id} → RunStatus JSON
185
+ GET /runs/{id}/stream → SSE terminal lines
186
+ POST /runs/{id}/chat body: { message } → { reply }
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Contributing
192
+
193
+ Pull requests welcome. Please keep it surgical — one concern per PR.
194
+
195
+ - Backend logic lives in `app/` only. No logic in the frontend.
196
+ - Pydantic models for all data shapes.
197
+ - `uv` for Python deps, `npm` for frontend.
198
+ - Never delete Backboard assistants — they carry persistent data.
199
+
200
+ ---
201
+
202
+ ## Built with
203
+
204
+ - [Backboard.io](https://backboard.io) — AI agent infrastructure
205
+ - [FastAPI](https://fastapi.tiangolo.com)
206
+ - [Next.js](https://nextjs.org)
207
+ - [Tailwind CSS](https://tailwindcss.com)
208
+ - [shadcn/ui](https://ui.shadcn.com)
209
+
210
+ ---
211
+
212
+ <p align="center">
213
+ Made with ☕ and too many competitor analyses.
214
+ <br />
215
+ <a href="https://backboard.io">backboard.io</a>
216
+ </p>
@@ -0,0 +1 @@
1
+ # AI CMO Terminal API
@@ -0,0 +1,119 @@
1
+ """FastAPI app: runs API and SSE stream. All logic in API."""
2
+
3
+ import asyncio
4
+ import json
5
+ import uuid
6
+ from contextlib import asynccontextmanager
7
+
8
+ try:
9
+ from dotenv import load_dotenv
10
+ load_dotenv()
11
+ except ImportError:
12
+ pass
13
+
14
+ from fastapi import FastAPI, HTTPException
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from sse_starlette.sse import EventSourceResponse
17
+
18
+ from app import orchestrator
19
+ from pydantic import BaseModel as PydanticBaseModel
20
+
21
+ from app.schemas import AnalyticsMetric, ChatMessage, FeedItem, RunCreate, RunResponse, RunStatus
22
+
23
+
24
+ @asynccontextmanager
25
+ async def lifespan(app: FastAPI):
26
+ yield
27
+
28
+
29
+ app = FastAPI(title="AI CMO Terminal API", lifespan=lifespan)
30
+
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ allow_origins=["http://localhost:9000", "http://127.0.0.1:9000"],
34
+ allow_credentials=True,
35
+ allow_methods=["*"],
36
+ allow_headers=["*"],
37
+ )
38
+
39
+
40
+ @app.post("/api/runs", response_model=RunResponse)
41
+ async def create_run(body: RunCreate):
42
+ run_id = uuid.uuid4().hex[:12]
43
+ orchestrator._runs[run_id] = RunStatus(
44
+ run_id=run_id,
45
+ status="pending",
46
+ website_url=body.website_url,
47
+ credits=2000,
48
+ analytics_overview=[
49
+ AnalyticsMetric(key="performance", label="Performance", score=44, tone="red"),
50
+ AnalyticsMetric(key="accessibility", label="Accessibility", score=78, tone="yellow"),
51
+ AnalyticsMetric(key="best_practices", label="Best Practices", score=73, tone="yellow"),
52
+ AnalyticsMetric(key="seo", label="SEO", score=92, tone="green"),
53
+ ],
54
+ feed_items=[
55
+ FeedItem(id="mentions", title="Found 2 mentions", status="Suggested"),
56
+ FeedItem(id="seo-geo", title="SEO + GEO Recommendations", status="Found 2 issues"),
57
+ FeedItem(id="meta-tags", title="Add Critical Meta Tags", status="High priority"),
58
+ FeedItem(id="schema", title="Implement Structured Data", status="In progress"),
59
+ ],
60
+ chat_status="loading",
61
+ )
62
+ orchestrator._terminal_queues[run_id] = asyncio.Queue()
63
+ asyncio.create_task(orchestrator.run_orchestrator(run_id, body.website_url))
64
+ return RunResponse(run_id=run_id)
65
+
66
+
67
+ @app.get("/api/runs/{run_id}", response_model=RunStatus)
68
+ async def get_run(run_id: str):
69
+ run = orchestrator.get_run(run_id)
70
+ if not run:
71
+ raise HTTPException(status_code=404, detail="Run not found")
72
+ return run
73
+
74
+
75
+ async def stream_generator(run_id: str):
76
+ run = orchestrator.get_run(run_id)
77
+ if not run:
78
+ yield {"data": '{"line": "> Run not found."}'}
79
+ return
80
+ q = orchestrator.get_terminal_queue(run_id)
81
+ try:
82
+ while True:
83
+ try:
84
+ line = await asyncio.wait_for(q.get(), timeout=30.0)
85
+ yield {"data": json.dumps({"line": line})}
86
+ except asyncio.TimeoutError:
87
+ yield {"data": ""}
88
+ r = orchestrator.get_run(run_id)
89
+ if r and r.status in ("completed", "failed"):
90
+ break
91
+ except asyncio.CancelledError:
92
+ pass
93
+
94
+
95
+ @app.get("/api/runs/{run_id}/stream")
96
+ async def stream_run(run_id: str):
97
+ if not orchestrator.get_run(run_id):
98
+ raise HTTPException(status_code=404, detail="Run not found")
99
+ return EventSourceResponse(stream_generator(run_id))
100
+
101
+
102
+ class ChatRequest(PydanticBaseModel):
103
+ message: str
104
+
105
+
106
+ @app.post("/api/runs/{run_id}/chat")
107
+ async def chat(run_id: str, body: ChatRequest):
108
+ run = orchestrator.get_run(run_id)
109
+ if not run:
110
+ raise HTTPException(status_code=404, detail="Run not found")
111
+ run.chat_messages.append(ChatMessage(role="user", content=body.message))
112
+ reply = await orchestrator.chat_reply(run_id, body.message)
113
+ run.chat_messages.append(ChatMessage(role="assistant", content=reply))
114
+ return {"messages": run.chat_messages}
115
+
116
+
117
+ def run():
118
+ import uvicorn
119
+ uvicorn.run("app.main:app", host="0.0.0.0", port=9000, reload=True)
@@ -0,0 +1,323 @@
1
+ """Run orchestration: Backboard assistants as agents, SSE terminal lines."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ import re
7
+ import uuid
8
+ from typing import Optional
9
+
10
+ import httpx
11
+ from backboard import BackboardClient
12
+
13
+ _BACKBOARD_BASE = "https://app.backboard.io/api"
14
+
15
+
16
+ async def _add_message_with_search(thread_id: str, content: str) -> str:
17
+ """Call the Backboard message endpoint directly with web_search enabled."""
18
+ api_key = os.getenv("BACKBOARD_API_KEY", "")
19
+ async with httpx.AsyncClient(timeout=120) as hx:
20
+ resp = await hx.post(
21
+ f"{_BACKBOARD_BASE}/threads/{thread_id}/messages",
22
+ headers={"X-API-Key": api_key},
23
+ data={
24
+ "content": content,
25
+ "stream": "true",
26
+ "memory": "off",
27
+ "web_search": "Auto",
28
+ "llm_provider": "openai",
29
+ "model_name": "gpt-5.4",
30
+ },
31
+ )
32
+ resp.raise_for_status()
33
+
34
+ body = ""
35
+ for line in resp.text.splitlines():
36
+ line = line.strip()
37
+ if line.startswith("data:"):
38
+ line = line[len("data:"):].strip()
39
+ if not line:
40
+ continue
41
+ try:
42
+ data = json.loads(line)
43
+ except json.JSONDecodeError:
44
+ continue
45
+ if data.get("type") == "run_ended":
46
+ body = data.get("final_content", "")
47
+
48
+ print(body)
49
+ return body
50
+
51
+ from app.schemas import (
52
+ AnalyticsMetric,
53
+ AuditCheck,
54
+ ChatMessage,
55
+ CompetitorItem,
56
+ CompetitorReport,
57
+ CompetitorReportRow,
58
+ DocumentItem,
59
+ FeedItem,
60
+ RunStatus,
61
+ )
62
+
63
+
64
+ _AUDIT_PROMPT = """\
65
+ Search web for {url}
66
+ Audit the website {url}. Respond with ONLY a raw JSON object — no markdown fences, \
67
+ no explanatory text before or after. Use exactly these keys:
68
+ {{
69
+ "performance_score": <integer 0-100>,
70
+ "accessibility_score": <integer 0-100>,
71
+ "best_practices_score": <integer 0-100>,
72
+ "seo_score": <integer 0-100>,
73
+ "summary": "<2-3 sentence plain-text summary>",
74
+ "passed_checks": [{{"name": "...", "description": "1 sentence note", "value": "<ultra-short status, ≤20 chars, e.g. 42/42 or 18 int / 6 ext or Yes or OK>"}}],
75
+ "failed_checks": [{{"name": "...", "description": "1 sentence explaining the issue", "value": "<ultra-short status, ≤20 chars, e.g. Missing or No or None or 0/5>", "priority": "critical|high|medium", "how_to_fix": "numbered step-by-step instructions to fix this specific issue, 3-5 steps"}}]
76
+ }}
77
+ Check: page structure, meta title/description, H1, image alt text, HTTPS, mobile-friendliness, \
78
+ Core Web Vitals estimates, structured data, canonical URL, robots.txt, sitemap, broken links, \
79
+ accessibility basics. List every check — passing ones in passed_checks, failing ones in failed_checks.\
80
+ """
81
+
82
+
83
+ def _score_tone(score: int) -> str:
84
+ if score >= 90:
85
+ return "green"
86
+ if score >= 70:
87
+ return "yellow"
88
+ return "red"
89
+
90
+
91
+ def _extract_json(text: str) -> dict:
92
+ """Extract and parse the first JSON object found in text."""
93
+ try:
94
+ return json.loads(text)
95
+ except json.JSONDecodeError:
96
+ pass
97
+ match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
98
+ if match:
99
+ return json.loads(match.group(1))
100
+ start, end = text.find("{"), text.rfind("}")
101
+ if start != -1 and end != -1:
102
+ return json.loads(text[start : end + 1])
103
+ raise ValueError("No JSON object found in audit response")
104
+
105
+
106
+ # In-memory run state: run_id -> RunStatus and queue of terminal lines for SSE
107
+ _runs: dict[str, RunStatus] = {}
108
+ _terminal_queues: dict[str, asyncio.Queue[str]] = {}
109
+
110
+
111
+ def _get_client() -> BackboardClient:
112
+ api_key = os.getenv("BACKBOARD_API_KEY")
113
+ if not api_key:
114
+ raise ValueError("BACKBOARD_API_KEY not set")
115
+ return BackboardClient(api_key=api_key)
116
+
117
+
118
+ def _assistant_id(key: str) -> str:
119
+ val = os.getenv(key)
120
+ if not val:
121
+ raise ValueError(f"{key} not set in .env")
122
+ return val
123
+
124
+
125
+ async def _emit(run_id: str, line: str) -> None:
126
+ """Emit a terminal line for this run (prefix with > if not already)."""
127
+ if not line.strip().startswith(">"):
128
+ line = f"> {line}"
129
+ q = _terminal_queues.get(run_id)
130
+ if q:
131
+ await q.put(line)
132
+
133
+
134
+ _chat_threads: dict[str, str] = {}
135
+
136
+
137
+ def get_run(run_id: str) -> Optional[RunStatus]:
138
+ return _runs.get(run_id)
139
+
140
+
141
+ async def chat_reply(run_id: str, message: str) -> str:
142
+ """Send a chat message for this run and return the assistant reply."""
143
+ client = _get_client()
144
+ audit_id = _assistant_id("BACKBOARD_ASSISTANT_AUDIT")
145
+ run = _runs.get(run_id)
146
+
147
+ if run_id not in _chat_threads:
148
+ thread = await client.create_thread(audit_id)
149
+ _chat_threads[run_id] = str(thread.thread_id)
150
+
151
+ return (await _add_message_with_search(_chat_threads[run_id], message))[:1000]
152
+
153
+
154
+ def get_terminal_queue(run_id: str) -> asyncio.Queue[str]:
155
+ if run_id not in _terminal_queues:
156
+ _terminal_queues[run_id] = asyncio.Queue()
157
+ return _terminal_queues[run_id]
158
+
159
+
160
+ async def run_orchestrator(run_id: str, website_url: str) -> None:
161
+ """Run the agent pipeline and update run state. Emits terminal lines to queue."""
162
+ client = _get_client()
163
+ try:
164
+ content_id = _assistant_id("BACKBOARD_ASSISTANT_CONTENT")
165
+ competitor_id = _assistant_id("BACKBOARD_ASSISTANT_COMPETITOR")
166
+ brand_id = _assistant_id("BACKBOARD_ASSISTANT_BRAND")
167
+ audit_id = _assistant_id("BACKBOARD_ASSISTANT_AUDIT")
168
+ except ValueError as e:
169
+ _runs[run_id] = RunStatus(
170
+ run_id=run_id,
171
+ status="failed",
172
+ website_url=website_url,
173
+ project_description=str(e),
174
+ )
175
+ await _emit(run_id, f"Error: {e}")
176
+ return
177
+
178
+ run = _runs.get(run_id)
179
+ if run:
180
+ run.status = "running"
181
+ run.chat_status = "loading"
182
+
183
+ await _emit(run_id, "Checking what content and documents you have...")
184
+ content_thread = await client.create_thread(content_id)
185
+ content_prompt = f"Search web for {website_url}. Visit and summarize the website {website_url}. What content and key documents or product information does it offer? Be concise."
186
+ product_info = (await _add_message_with_search(str(content_thread.thread_id), content_prompt))[:2000]
187
+ if run:
188
+ run.project_description = product_info or f"Site: {website_url}"
189
+ run.documents = [DocumentItem(id="product", title="Product Information")]
190
+ run.feed_items = [
191
+ FeedItem(id="mentions", title="Found 2 mentions", status="Suggested"),
192
+ FeedItem(id="seo-geo", title="SEO + GEO Recommendations", status="Found 2 issues"),
193
+ FeedItem(id="meta-tags", title="Add Critical Meta Tags", status="High priority"),
194
+ FeedItem(id="schema", title="Implement Structured Data", status="In progress"),
195
+ ]
196
+ await _emit(run_id, "Content and documents summarized.")
197
+
198
+ await _emit(run_id, "Now let me check out your competition...")
199
+ competitor_thread = await client.create_thread(competitor_id)
200
+ domain = website_url.replace("https://", "").replace("http://", "").split("/")[0]
201
+ search_query = f"{domain} competitors alternative privacy AI chat"
202
+ await _emit(run_id, f"Searching: {search_query}")
203
+ comp_prompt = f"Search web for {website_url}. Find direct and secondary competitors to the website {website_url}. For each competitor list: name, category (Direct or Secondary), and pricing (e.g. Free / $X/mo). Format as a short executive summary then a clear list."
204
+ comp_text = await _add_message_with_search(str(competitor_thread.thread_id), comp_prompt)
205
+ await _emit(run_id, "Evaluating competitor's positioning strategy...")
206
+ await _emit(run_id, "Competitor analysis complete.")
207
+
208
+ competitors: list[CompetitorItem] = []
209
+ report_rows: list[CompetitorReportRow] = []
210
+ for i, line in enumerate(comp_text.split("\n")):
211
+ line = line.strip()
212
+ if not line or line.startswith("#"):
213
+ continue
214
+ if "|" in line and i > 0:
215
+ parts = [p.strip() for p in line.split("|") if p.strip()]
216
+ if len(parts) >= 3:
217
+ report_rows.append(
218
+ CompetitorReportRow(
219
+ competitor=parts[0],
220
+ category=parts[1],
221
+ pricing=parts[2],
222
+ )
223
+ )
224
+ competitors.append(CompetitorItem(id=parts[0].lower().replace(" ", "-"), name=parts[0]))
225
+ elif " - " in line and ("Direct" in line or "Secondary" in line or "Free" in line or "$" in line):
226
+ parts = line.split(" - ", 1)
227
+ if len(parts) == 2:
228
+ name = parts[0].strip().strip("-*")
229
+ rest = parts[1].strip()
230
+ cat = "Direct" if "direct" in rest.lower() else "Secondary"
231
+ report_rows.append(CompetitorReportRow(competitor=name, category=cat, pricing=rest))
232
+ competitors.append(CompetitorItem(id=name.lower().replace(" ", "-"), name=name))
233
+
234
+ if run:
235
+ run.competitors = competitors or [CompetitorItem(id="c1", name="See report")]
236
+ run.documents = (run.documents or []) + [
237
+ DocumentItem(id="competitor", title="Competitor Analysis"),
238
+ ]
239
+ from datetime import datetime
240
+ run.competitor_report = CompetitorReport(
241
+ title="Competitor Analysis",
242
+ date=datetime.utcnow().strftime("As of %B %d, %Y"),
243
+ executive_summary=comp_text[:1500] if comp_text else "No summary.",
244
+ rows=report_rows if report_rows else [
245
+ CompetitorReportRow(competitor="—", category="—", pricing=comp_text[:200] or "—"),
246
+ ],
247
+ )
248
+ brand_prompt = f"Search web for {website_url}. Based on the website {website_url} and this product summary, describe the brand voice in 2-3 sentences: {product_info[:500]}"
249
+ await _emit(run_id, "Now let me figure out your brand voice...")
250
+ brand_thread = await client.create_thread(brand_id)
251
+ brand_snippet = (await _add_message_with_search(str(brand_thread.thread_id), brand_prompt))[:500]
252
+ await _emit(run_id, "Brand voice guide ready")
253
+ if run:
254
+ run.brand_voice_snippet = brand_snippet
255
+ run.documents = (run.documents or []) + [DocumentItem(id="brand", title="Brand Voice")]
256
+
257
+ await _emit(run_id, "Running website audit...")
258
+ await _emit(run_id, "Scanning page structure and metadata...")
259
+ audit_thread = await client.create_thread(audit_id)
260
+ audit_raw = await _add_message_with_search(
261
+ str(audit_thread.thread_id),
262
+ _AUDIT_PROMPT.format(url=website_url),
263
+ )
264
+ await _emit(run_id, "Page speed and core web vitals measured")
265
+
266
+ if run:
267
+ try:
268
+ audit_data = _extract_json(audit_raw)
269
+ perf = int(audit_data.get("performance_score", 50))
270
+ a11y = int(audit_data.get("accessibility_score", 50))
271
+ bp = int(audit_data.get("best_practices_score", 50))
272
+ seo = int(audit_data.get("seo_score", 50))
273
+ run.analytics_overview = [
274
+ AnalyticsMetric(key="performance", label="Performance", score=perf, tone=_score_tone(perf)),
275
+ AnalyticsMetric(key="accessibility", label="Accessibility", score=a11y, tone=_score_tone(a11y)),
276
+ AnalyticsMetric(key="best_practices", label="Best Practices", score=bp, tone=_score_tone(bp)),
277
+ AnalyticsMetric(key="seo", label="SEO", score=seo, tone=_score_tone(seo)),
278
+ ]
279
+ run.passed_checks = [
280
+ AuditCheck(name=c["name"], description=c.get("description", ""), value=c.get("value", ""), passed=True)
281
+ for c in audit_data.get("passed_checks", [])
282
+ if isinstance(c, dict) and c.get("name")
283
+ ]
284
+ failed = [
285
+ c for c in audit_data.get("failed_checks", [])
286
+ if isinstance(c, dict) and c.get("name")
287
+ ]
288
+ run.failed_checks = [
289
+ AuditCheck(
290
+ name=c["name"],
291
+ description=c.get("description", ""),
292
+ value=c.get("value", ""),
293
+ passed=False,
294
+ how_to_fix=c.get("how_to_fix", ""),
295
+ )
296
+ for c in failed
297
+ ]
298
+ run.feed_items = [
299
+ FeedItem(
300
+ id=c["name"].lower().replace(" ", "-")[:40],
301
+ title=c["name"],
302
+ status=f"{c.get('priority', 'medium').title()} priority",
303
+ description=c.get("description", ""),
304
+ how_to_fix=c.get("how_to_fix", ""),
305
+ action_label="Fix",
306
+ )
307
+ for c in failed
308
+ ]
309
+ run.audit_summary = audit_data.get("summary", audit_raw[:1500])
310
+ await _emit(run_id, f"Found {len(failed)} SEO optimization opportunities (score: {seo}/100)")
311
+ except Exception:
312
+ run.audit_summary = audit_raw[:1500]
313
+
314
+ if run:
315
+ run.status = "completed"
316
+ run.project_name = domain.replace(".", " ").title() if domain else "Project"
317
+ run.chat_status = "ready"
318
+ run.chat_messages = [
319
+ ChatMessage(
320
+ role="assistant",
321
+ content="I finished the audit and competitor scan. Want me to draft a launch plan or prioritize the SEO fixes?",
322
+ )
323
+ ]
@@ -0,0 +1,84 @@
1
+ """Pydantic schemas for API request/response and run state."""
2
+
3
+ from pydantic import BaseModel, Field, HttpUrl
4
+ from typing import Optional
5
+
6
+
7
+ class RunCreate(BaseModel):
8
+ website_url: str = Field(..., description="Website URL to analyze")
9
+
10
+
11
+ class RunResponse(BaseModel):
12
+ run_id: str
13
+
14
+
15
+ class DocumentItem(BaseModel):
16
+ id: str
17
+ title: str
18
+
19
+
20
+ class CompetitorItem(BaseModel):
21
+ id: str
22
+ name: str
23
+
24
+
25
+ class CompetitorReportRow(BaseModel):
26
+ competitor: str
27
+ category: str
28
+ pricing: str
29
+
30
+
31
+ class CompetitorReport(BaseModel):
32
+ title: str = "Competitor Analysis"
33
+ date: str = ""
34
+ executive_summary: str = ""
35
+ rows: list[CompetitorReportRow] = Field(default_factory=list)
36
+
37
+
38
+ class AnalyticsMetric(BaseModel):
39
+ key: str
40
+ label: str
41
+ score: int = Field(..., ge=0, le=100)
42
+ tone: str = "neutral"
43
+
44
+
45
+ class AuditCheck(BaseModel):
46
+ name: str
47
+ description: str
48
+ value: str = ""
49
+ passed: bool = True
50
+ how_to_fix: str = ""
51
+
52
+
53
+ class FeedItem(BaseModel):
54
+ id: str
55
+ title: str
56
+ status: str
57
+ description: str = ""
58
+ how_to_fix: str = ""
59
+ action_label: str = "Fix"
60
+
61
+
62
+ class ChatMessage(BaseModel):
63
+ role: str
64
+ content: str
65
+
66
+
67
+ class RunStatus(BaseModel):
68
+ run_id: str
69
+ status: str = Field(..., description="pending | running | completed | failed")
70
+ website_url: str = ""
71
+ project_name: str = ""
72
+ project_description: str = ""
73
+ documents: list[DocumentItem] = Field(default_factory=list)
74
+ competitors: list[CompetitorItem] = Field(default_factory=list)
75
+ competitor_report: Optional[CompetitorReport] = None
76
+ brand_voice_snippet: str = ""
77
+ audit_summary: str = ""
78
+ analytics_overview: list[AnalyticsMetric] = Field(default_factory=list)
79
+ passed_checks: list[AuditCheck] = Field(default_factory=list)
80
+ failed_checks: list[AuditCheck] = Field(default_factory=list)
81
+ feed_items: list[FeedItem] = Field(default_factory=list)
82
+ chat_status: str = "loading"
83
+ chat_messages: list[ChatMessage] = Field(default_factory=list)
84
+ credits: int = 2000
@@ -0,0 +1,34 @@
1
+ [project]
2
+ name = "backboard-cmo"
3
+ version = "0.1.1"
4
+ description = "AI CMO Terminal — Backboard agents for traffic and users"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "fastapi>=0.115.0",
8
+ "uvicorn[standard]>=0.32.0",
9
+ "backboard-sdk>=1.5.0",
10
+ "httpx>=0.27.0",
11
+ "pydantic>=2.0",
12
+ "sse-starlette>=2.0",
13
+ "python-dotenv>=1.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ cmo-api = "app.main:run"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["app"]
25
+
26
+ [tool.hatch.build.targets.sdist]
27
+ exclude = ["web/", ".dockerignore", "Dockerfile", "start.sh"]
28
+
29
+ [tool.uv]
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pillow>=12.1.1",
34
+ ]
@@ -0,0 +1,46 @@
1
+ """One-off: create the four Backboard assistants. Print IDs for .env."""
2
+
3
+ import asyncio
4
+ import os
5
+
6
+ from backboard import BackboardClient
7
+
8
+
9
+ async def main():
10
+ api_key = os.getenv("BACKBOARD_API_KEY")
11
+ if not api_key:
12
+ print("Set BACKBOARD_API_KEY")
13
+ return
14
+ client = BackboardClient(api_key=api_key)
15
+
16
+ assistants = [
17
+ (
18
+ "BACKBOARD_ASSISTANT_CONTENT",
19
+ "AI CMO Content Agent",
20
+ "You are a content analyst. Given a website URL, summarize its content and key documents or product information. Be concise and factual.",
21
+ ),
22
+ (
23
+ "BACKBOARD_ASSISTANT_COMPETITOR",
24
+ "AI CMO Competitor Agent",
25
+ "You are a competitive analyst. Given a website, find direct and secondary competitors. For each: name, category (Direct/Secondary), pricing. Use web search for current data. Output a short executive summary then a clear list or table.",
26
+ ),
27
+ (
28
+ "BACKBOARD_ASSISTANT_BRAND",
29
+ "AI CMO Brand Voice Agent",
30
+ "You infer brand voice from a website and its copy. Describe tone, vocabulary, and positioning in 2-3 sentences.",
31
+ ),
32
+ (
33
+ "BACKBOARD_ASSISTANT_AUDIT",
34
+ "AI CMO Audit Agent",
35
+ "You audit websites: structure, metadata, and when possible Core Web Vitals or performance. Be concise.",
36
+ ),
37
+ ]
38
+
39
+ print("# Add these to your .env (do not delete these assistants):")
40
+ for env_key, name, system_prompt in assistants:
41
+ a = await client.create_assistant(name=name, system_prompt=system_prompt)
42
+ print(f"{env_key}={a.assistant_id}")
43
+
44
+
45
+ if __name__ == "__main__":
46
+ asyncio.run(main())