Skip to content

Repository files navigation

content-app

Live Demo

Agentic content generation: a LangGraph pipeline that pulls brand voice context from brandvoice-mcp (stdio), drafts with a ReAct agent (Claude tool-use loop), checks alignment, and retries with feedback until the score clears the threshold or max retries.

Fully deployed on EC2. CLI + LangGraph + SQLite + pytest; FastAPI with run registry, SSE (/api/runs/.../events), snapshots, and per-node node_start / node_end events; and a React + TypeScript + Vite UI (react-router) with three areas: Pipeline (run form, React Flow visualizer, live SSE), Brand dashboard (voice profile, samples ingest, guidelines via brandvoice-mcp), and Run history (recent runs from SQLite). The draft node now runs a ReAct tool-use loop — the agent can call web_search (Tavily) and get_writing_examples before submitting the final post via draft_content. Deployed via Docker (multi-stage build, nginx + uvicorn via supervisord) with a GitHub Actions CI/CD pipeline that auto-deploys on push to main.


Requirements

  • Python 3.12+
  • uv for installs and running commands
  • Node.js 20+ and npm (for the frontend/ dev server and build)
  • uvx brandvoice-mcp available on your machine (same as brandvoice-mcp’s docs)
  • .env with API keys (see Setup)

Project layout

content-app/
├── pyproject.toml
├── uv.lock
├── .env.example
├── README.md
├── Dockerfile                   # Multi-stage build (Node → Python/nginx)
├── docker-compose.yml           # Single-container deployment (port 80, SQLite volume)
├── nginx.conf                   # Static SPA + /api/ proxy to uvicorn, SSE-safe
├── supervisord.conf             # Manages nginx + uvicorn processes inside container
├── .github/workflows/deploy.yml # CI/CD: push to main → SSH into EC2 → docker compose up --build
├── frontend/                    # React + Vite UI (Tailwind, @xyflow/react)
│   ├── package.json
│   ├── vite.config.ts           # dev proxy: /api → http://localhost:8000
│   ├── index.html
│   ├── public/
│   └── src/
│       ├── App.tsx                # Routes + nav
│       ├── pages/                 # PipelinePage, BrandPage, HistoryPage
│       ├── components/            # RunForm, PipelineVisualizer, ContentPanel, …
│       ├── hooks/
│       ├── api/
│       │   ├── runs.ts
│       │   └── brand.ts           # overview, profile, samples, guidelines
│       └── types/
├── src/
│   ├── main.py                  # FastAPI entry point for Docker (src.main:app)
│   └── content_app/
│       ├── __init__.py
│       ├── cli.py                 # Click CLI → run_pipeline_blocking
│       ├── runner.py              # Queue + history + start_run_async / blocking run
│       ├── config.py              # Pydantic Settings (.env)
│       ├── api/
│       │   ├── app.py             # FastAPI factory + /health
│       │   ├── schemas.py         # Request/response models
│       │   ├── routes_runs.py     # POST/GET runs, list runs, SSE events
│       │   └── routes_brand.py    # brandvoice-mcp proxy (overview, profile, samples, guidelines)
│       ├── agent/
│       │   ├── executor.py        # run_draft_agent — ReAct tool-use loop
│       │   ├── tools.py           # AGENT_TOOLS definitions (web_search, get_writing_examples, draft_content)
│       │   └── handlers.py        # handle_web_search (Tavily), build_get_writing_examples_handler
│       ├── graph/
│       │   ├── state.py           # ContentState (TypedDict + reducers)
│       │   ├── nodes.py           # create_nodes(..., emit=...); generate_draft uses agent loop w/ fallback
│       │   └── builder.py         # build_graph(..., emit=...)
│       ├── providers/
│       │   ├── protocol.py        # LLMProvider
│       │   ├── claude.py          # ClaudeProvider (Anthropic)
│       │   └── openai.py          # OpenAIProvider stub (Phase 3+)
│       ├── mcp/
│       │   └── brandvoice.py      # BrandvoiceClient (stdio MCP)
│       └── db/
│           └── sqlite.py          # init_db, save_run, get_run, list_runs
└── tests/
    ├── test_api.py
    ├── test_graph.py
    ├── test_runner.py
    ├── test_providers.py
    ├── test_mcp.py
    ├── test_db.py
    ├── test_agent_executor.py
    ├── test_agent_handlers.py
    └── test_config.py

The console script content-app points at content_app.cli:main.


Setup

uv sync
cp .env.example .env

Edit .env and set:

Variable Purpose
ANTHROPIC_API_KEY Claude in content-app and (forwarded) brandvoice-mcp
OPENAI_API_KEY Required. Forwarded to brandvoice-mcp for embeddings (Chroma / RAG)

Optional settings (see .env.example): BRANDVOICE_COMMAND, BRANDVOICE_ARGS, DEFAULT_MODEL, MAX_RETRIES, ALIGNMENT_THRESHOLD, DATABASE_URL, LOG_LEVEL, TAVILY_API_KEY (enables web_search in the draft agent).

Voice profile: Ingest writing samples in brandvoice-mcp for meaningful get_voice_context / check_alignment. Without a profile, alignment may be weak or generic; the MCP client normalizes check_alignment JSON (alignment_score / drift_flags vs score / feedback) and falls back safely on parse errors.


Run the CLI

Use uv run so the editable package and venv are used.

uv run content-app --topic "AI trends" --platform linkedin --tone professional

--platform must be one of: linkedin, twitter, blog.

Runs persist to SQLite (default sqlite:///content_app.db).


Run the HTTP API

uv run uvicorn src.main:app --reload --host 0.0.0.0 --port 8000
Endpoint Description
GET /health Liveness
POST /api/runs Body: {"topic","platform","tone"}201 + { "run_id" } (pipeline runs in a background asyncio task)
GET /api/runs List recent runs from SQLite (?limit=20 default)
GET /api/runs/{run_id}/events SSE stream (polling-based, replays history then tails live); JSON lines in data: with run_started, node_start / node_end, run_complete / run_failed
GET /api/runs/{run_id}/stream SSE stream (queue-based, real-time with 30 s heartbeat); closes after final event
GET /api/runs/{run_id} Snapshot: phase, result (if any), and events (history)
GET /api/brand/overview Voice profile + samples in one MCP session (used by the Brand page)
GET /api/brand/profile Voice profile (brandvoice-mcp)
GET /api/brand/samples List ingested samples
POST /api/brand/samples Ingest writing samples (JSON body: content)
POST /api/brand/samples/delete Delete samples: body {"sample_ids":["…"]} or {"all": true} (proxies brandvoice-mcp delete_samples)
PUT /api/brand/guidelines Update brand guidelines (JSON body: guidelines)

The SSE handler replays from an in-memory event history (and short-polls until phase is complete or failed). A per-run asyncio.Queue remains for compatibility with the runner; subscribers should use the SSE endpoint rather than reading the queue directly.


Run the frontend

Start the API on port 8000 first (see above). In another terminal:

cd frontend
npm install
npm run dev

Open the URL Vite prints (default http://localhost:5173). Browser calls to /api/... are proxied to http://localhost:8000, so the UI and API share the same origin during development.

Pages: Pipeline (/) — start runs and watch the graph + SSE; Brand (/brand) — profile, samples, guidelines; History (/history) — past runs from the API.

Script Description
npm run dev Vite dev server with HMR
npm run build Typecheck + production build to frontend/dist/
npm run preview Serve the production build locally
npm run lint ESLint

Tests

uv run pytest
  • Unit tests mock BrandvoiceClient and Claude where needed; no real subprocess or API calls.
  • The integration marker is registered for future opt-in tests (uv run pytest -m integration).

Architecture

User input (topic, platform, tone)
  → fetch_voice_context   (brandvoice-mcp over stdio)
  → generate_draft        (ReAct tool-use loop via ClaudeProvider.generate_with_tools)
      ├─ [optional] web_search          → Tavily (requires TAVILY_API_KEY)
      ├─ [optional] get_writing_examples → brandvoice-mcp RAG
      └─ draft_content                  → final post (exits loop)
      fallback: plain provider.generate() if agent fails or provider lacks tool support
  → check_alignment       (brandvoice-mcp; normalized to score + feedback)
  → score ≥ threshold? → done
  → else retry (inject feedback + previous draft) until max_retries

API runs use the same graph with an optional emit callback so each node emits node_start / node_end (plus run_started / run_complete from the runner). The frontend consumes POST /api/runs, GET /api/runs/{id}, and the SSE stream for the pipeline page; GET /api/runs and GET /api/brand/overview (and related brand routes) power history and the brand dashboard.


Deployment

The app ships as a single Docker container running nginx + uvicorn via supervisord.

nginx (port 80)
  ├─ static assets  →  /usr/share/nginx/html  (React SPA, built in Docker)
  └─ /api/*         →  127.0.0.1:8000         (uvicorn, SSE-safe: no buffering, 600 s timeout)

Build and run locally:

docker compose up --build

The app_data Docker volume persists SQLite across restarts (DATABASE_URL=sqlite:////data/content_app.db).

CI/CD (GitHub Actions): every push to main SSH's into the EC2 host, runs git pull + docker compose up -d --build, and prunes old images. Secrets required: EC2_HOST, EC2_SSH_KEY.

About

Agentic content generation pipeline — ReAct agent (Claude tool_use) + LangGraph orchestration + brandvoice-mcp + React Flow real-time visualizer

Resources

Stars

8 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages