# christianohle-cowork-pipeline-seo

> **Was das hier ist:** Eine vollständige Build-Anleitung für Claude im Cowork-Modus. Wenn du diese Datei in einen leeren Cowork-Ordner ziehst und Claude bittest, sie umzusetzen, baut er dir die komplette SEO-Content-Pipeline mit Dashboard nach.
>
> **Was du am Ende hast:** Astro-Site mit Content-Collections, lokalen Node-API-Server (Port 4322), Web-Dashboard (Port 4321) mit 7-Step-Pipeline (Discovery → Research → Schreiben → Fakten-Check → SEO-Check → Bild → Veröffentlichen), Anthropic-API-Anbindung, Datei-basierte Persistenz.
>
> **Geschätzte Bauzeit für Cowork:** 15–25 Minuten je nach Modell und Internet.

---

## So benutzt du diese Datei

1. **Leeren Ordner anlegen** auf deinem Rechner. z.B. `~/projekte/mein-content-system/`.
2. **Claude Desktop öffnen → Cowork-Mode → diesen Ordner auswählen**.
3. **Diese `.md`-Datei in den Chat ziehen** (oder als Anhang einfügen).
4. **Schreiben:** „Bau mir das Projekt aus dieser Anleitung. Halt dich strikt an die Reihenfolge der Schritte und sag mir, wenn du eine Entscheidung triffst, die hier nicht spezifiziert ist."
5. **Nach dem Build:** `.env` mit deinem Anthropic-Key füllen (optional Replicate-Token für Bild-Generierung), `npm run server` und `npm run dev` in zwei Terminals — fertig.

Die Anleitung ist auf dem Stand von April 2026. Falls Claude bei Library-Versionen zickt, gib ihm grünes Licht für die jeweils aktuelle Stable-Version — die Architektur bleibt gleich.

---

## 1 — Tech-Stack & Entscheidungen

| Layer | Wahl | Warum |
|---|---|---|
| Framework | Astro 6 (Static Output) | Schnell, content-first, MDX nativ, einfaches Deploy auf Cloudflare Pages |
| Styling | Tailwind v4 (CSS-based `@theme`) | Keine Config-Datei nötig, Tokens direkt in `global.css` |
| Content | MDX + Content Collections (Zod-Schema) | Typed Frontmatter, Code-Blöcke, JSX im Artikel |
| API-Server | Native Node `http`-Modul (Port 4322) | Zero-Dependency, einfach zu hosten, keine Express-Abhängigkeit |
| Persistenz | JSON-Files in `.runs/<run-id>.json` | Reicht für Solo-Usage, kein DB-Setup |
| LLM | `@anthropic-ai/sdk` (Claude Sonnet 4.6) | Beste Quality/Cost für Long-Form-DE-Content |
| Frontend-Updates | Polling alle 1500ms | Robust, simpel, reicht für Step-States |
| Deploy | Cloudflare Pages | Free Tier, schnell, gutes DX |

**Wichtige Constraints, die Claude beachten muss:**

- Astro 6 fängt `/api/*` vor jedem Vite-Proxy ab → API-Calls direkt an `http://localhost:4322` schicken (CORS aktiv).
- Tailwind v4 lebt komplett in `src/styles/global.css` mit `@theme`-Block, **keine** `tailwind.config.mjs`.
- Trailing-Slashes in Astro-Config: `"ignore"` (sonst 404s auf interne Links).
- `<details>`/`<summary>` in MDX akzeptiert **keine** `class`-Attribute → Code-Block-Akkordeons via JS in der Layout-Komponente, nicht inline.
- MDX 3 interpretiert `<http...>` als JSX-Tag → URLs in Artikeln immer in Inline-Code-Backticks.

---

## 2 — Projekt-Struktur (komplett)

```
christianohle/
├─ astro.config.mjs
├─ package.json
├─ tsconfig.json
├─ .env.example
├─ .gitignore
├─ README.md
├─ public/
│  ├─ robots.txt
│  ├─ favicon.svg
│  └─ images/
│     └─ logo-horizontal.png  (Platzhalter — User legt eigenes Logo rein)
├─ src/
│  ├─ content.config.ts
│  ├─ env.d.ts
│  ├─ styles/
│  │  └─ global.css
│  ├─ layouts/
│  │  ├─ BaseLayout.astro
│  │  └─ ArticlePage.astro
│  ├─ components/
│  │  ├─ Header.astro
│  │  ├─ Footer.astro
│  │  ├─ Logo.astro
│  │  ├─ Eyebrow.astro
│  │  ├─ AuthorBox.astro
│  │  ├─ TableOfContents.astro
│  │  ├─ ToolCard.astro
│  │  ├─ SponsorSlot.astro
│  │  └─ dashboard/
│  │     ├─ RunStatusCard.astro
│  │     ├─ StepCard.astro
│  │     ├─ KeywordSuggestionCard.astro
│  │     ├─ LiveLogTerminal.astro
│  │     └─ StatusPill.astro
│  ├─ pages/
│  │  ├─ index.astro
│  │  ├─ ueber.astro
│  │  ├─ newsletter.astro
│  │  ├─ datenschutz.astro
│  │  ├─ impressum.astro
│  │  ├─ tools/index.astro
│  │  ├─ news/index.astro
│  │  ├─ agents/index.astro
│  │  ├─ agents/[slug].astro
│  │  ├─ lokal/index.astro
│  │  ├─ lokal/[slug].astro
│  │  ├─ anleitungen/index.astro
│  │  ├─ anleitungen/[slug].astro
│  │  ├─ dashboard/index.astro
│  │  ├─ dashboard/new.astro
│  │  └─ dashboard/runs/[id].astro
│  ├─ scripts/
│  │  └─ dashboard-client.ts
│  └─ content/
│     ├─ agents/      (MDX-Artikel)
│     ├─ lokal/       (MDX-Artikel)
│     ├─ anleitungen/ (MDX-Artikel)
│     └─ tools/       (Markdown-Einträge)
├─ scripts/
│  ├─ server.mjs
│  ├─ cli.mjs
│  ├─ commands/
│  │  ├─ new.mjs
│  │  ├─ drafts.mjs
│  │  ├─ publish.mjs
│  │  └─ list.mjs
│  └─ lib/
│     ├─ anthropic.mjs
│     ├─ pillars.mjs
│     ├─ slugify.mjs
│     ├─ prompt.mjs
│     ├─ frontmatter.mjs
│     ├─ runs.mjs
│     └─ steps/
│        ├─ index.mjs
│        ├─ discovery.mjs
│        ├─ research.mjs
│        ├─ write.mjs
│        ├─ fact-check.mjs
│        ├─ seo.mjs
│        ├─ image.mjs
│        └─ publish.mjs
└─ .runs/  (gitignored — Run-Persistence)
```

---

## 3 — Setup-Sequenz (Claude führt das aus)

### 3.1 Astro-Projekt initialisieren

```bash
npm create astro@latest . -- --template minimal --typescript strict --install --no-git --skip-houston
npx astro add tailwind --yes
npx astro add mdx --yes
npx astro add sitemap --yes
npm install @anthropic-ai/sdk replicate dotenv
npm install -D @types/node
```

### 3.2 `astro.config.mjs`

```js
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
import mdx from "@astrojs/mdx";
import sitemap from "@astrojs/sitemap";

export default defineConfig({
  site: "https://DEIN-PROJEKT.de",
  trailingSlash: "ignore",
  build: { format: "directory" },
  integrations: [mdx(), sitemap()],
  vite: { plugins: [tailwindcss()] },
  markdown: {
    shikiConfig: { theme: "github-dark", wrap: true },
  },
});
```

### 3.3 `package.json` Scripts

```json
{
  "scripts": {
    "dev": "astro dev",
    "build": "astro build",
    "preview": "astro preview",
    "server": "node scripts/server.mjs",
    "dashboard": "concurrently \"npm:server\" \"npm:dev\"",
    "cli": "node scripts/cli.mjs",
    "new": "node scripts/cli.mjs new",
    "drafts": "node scripts/cli.mjs drafts",
    "publish": "node scripts/cli.mjs publish",
    "list": "node scripts/cli.mjs list"
  }
}
```

### 3.4 `.env.example`

```
ANTHROPIC_API_KEY=sk-ant-...
ANTHROPIC_MODEL=claude-sonnet-4-6

# Optional — für den Bild-Step (Replicate Flux Dev)
REPLICATE_API_TOKEN=r8_...
# REPLICATE_IMAGE_MODEL=black-forest-labs/flux-dev   # Default
# REPLICATE_IMAGE_MODEL=black-forest-labs/flux-schnell   # billiger (~€0,003/Bild)
# REPLICATE_IMAGE_MODEL=black-forest-labs/flux-pro       # Premium (~€0,055/Bild)
```

### 3.5 `.gitignore`

```
.env
.runs/
node_modules/
dist/
.astro/
```

---

## 4 — Content-Layer

### 4.1 `src/content.config.ts`

Drei Artikel-Collections mit identischem Schema, plus eine Tools-Collection:

```ts
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";

const articleSchema = z.object({
  title: z.string(),
  description: z.string(),
  pubDate: z.coerce.date(),
  updatedDate: z.coerce.date().optional(),
  draft: z.boolean().default(false),
  tags: z.array(z.string()).default([]),
  heroImage: z.string().optional(),
});

const agents = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/agents" }),
  schema: articleSchema,
});
const lokal = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/lokal" }),
  schema: articleSchema,
});
const anleitungen = defineCollection({
  loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/anleitungen" }),
  schema: articleSchema,
});

const tools = defineCollection({
  loader: glob({ pattern: "**/*.md", base: "./src/content/tools" }),
  schema: z.object({
    name: z.string(),
    category: z.enum(["llm", "agent", "tool", "deploy", "ui"]),
    pricing: z.enum(["free", "freemium", "paid"]),
    dsgvo: z.enum(["ja", "nein", "teilweise"]),
    rating: z.number().min(1).max(5),
    description: z.string(),
    url: z.string().url(),
    badges: z.array(z.string()).default([]),
  }),
});

export const collections = { agents, lokal, anleitungen, tools };
```

### 4.2 Pillar-Konvention

| Pillar | Slug-Prefix | Idee |
|---|---|---|
| `agents` | `/agents/...` | MCP, Agent-Frameworks, Tool-Use |
| `lokal` | `/lokal/...` | Lokale LLMs, Hardware, Inference |
| `anleitungen` | `/anleitungen/...` | Step-by-Step Builds (KI-PC, Setups) |

---

## 5 — Styling-Tokens (`src/styles/global.css`)

```css
@import "tailwindcss";

@theme {
  --color-bg: #0d0e10;
  --color-surface: #161719;
  --color-surface-hover: #1d1e21;
  --color-border: #26272b;
  --color-border-strong: #3a3b40;
  --color-primary: #f5f5f5;
  --color-text-secondary: #b5b6ba;
  --color-text-tertiary: #75767a;
  --color-accent: #e85a2c;       /* Schmiede-Orange */
  --color-accent-dark: #c64518;

  --font-heading: "Manrope", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", ui-monospace, monospace;

  --text-h1: 2.75rem;
  --text-h2: 2rem;
  --text-h3: 1.5rem;
  --text-h4: 1.125rem;
  --text-lead: 1.125rem;

  --radius-card: 1rem;

  --spacing-22: 5.5rem;
}

/* Prose-Styles (Articles) */
.prose-christianohle { /* H2/H3 spacing, Code-Block-Treatment, Blockquote-Styling, etc. */ }

/* Code-Block-Akkordeon (für lange Snippets) */
.code-collapsed pre { max-height: 12rem; overflow: hidden; position: relative; }
.code-collapsed pre::after { /* fade-out gradient + Klick-Hinweis */ }
```

Für Details: Claude bekommt das CSS so generisch und passt es während des Builds an die Brand-Farben an.

---

## 6 — Layouts & Komponenten (Pflicht-Komponenten)

### 6.1 `BaseLayout.astro`

Standard-HTML-Shell: `<head>` mit Meta-Tags (OG, Twitter, Canonical), Manrope + JetBrains Mono via `<link>`, Header, `<slot/>`, Footer.

### 6.2 `ArticlePage.astro`

Article-Layout mit:
- Sticky-Sidebar links: `<TableOfContents />` (nur H2, IntersectionObserver für Active-State).
- Mainspalte: Prose mit `prose-christianohle`-Klasse.
- Unten: `<AuthorBox />`.
- JS-Snippet, das alle `<pre>`-Blöcke länger als X Zeilen in Akkordeons verwandelt.

### 6.3 `dashboard/StepCard.astro`

Step-Karte mit:
- Status-Pill (`pending`, `active`, `running`, `done`, `error`).
- Step-Nr + Titel + Beschreibung.
- Klickbar **wenn** Status `active` ODER `done` ODER (`pending` UND vorheriger Step `done`).

---

## 7 — Der lokale API-Server (`scripts/server.mjs`)

**Zentraler Punkt der ganzen Pipeline.** Native Node-HTTP, Port 4322, CORS für `localhost:4321`.

### 7.1 Endpunkte

| Method | Path | Zweck |
|---|---|---|
| `GET`  | `/api/runs` | Alle Runs auflisten |
| `POST` | `/api/runs` | Neuen Run anlegen (`{ topic, pillar }`) |
| `GET`  | `/api/runs/:id` | Run-State holen |
| `POST` | `/api/runs/:id/steps/:stepName/run` | Step ausführen (löst Anthropic-Call aus) |
| `POST` | `/api/runs/:id/keyword` | Keyword-Auswahl bei Discovery |
| `GET`  | `/api/runs/:id/log` | Live-Log für Terminal-Komponente |

### 7.2 Run-State-Shape (`.runs/<id>.json`)

```json
{
  "id": "run_2026-04-27_abc123",
  "createdAt": "2026-04-27T18:00:00.000Z",
  "topic": "MCP-Server bauen",
  "pillar": "agents",
  "status": "in_progress",
  "selectedKeyword": "mcp server tutorial",
  "steps": {
    "discovery":  { "status": "done",    "result": { "keywords": [...] } },
    "research":   { "status": "done",    "result": { "outline": "...", "sources": [...] } },
    "write":      { "status": "active",  "result": null },
    "fact-check": { "status": "pending", "result": null },
    "seo":        { "status": "pending", "result": null },
    "image":      { "status": "pending", "result": null },
    "publish":    { "status": "pending", "result": null }
  },
  "logs": []
}
```

### 7.3 Step-Auto-Advance

Nach jedem `await stepFn(run)` muss der Server:
1. Den aktuellen Step auf `done` setzen.
2. Den nächsten Step (wenn `pending`) auf `active` setzen.
3. State persistieren.

**Bug-Falle:** Auto-Advance darf NICHT ausgelöst werden, wenn ein Step Fehler wirft → `error`-Status, kein Forward-Move.

---

## 8 — Steps (`scripts/lib/steps/*.mjs`)

Jeder Step exportiert eine async-Funktion `run(state) → state`. Hier die 7 Steps mit Kurzbeschreibung:

### 8.1 `discovery.mjs`
Input: `topic` + `pillar`. Anthropic-Call mit Prompt: „Gib mir 5 Keyword-Vorschläge mit Search-Volume-Schätzung und Schwierigkeit für deutsche Builder im DACH-Raum." Output: `keywords[]`. **User wählt eines aus** über `/keyword`-Endpoint.

### 8.2 `research.mjs`
Input: gewähltes Keyword. Anthropic-Call: Web-Research-Prompt mit Auftrag „Erstelle eine Outline mit H2/H3, identifiziere relevante Quellen, fass zentrale Argumente zusammen." Output: `outline` (Markdown), `sources[]`.

### 8.3 `write.mjs`
Input: Outline + Quellen. Anthropic-Call: christianohle-Voice-System-Prompt + Outline → Long-Form-MDX (3000–5000 Wörter). System-Prompt enthält:
- „Direkte Du-Ansprache, keine Du/Sie-Mischung."
- „Konkret, nicht abstrakt. Code-Snippets, wo immer möglich."
- „Kein Marketing-Sprech. Wenn Tool nichts taugt, sag es."
- Frontmatter-Schema strikt einhalten.

Output: kompletter MDX-String. Wird als Draft in `src/content/<pillar>/<slug>.mdx` geschrieben.

### 8.4 `fact-check.mjs`
Input: gerade-geschriebener Artikel. Anthropic-Call: „Prüfe alle Faktenbehauptungen, markiere `[VERIFY:]`-Kommentare wo unsicher, gib Liste fragwürdiger Aussagen zurück." Output: annotierter MDX + `issues[]`-Array.

### 8.5 `seo.mjs`
Input: Artikel. Anthropic-Call: „Prüfe Title-Length (≤60), Meta-Description (≤160), H1-Uniqueness, Keyword-Density, interne Verlinkungen, Image-Alt-Tags." Output: `seoScore` + `recommendations[]`.

### 8.6 `image.mjs` — Bild-Step mit Replicate Flux Dev

Input: Artikel-Topic + Pillar + Keywords aus dem State.

Pipeline in vier Schritten:

1. **Prompt generieren via Claude** (`generateImagePrompt` in `anthropic.mjs`) — Single-Subject-Komposition, kein Schmiede-Pathos, photorealistic stylized, 1–3 Sätze auf Englisch.
2. **Replicate Flux Dev rendert** (`generateImage` in `replicate.mjs`) — der Brand-Style-Suffix wird automatisch angehängt: `minimal editorial photography, matte charcoal background, subtle warm orange accent (#e85a2c) used sparingly, no flames, 16:9`.
3. **Bild herunterladen + lokal speichern** nach `public/images/articles/<slug>.jpg`.
4. **State aktualisieren**: `state.artifacts.heroImage = "/images/articles/<slug>.jpg"`, plus Prompt + Modell-Name + Dateigröße als Artefakte. Cost wird um $0.025 hochgezählt.

Dazu nötig:
- `npm install replicate dotenv`
- ENV-Variable `REPLICATE_API_TOKEN=r8_…` in `.env` (Token holen unter https://replicate.com/account/api-tokens)
- Optional override: `REPLICATE_IMAGE_MODEL=black-forest-labs/flux-dev` (Default), alternativ `flux-schnell` (~€0,003/Bild) oder `flux-pro` (~€0,055/Bild)

`scripts/lib/replicate.mjs` — Wrapper:

```js
import Replicate from "replicate";
import fs from "fs";
import path from "path";

const DEFAULT_MODEL = "black-forest-labs/flux-dev";

export const BRAND_STYLE_SUFFIX = [
  "minimal editorial photography style",
  "matte charcoal-grey background, deep blacks",
  "subtle warm orange accent (#e85a2c) used sparingly as a single small highlight, never as fire or glow",
  "soft directional studio lighting from a single source",
  "clean composition with generous negative space",
  "no text, no watermarks, no logos, no people, no flames",
  "16:9 aspect ratio, hero-image composition",
].join(", ");

export async function generateImage({ prompt, aspectRatio = "16:9", model } = {}) {
  const client = new Replicate({ auth: process.env.REPLICATE_API_TOKEN });
  const useModel = model || process.env.REPLICATE_IMAGE_MODEL || DEFAULT_MODEL;
  const finalPrompt = `${prompt}. ${BRAND_STYLE_SUFFIX}`;

  const output = await client.run(useModel, {
    input: {
      prompt: finalPrompt,
      aspect_ratio: aspectRatio,
      output_format: "jpg",
      output_quality: 90,
      num_outputs: 1,
      num_inference_steps: 28,
      guidance: 3.5,
    },
  });

  // Replicate-SDK kann verschiedene Output-Shapes liefern — robust extrahieren
  const url = await extractFirstUrl(output);
  if (!url) throw new Error("Replicate hat keine Bild-URL geliefert");
  return { url, finalPrompt, model: useModel };
}

export async function downloadImage(url, destPath) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`Download fehlgeschlagen: HTTP ${res.status}`);
  const buf = Buffer.from(await res.arrayBuffer());
  fs.mkdirSync(path.dirname(destPath), { recursive: true });
  fs.writeFileSync(destPath, buf);
  return { path: destPath, bytes: buf.length };
}

async function extractFirstUrl(output) {
  if (Array.isArray(output)) return extractFirstUrl(output[0]);
  if (typeof output === "string") return output;
  if (typeof output?.url === "function") return await output.url();
  if (typeof output?.url === "string") return output.url;
  return null;
}
```

`scripts/lib/anthropic.mjs` ergänzt um `generateImagePrompt({topic, pillar, keywords})`, das Claude einen englischen Bild-Prompt schreiben lässt — mit explizitem Verbot von Schmiede/Werkbank/Funken/Hammer-Imagery, dafür Editorial-Foto-Stil im Wired/Verge-Look.

**Bonus-Skript für Bestandsartikel**, `scripts/image-cli.mjs` — wenn ein Artikel schon existiert und nur ein Bild braucht ohne ganze Pipeline:

```bash
npm run image -- --slug=mcp-server-bauen --pillar=agents --prompt="A single mechanical keyboard photographed from above on a matte dark surface, s