diff --git a/agent/README.md b/agent/README.md
new file mode 100644
index 0000000..fd9f893
--- /dev/null
+++ b/agent/README.md
@@ -0,0 +1,150 @@
+# Recipe Standardization Agent
+
+An agent that reads recipe files in **multiple formats**, normalises them into a standard schema, and produces a single **iPad-friendly HTML document** you can open directly in Safari.
+
+---
+
+## Features
+
+| Capability | Detail |
+|---|---|
+| **Supported input formats** | `.txt` (plain text), `.json`, `.md` / `.markdown`, `.csv` |
+| **Parsed fields** | Title, servings, prep time, cook time, ingredients (amount / unit / item), numbered instructions, notes |
+| **Output** | Single self-contained HTML file – no internet connection required on device |
+| **iPad optimised** | Responsive layout, system fonts, print-safe, table of contents |
+
+---
+
+## Quick Start
+
+```bash
+# Parse all recipes in recipes/input/ and write recipes/output/recipes.html
+python3 agent/recipe_agent.py
+
+# Custom paths
+python3 agent/recipe_agent.py --input /path/to/my/recipes --output /path/to/output.html
+```
+
+Open `recipes/output/recipes.html` in **Safari on your iPad** (or any browser).
+
+---
+
+## Project Layout
+
+```
+AgentBuilding/
+├── agent/
+│ └── recipe_agent.py # The agent (pure Python, no dependencies)
+├── recipes/
+│ ├── input/ # Drop your recipe files here
+│ │ ├── banana_bread.txt
+│ │ ├── caesar_salad.csv
+│ │ ├── chicken_tikka_masala.md
+│ │ ├── chocolate_chip_cookies.json
+│ │ └── spaghetti_carbonara.txt
+│ └── output/
+│ └── recipes.html # Generated output (open on iPad)
+└── README.md
+```
+
+---
+
+## Input Format Examples
+
+### Plain Text (`.txt`)
+
+```
+My Recipe Title
+
+Servings: 4
+Prep Time: 15 minutes
+Cook Time: 30 minutes
+
+Ingredients:
+- 2 cups flour
+- 1 tsp salt
+
+Instructions:
+1. Mix the ingredients.
+2. Bake at 350°F for 30 minutes.
+
+Notes:
+Don't over-mix the batter.
+```
+
+Section headings are flexible — `INGREDIENTS:`, `What you need:`, `You will need:` are all recognised. Instruction headings like `INSTRUCTIONS:`, `Directions:`, `How to make it:` also work.
+
+### JSON (`.json`)
+
+```json
+{
+ "name": "My Recipe",
+ "servings": "4",
+ "prepTime": "10 min",
+ "cookTime": "25 min",
+ "ingredients": [
+ { "qty": "2", "unit": "cups", "item": "flour" }
+ ],
+ "steps": [
+ "Mix the ingredients.",
+ "Bake for 25 minutes."
+ ],
+ "notes": "Optional tip here."
+}
+```
+
+### Markdown (`.md`)
+
+```markdown
+# My Recipe
+
+**Servings:** 4
+**Prep Time:** 10 minutes
+**Cook Time:** 25 minutes
+
+## Ingredients
+- 2 cups flour
+- 1 tsp salt
+
+## Instructions
+1. Mix the ingredients.
+2. Bake for 25 minutes.
+
+## Notes
+> Optional tip here.
+```
+
+### CSV (`.csv`)
+
+```csv
+Recipe Name,My Recipe
+Servings,4
+Prep Time,10 minutes
+Cook Time,25 minutes
+Notes,Optional tip here.
+
+Section,Amount,Unit,Ingredient
+Main,2,cups,flour
+Main,1,tsp,salt
+
+Step,Instruction
+1,Mix the ingredients.
+2,Bake for 25 minutes.
+```
+
+---
+
+## Requirements
+
+- Python 3.7 or later
+- No third-party libraries required
+
+---
+
+## Adding Your Own Recipes
+
+1. Place your recipe file(s) in `recipes/input/`
+2. Run `python3 agent/recipe_agent.py`
+3. Open `recipes/output/recipes.html` on your iPad
+
+The agent processes all supported files in the input directory alphabetically and combines them into one document.
diff --git a/agent/__pycache__/recipe_agent.cpython-312.pyc b/agent/__pycache__/recipe_agent.cpython-312.pyc
new file mode 100644
index 0000000..ef1f71d
Binary files /dev/null and b/agent/__pycache__/recipe_agent.cpython-312.pyc differ
diff --git a/agent/recipe_agent.py b/agent/recipe_agent.py
new file mode 100644
index 0000000..c7c43fe
--- /dev/null
+++ b/agent/recipe_agent.py
@@ -0,0 +1,752 @@
+#!/usr/bin/env python3
+"""
+Recipe Standardization Agent
+=============================
+An agent that reads recipe files in various formats (TXT, JSON, Markdown, CSV),
+parses and standardizes them into a common schema, and produces a single
+iPad-friendly HTML document.
+
+Supported input formats:
+ - .txt plain-text recipes (flexible heading detection)
+ - .json JSON-structured recipes
+ - .md Markdown recipes
+ - .csv CSV-structured recipes (custom two-section format)
+
+Usage:
+ python recipe_agent.py [--input
] [--output ]
+
+Defaults:
+ --input recipes/input/
+ --output recipes/output/recipes.html
+"""
+
+import argparse
+import csv
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import List, Optional
+
+
+# ---------------------------------------------------------------------------
+# Data model
+# ---------------------------------------------------------------------------
+
+@dataclass
+class Ingredient:
+ amount: str = ""
+ unit: str = ""
+ item: str = ""
+
+ def display(self) -> str:
+ parts = [p for p in (self.amount, self.unit, self.item) if p]
+ return " ".join(parts)
+
+
+@dataclass
+class Recipe:
+ title: str = "Untitled Recipe"
+ servings: str = ""
+ prep_time: str = ""
+ cook_time: str = ""
+ ingredients: List[Ingredient] = field(default_factory=list)
+ instructions: List[str] = field(default_factory=list)
+ notes: str = ""
+ source_file: str = ""
+
+
+# ---------------------------------------------------------------------------
+# Parsers
+# ---------------------------------------------------------------------------
+
+class RecipeParser:
+ """Base class – subclasses implement `can_parse` and `parse`."""
+
+ def can_parse(self, path: Path) -> bool:
+ raise NotImplementedError
+
+ def parse(self, path: Path) -> Recipe:
+ raise NotImplementedError
+
+
+class JsonParser(RecipeParser):
+ """Parses JSON-structured recipe files."""
+
+ def can_parse(self, path: Path) -> bool:
+ return path.suffix.lower() == ".json"
+
+ def parse(self, path: Path) -> Recipe:
+ with open(path, encoding="utf-8") as f:
+ data = json.load(f)
+
+ recipe = Recipe(source_file=path.name)
+ recipe.title = data.get("name") or data.get("title") or path.stem.replace("_", " ").title()
+ recipe.servings = str(data.get("servings") or data.get("yield") or "")
+ recipe.prep_time = str(data.get("prepTime") or data.get("prep_time") or "")
+ recipe.cook_time = str(data.get("cookTime") or data.get("cook_time") or "")
+ recipe.notes = data.get("notes") or ""
+
+ raw_ingredients = data.get("ingredients") or []
+ for item in raw_ingredients:
+ if isinstance(item, dict):
+ recipe.ingredients.append(Ingredient(
+ amount=str(item.get("qty") or item.get("amount") or ""),
+ unit=str(item.get("unit") or ""),
+ item=str(item.get("item") or item.get("name") or ""),
+ ))
+ elif isinstance(item, str):
+ recipe.ingredients.append(Ingredient(item=item))
+
+ instructions = data.get("steps") or data.get("instructions") or []
+ recipe.instructions = [str(s) for s in instructions]
+
+ return recipe
+
+
+class MarkdownParser(RecipeParser):
+ """Parses Markdown-structured recipe files."""
+
+ _TIME_KEYS = re.compile(r"(prep|cook|total)\s*time", re.IGNORECASE)
+ _SERVING_KEYS = re.compile(r"(servings?|yield|makes)", re.IGNORECASE)
+ _HEADING = re.compile(r"^#{1,4}\s+(.*)", re.IGNORECASE)
+ _LIST_ITEM = re.compile(r"^\s*[-*+]\s+(.*)")
+ _NUMBERED = re.compile(r"^\s*\d+\.\s+(.*)")
+ _BOLD_META = re.compile(r"\*\*([^*]+)\*\*\s*:?\s*(.*)")
+
+ def can_parse(self, path: Path) -> bool:
+ return path.suffix.lower() in (".md", ".markdown")
+
+ def parse(self, path: Path) -> Recipe:
+ with open(path, encoding="utf-8") as f:
+ lines = f.readlines()
+
+ recipe = Recipe(source_file=path.name)
+ section = "meta"
+
+ for line in lines:
+ stripped = line.strip()
+ if not stripped:
+ continue
+
+ # Top-level heading → recipe title
+ h_match = self._HEADING.match(stripped)
+ if h_match:
+ heading = h_match.group(1).strip()
+ lower = heading.lower()
+ if "ingredient" in lower:
+ section = "ingredients"
+ elif "instruction" in lower or "direction" in lower or "method" in lower or "step" in lower:
+ section = "instructions"
+ elif "note" in lower or "tip" in lower:
+ section = "notes"
+ elif section == "meta":
+ recipe.title = heading
+ continue
+
+ # Bold metadata lines (e.g. **Servings:** 4)
+ bold_match = self._BOLD_META.match(stripped)
+ if bold_match:
+ key, value = bold_match.group(1).strip(), bold_match.group(2).strip()
+ value = value.lstrip(":").strip()
+ if self._SERVING_KEYS.search(key):
+ recipe.servings = value
+ elif "prep" in key.lower():
+ recipe.prep_time = value
+ elif "cook" in key.lower() or "bake" in key.lower():
+ recipe.cook_time = value
+ continue
+
+ # List items
+ list_match = self._LIST_ITEM.match(stripped)
+ if list_match:
+ content = list_match.group(1).strip()
+ if section == "ingredients":
+ recipe.ingredients.append(_parse_ingredient_string(content))
+ elif section == "notes":
+ recipe.notes += content + " "
+ continue
+
+ # Numbered steps
+ num_match = self._NUMBERED.match(stripped)
+ if num_match:
+ content = num_match.group(1).strip()
+ # Strip bold sub-headings like **Marinate:**
+ content = re.sub(r"\*\*[^*]+\*\*:?\s*", "", content).strip()
+ if section == "instructions" and content:
+ recipe.instructions.append(content)
+ continue
+
+ # Blockquote notes
+ if stripped.startswith(">"):
+ note_text = stripped.lstrip("> ").strip()
+ recipe.notes += note_text + " "
+
+ recipe.notes = recipe.notes.strip()
+ return recipe
+
+
+class CsvParser(RecipeParser):
+ """
+ Parses a two-section CSV format:
+ - Header rows: Recipe Name, Servings, Prep Time, Cook Time, Notes
+ - Ingredient rows: Section, Amount, Unit, Ingredient
+ - Step rows: Step, Instruction
+ """
+
+ def can_parse(self, path: Path) -> bool:
+ return path.suffix.lower() == ".csv"
+
+ def parse(self, path: Path) -> Recipe:
+ with open(path, encoding="utf-8", newline="") as f:
+ rows = list(csv.reader(f))
+
+ recipe = Recipe(source_file=path.name)
+ mode = "header"
+
+ for row in rows:
+ if not any(cell.strip() for cell in row):
+ continue
+
+ first = row[0].strip()
+ second = row[1].strip() if len(row) > 1 else ""
+
+ # Detect section transitions
+ if first.lower() == "section" and second.lower() in ("amount", "qty"):
+ mode = "ingredients"
+ continue
+ if first.lower() == "step" and second.lower() == "instruction":
+ mode = "instructions"
+ continue
+
+ if mode == "header":
+ key = first.lower()
+ value = second
+ if "recipe name" in key or "name" == key:
+ recipe.title = value
+ elif "servings" in key or "yield" in key:
+ recipe.servings = value
+ elif "prep" in key:
+ recipe.prep_time = value
+ elif "cook" in key or "bake" in key:
+ recipe.cook_time = value
+ elif "note" in key:
+ recipe.notes = value.strip('"')
+
+ elif mode == "ingredients":
+ # Row format: Section, Amount, Unit, Ingredient
+ amount = row[1].strip() if len(row) > 1 else ""
+ unit = row[2].strip() if len(row) > 2 else ""
+ item = row[3].strip() if len(row) > 3 else ""
+ if item:
+ recipe.ingredients.append(Ingredient(amount=amount, unit=unit, item=item))
+
+ elif mode == "instructions":
+ instruction = row[1].strip() if len(row) > 1 else ""
+ if instruction:
+ recipe.instructions.append(instruction)
+
+ return recipe
+
+
+class PlainTextParser(RecipeParser):
+ """
+ Parses plain-text recipe files with flexible heading detection.
+ Handles headings like INGREDIENTS:, INSTRUCTIONS:, What you need:, How to make it: etc.
+ """
+
+ _INGREDIENT_HEADINGS = re.compile(
+ r"^(ingredients?|what\s+you\s+need|you\s+will\s+need|shopping\s+list)\s*:?\s*$",
+ re.IGNORECASE,
+ )
+ _INSTRUCTION_HEADINGS = re.compile(
+ r"^(instructions?|directions?|method|steps?|how\s+to\s+(make\s+)?it|preparation)\s*:?\s*$",
+ re.IGNORECASE,
+ )
+ _NOTES_HEADINGS = re.compile(
+ r"^(notes?|tips?|chef['\u2019s]*\s+note)\s*:?\s*$",
+ re.IGNORECASE,
+ )
+ _META_LINE = re.compile(
+ r"^(servings?|yield|makes|prep\s*(time)?|cook\s*(time)?|bak(e|ing)\s*(time)?|total\s*(time)?)\s*:\s*(.+)$",
+ re.IGNORECASE,
+ )
+ _NUMBERED = re.compile(r"^\d+[.)]\s+(.*)")
+ _BULLET = re.compile(r"^[-*•]\s+(.*)")
+ _INLINE_NOTE = re.compile(r"^(tip|note)\s*:\s*(.*)", re.IGNORECASE)
+
+ def can_parse(self, path: Path) -> bool:
+ return path.suffix.lower() in (".txt", ".text", "")
+
+ def parse(self, path: Path) -> Recipe:
+ with open(path, encoding="utf-8") as f:
+ lines = [l.rstrip("\n") for l in f.readlines()]
+
+ recipe = Recipe(source_file=path.name)
+ section = "title"
+
+ for line in lines:
+ stripped = line.strip()
+ if not stripped:
+ continue
+
+ # Section heading detection
+ if self._INGREDIENT_HEADINGS.match(stripped):
+ section = "ingredients"
+ continue
+ if self._INSTRUCTION_HEADINGS.match(stripped):
+ section = "instructions"
+ continue
+ if self._NOTES_HEADINGS.match(stripped):
+ section = "notes"
+ continue
+
+ # Metadata lines (Servings: 4, Prep Time: 15 min, ...)
+ meta = self._META_LINE.match(stripped)
+ if meta:
+ key = meta.group(1).lower()
+ value = meta.group(meta.lastindex).strip() if meta.lastindex else ""
+ if not value:
+ # fallback: everything after the first colon
+ value = stripped.split(":", 1)[-1].strip()
+ if "serving" in key or "yield" in key or "makes" in key:
+ recipe.servings = value
+ elif "prep" in key:
+ recipe.prep_time = value
+ elif "cook" in key or "bak" in key:
+ recipe.cook_time = value
+ continue
+
+ # Inline tip/note
+ note_match = self._INLINE_NOTE.match(stripped)
+ if note_match:
+ recipe.notes += note_match.group(2).strip() + " "
+ continue
+
+ if section == "title":
+ recipe.title = stripped
+ section = "meta" # only the first non-empty line is the title
+
+ elif section == "ingredients":
+ # Remove leading bullets
+ bullet = self._BULLET.match(stripped)
+ content = bullet.group(1).strip() if bullet else stripped
+ recipe.ingredients.append(_parse_ingredient_string(content))
+
+ elif section == "instructions":
+ num = self._NUMBERED.match(stripped)
+ if num:
+ recipe.instructions.append(num.group(1).strip())
+ else:
+ # Treat non-empty lines as instruction sentences
+ recipe.instructions.append(stripped)
+
+ elif section == "notes":
+ recipe.notes += stripped + " "
+
+ recipe.notes = recipe.notes.strip()
+ return recipe
+
+
+# ---------------------------------------------------------------------------
+# Ingredient string parser
+# ---------------------------------------------------------------------------
+
+_AMOUNT_PATTERN = re.compile(
+ # Amount: mixed numbers (e.g. "1 1/2"), plain fractions ("3/4"), integers, or vulgar fractions
+ r"^(\d+\s+\d+/\d+|\d+/\d+|\d+(?:\.\d+)?|[½¼¾⅓⅔⅛⅜⅝⅞])\s*"
+ # Unit: must match as a complete word (word boundary) to avoid "l" matching "large"
+ r"(cups?|tbsps?|tablespoons?|tsps?|teaspoons?|fl\.?\s*oz|oz|ounces?|lbs?|pounds?|"
+ r"grams?|kg|kilograms?|ml|liters?|litres?|cloves?|heads?|bunches?|stalks?|"
+ r"cans?|packages?|slices?|pieces?|pinch(?:es)?|dash(?:es)?|"
+ r"small|medium|large|whole|g)\b\s*"
+ r"(.*)",
+ re.IGNORECASE,
+)
+
+
+def _parse_ingredient_string(text: str) -> Ingredient:
+ """Best-effort parse of a free-form ingredient string into (amount, unit, item)."""
+ m = _AMOUNT_PATTERN.match(text.strip())
+ if m:
+ amount = m.group(1).strip()
+ unit = (m.group(2) or "").strip()
+ item = (m.group(3) or "").strip().lstrip(",- ").strip()
+ return Ingredient(amount=amount, unit=unit, item=item or text)
+ return Ingredient(item=text)
+
+
+# ---------------------------------------------------------------------------
+# Agent orchestrator
+# ---------------------------------------------------------------------------
+
+PARSERS: List[RecipeParser] = [
+ JsonParser(),
+ MarkdownParser(),
+ CsvParser(),
+ PlainTextParser(),
+]
+
+
+def process_directory(input_dir: Path) -> List[Recipe]:
+ """Walk input_dir, select a parser for each file, and return parsed recipes."""
+ recipes: List[Recipe] = []
+ supported = {".txt", ".text", ".json", ".md", ".markdown", ".csv"}
+
+ for path in sorted(input_dir.iterdir()):
+ if path.is_dir() or path.suffix.lower() not in supported:
+ continue
+ parser = next((p for p in PARSERS if p.can_parse(path)), None)
+ if parser is None:
+ print(f" [skip] no parser for {path.name}", file=sys.stderr)
+ continue
+ print(f" [parse] {path.name} ({parser.__class__.__name__})")
+ try:
+ recipe = parser.parse(path)
+ recipes.append(recipe)
+ except Exception as exc: # noqa: BLE001
+ print(f" [error] {path.name}: {exc}", file=sys.stderr)
+
+ return recipes
+
+
+# ---------------------------------------------------------------------------
+# HTML renderer (iPad-optimised)
+# ---------------------------------------------------------------------------
+
+_CSS = """
+:root {
+ --bg: #fdf6ec;
+ --card: #ffffff;
+ --accent: #c0392b;
+ --accent-light: #f9e5e3;
+ --text: #2c2c2c;
+ --muted: #6b6b6b;
+ --border: #e2d9cf;
+ --shadow: 0 2px 12px rgba(0,0,0,.08);
+ --radius: 14px;
+}
+
+* { box-sizing: border-box; margin: 0; padding: 0; }
+
+body {
+ font-family: -apple-system, "Helvetica Neue", Arial, sans-serif;
+ background: var(--bg);
+ color: var(--text);
+ line-height: 1.65;
+ padding: 1.5rem 1rem 3rem;
+}
+
+header {
+ text-align: center;
+ padding: 2rem 1rem 1.5rem;
+}
+header h1 {
+ font-size: 2rem;
+ color: var(--accent);
+ letter-spacing: -.5px;
+}
+header p {
+ color: var(--muted);
+ font-size: .9rem;
+ margin-top: .3rem;
+}
+
+/* Table of contents */
+.toc {
+ background: var(--card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.2rem 1.5rem;
+ max-width: 700px;
+ margin: 0 auto 2rem;
+ box-shadow: var(--shadow);
+}
+.toc h2 {
+ font-size: 1rem;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--muted);
+ margin-bottom: .7rem;
+}
+.toc ol { padding-left: 1.4rem; }
+.toc li { margin: .3rem 0; }
+.toc a { color: var(--accent); text-decoration: none; font-weight: 500; }
+.toc a:hover { text-decoration: underline; }
+
+/* Recipe cards */
+.recipes { max-width: 700px; margin: 0 auto; display: flex; flex-direction: column; gap: 2rem; }
+
+.card {
+ background: var(--card);
+ border-radius: var(--radius);
+ border: 1px solid var(--border);
+ box-shadow: var(--shadow);
+ overflow: hidden;
+}
+
+.card-header {
+ background: var(--accent);
+ color: #fff;
+ padding: 1.2rem 1.5rem;
+}
+.card-header h2 {
+ font-size: 1.4rem;
+ font-weight: 700;
+}
+.card-header .source {
+ font-size: .75rem;
+ opacity: .75;
+ margin-top: .2rem;
+}
+
+.meta-bar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: .5rem 1.5rem;
+ padding: .9rem 1.5rem;
+ background: var(--accent-light);
+ border-bottom: 1px solid var(--border);
+}
+.meta-item { font-size: .85rem; color: var(--muted); }
+.meta-item strong { color: var(--text); }
+
+.card-body { padding: 1.2rem 1.5rem; }
+
+.section-title {
+ font-size: .8rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: .1em;
+ color: var(--accent);
+ margin: 1.2rem 0 .5rem;
+ padding-bottom: .3rem;
+ border-bottom: 2px solid var(--accent-light);
+}
+.section-title:first-child { margin-top: 0; }
+
+.ingredients { list-style: none; }
+.ingredients li {
+ padding: .4rem 0;
+ border-bottom: 1px solid var(--border);
+ font-size: .95rem;
+ display: flex;
+ align-items: baseline;
+ gap: .4rem;
+}
+.ingredients li:last-child { border-bottom: none; }
+.ingredients .amount { font-weight: 600; min-width: 3rem; }
+.ingredients .unit { color: var(--muted); min-width: 4rem; }
+
+.instructions { list-style: none; counter-reset: step; }
+.instructions li {
+ counter-increment: step;
+ display: flex;
+ gap: .9rem;
+ padding: .5rem 0;
+ font-size: .95rem;
+ border-bottom: 1px solid var(--border);
+ align-items: flex-start;
+}
+.instructions li:last-child { border-bottom: none; }
+.instructions li::before {
+ content: counter(step);
+ background: var(--accent);
+ color: #fff;
+ border-radius: 50%;
+ min-width: 1.6rem;
+ height: 1.6rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: .8rem;
+ font-weight: 700;
+ flex-shrink: 0;
+ margin-top: .1rem;
+}
+
+.notes-box {
+ background: #fffbf0;
+ border-left: 4px solid #f0c040;
+ border-radius: 0 8px 8px 0;
+ padding: .7rem 1rem;
+ font-size: .9rem;
+ color: #5a4a00;
+ margin-top: .5rem;
+}
+
+footer {
+ text-align: center;
+ color: var(--muted);
+ font-size: .8rem;
+ margin-top: 3rem;
+}
+
+/* iPad / print optimisations */
+@media (min-width: 768px) {
+ body { padding: 2rem 2rem 4rem; }
+ .card-header h2 { font-size: 1.6rem; }
+}
+@media print {
+ body { background: #fff; }
+ .card { page-break-inside: avoid; box-shadow: none; border: 1px solid #ccc; }
+}
+"""
+
+
+def _escape(text: str) -> str:
+ """Minimal HTML escaping."""
+ return (
+ text.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace('"', """)
+ )
+
+
+def render_html(recipes: List[Recipe], generated_at: str, year: str = "") -> str:
+ """Return the full HTML document as a string."""
+ parts: List[str] = []
+
+ parts.append(f"""
+
+
+
+
+ My Recipe Book
+
+
+
+
+
+""")
+
+ # Table of contents
+ parts.append('\n Contents \n \n')
+ for i, r in enumerate(recipes, 1):
+ anchor = f"recipe-{i}"
+ parts.append(f' {_escape(r.title)} \n')
+ parts.append(" \n \n\n")
+
+ # Recipe cards
+ parts.append('\n')
+ for i, r in enumerate(recipes, 1):
+ anchor = f"recipe-{i}"
+ parts.append(f'
\n')
+
+ # Card header
+ parts.append(f' \n')
+
+ # Meta bar
+ meta_items = []
+ if r.servings:
+ meta_items.append(f'Servings: {_escape(r.servings)} ')
+ if r.prep_time:
+ meta_items.append(f'Prep: {_escape(r.prep_time)} ')
+ if r.cook_time:
+ meta_items.append(f'Cook: {_escape(r.cook_time)} ')
+ if meta_items:
+ parts.append(' \n ')
+ parts.append("\n ".join(meta_items))
+ parts.append('\n
\n')
+
+ parts.append(' \n')
+
+ # Ingredients
+ if r.ingredients:
+ parts.append('
Ingredients
\n')
+ parts.append('
\n')
+ for ing in r.ingredients:
+ amount_html = f'{_escape(ing.amount)} ' if ing.amount else ""
+ unit_html = f'{_escape(ing.unit)} ' if ing.unit else ""
+ item_html = _escape(ing.item)
+ parts.append(f' {amount_html}{unit_html}{item_html} \n')
+ parts.append(' \n')
+
+ # Instructions
+ if r.instructions:
+ parts.append('
Instructions
\n')
+ parts.append('
\n')
+ for step in r.instructions:
+ parts.append(f' {_escape(step)} \n')
+ parts.append(' \n')
+
+ # Notes
+ if r.notes:
+ parts.append(f'
Notes
\n')
+ parts.append(f'
{_escape(r.notes)}
\n')
+
+ parts.append('
\n') # card-body
+ parts.append(' \n\n')
+
+ parts.append('
\n\n') # .recipes
+
+ parts.append(f'Recipe Book © {_escape(year or generated_at[-4:])} · Open in Safari on iPad for best experience \n')
+ parts.append('\n\n')
+
+ return "".join(parts)
+
+
+# ---------------------------------------------------------------------------
+# CLI entry point
+# ---------------------------------------------------------------------------
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Recipe Standardization Agent – converts mixed-format recipes to a single iPad-friendly HTML file."
+ )
+ parser.add_argument(
+ "--input",
+ default="recipes/input",
+ help="Directory containing recipe files (default: recipes/input)",
+ )
+ parser.add_argument(
+ "--output",
+ default="recipes/output/recipes.html",
+ help="Output HTML file path (default: recipes/output/recipes.html)",
+ )
+ args = parser.parse_args()
+
+ input_dir = Path(args.input)
+ output_path = Path(args.output)
+
+ if not input_dir.is_dir():
+ print(f"Error: input directory '{input_dir}' does not exist.", file=sys.stderr)
+ sys.exit(1)
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ print(f"Recipe Standardization Agent")
+ print(f" Input : {input_dir.resolve()}")
+ print(f" Output : {output_path.resolve()}")
+ print()
+
+ recipes = process_directory(input_dir)
+
+ if not recipes:
+ print("No recipes parsed – nothing to write.", file=sys.stderr)
+ sys.exit(1)
+
+ print(f"\n {len(recipes)} recipe(s) standardised successfully.")
+
+ from datetime import datetime, timezone
+ now = datetime.now(timezone.utc)
+ generated_at = now.strftime("%B %d, %Y")
+ year = now.strftime("%Y")
+
+ html = render_html(recipes, generated_at, year)
+ output_path.write_text(html, encoding="utf-8")
+ print(f" Output written to: {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/recipes/input/banana_bread.txt b/recipes/input/banana_bread.txt
new file mode 100644
index 0000000..ff2efcc
--- /dev/null
+++ b/recipes/input/banana_bread.txt
@@ -0,0 +1,28 @@
+Banana Bread
+
+Yield: 1 loaf (8 slices)
+Prep: 15 minutes
+Bake: 60-65 minutes
+
+What you need:
+3 very ripe bananas
+1/3 cup melted butter
+3/4 cup sugar
+1 egg, beaten
+1 tsp vanilla
+1 tsp baking soda
+Pinch of salt
+1 1/2 cups all-purpose flour
+
+How to make it:
+Preheat your oven to 350°F (175°C). Grease a 4x8-inch loaf pan.
+In a mixing bowl, mash the ripe bananas with a fork until smooth.
+Stir the melted butter into the mashed bananas.
+Mix in the sugar, beaten egg, and vanilla extract.
+Sprinkle the baking soda and salt over the mixture and stir.
+Add the flour and mix until just combined — do not overmix.
+Pour the batter into the prepared loaf pan.
+Bake for 60–65 minutes, or until a toothpick inserted in the center comes out clean.
+Let cool in the pan for a few minutes before turning out onto a wire rack.
+
+Tip: The riper and blacker the bananas, the sweeter and more flavorful the bread will be!
diff --git a/recipes/input/caesar_salad.csv b/recipes/input/caesar_salad.csv
new file mode 100644
index 0000000..ade60a5
--- /dev/null
+++ b/recipes/input/caesar_salad.csv
@@ -0,0 +1,26 @@
+Recipe Name,Caesar Salad
+Servings,2
+Prep Time,20 minutes
+Cook Time,0 minutes
+Notes,"A classic Caesar salad. Use fresh Parmesan for best results."
+
+Section,Amount,Unit,Ingredient
+Salad,1,head,romaine lettuce (washed and torn)
+Salad,0.5,cup,Parmesan cheese (freshly grated)
+Salad,1,cup,croutons
+Dressing,2,,anchovy fillets (minced)
+Dressing,1,,garlic clove (minced)
+Dressing,1,tsp,Dijon mustard
+Dressing,1,tsp,Worcestershire sauce
+Dressing,2,tbsp,lemon juice
+Dressing,0.5,cup,mayonnaise
+Dressing,0.25,cup,Parmesan cheese (grated)
+Dressing,,,Salt and black pepper to taste
+
+Step,Instruction
+1,Whisk together anchovies and garlic in a large bowl until a paste forms.
+2,Add mustard and Worcestershire sauce; whisk to combine.
+3,Whisk in lemon juice then mayonnaise until smooth and creamy.
+4,Stir in 1/4 cup Parmesan and season generously with salt and pepper.
+5,Add romaine lettuce and toss well to coat every leaf with dressing.
+6,Top with croutons and remaining Parmesan. Serve immediately.
diff --git a/recipes/input/chicken_tikka_masala.md b/recipes/input/chicken_tikka_masala.md
new file mode 100644
index 0000000..56b64b1
--- /dev/null
+++ b/recipes/input/chicken_tikka_masala.md
@@ -0,0 +1,43 @@
+# Chicken Tikka Masala
+
+**Servings:** 4
+**Prep Time:** 30 minutes (plus 2 hours marinating)
+**Cook Time:** 40 minutes
+
+## Ingredients
+
+### Marinade
+- 1 cup plain yogurt
+- 2 tbsp lemon juice
+- 2 tsp cumin
+- 1 tsp cinnamon
+- 2 tsp cayenne pepper
+- 2 tsp black pepper
+- 1 tbsp minced fresh ginger
+- 1 tsp salt
+- 3 boneless chicken breasts, cut into bite-sized pieces
+
+### Sauce
+- 3 tbsp butter
+- 1 clove garlic, minced
+- 1 jalapeno pepper, finely chopped
+- 2 tsp cumin
+- 2 tsp paprika
+- 1 tsp salt
+- 1 can (8 oz) tomato sauce
+- 1 cup heavy cream
+- 1/4 cup fresh cilantro, chopped
+
+## Instructions
+
+1. **Marinate the chicken:** Combine yogurt, lemon juice, and marinade spices in a bowl. Add the chicken, cover, and refrigerate for at least 2 hours (overnight preferred).
+2. **Grill or broil the chicken:** Thread marinated chicken onto skewers and grill or broil until slightly charred, about 5 minutes per side. Set aside.
+3. **Make the sauce:** Melt butter in a large heavy skillet over medium heat. Sauté garlic and jalapeno for 1 minute. Add cumin, paprika, and salt; stir for 1 minute.
+4. **Add tomatoes:** Pour in the tomato sauce and simmer for 15 minutes, stirring occasionally.
+5. **Add cream:** Stir in heavy cream and simmer until the sauce thickens, about 10 minutes.
+6. **Combine:** Add the grilled chicken to the sauce and simmer for 10 minutes.
+7. **Serve:** Garnish with fresh cilantro and serve over basmati rice or with naan bread.
+
+## Notes
+
+> The longer you marinate the chicken, the more tender and flavorful it will be. Overnight marinating gives the best results.
diff --git a/recipes/input/chocolate_chip_cookies.json b/recipes/input/chocolate_chip_cookies.json
new file mode 100644
index 0000000..8fdb9f5
--- /dev/null
+++ b/recipes/input/chocolate_chip_cookies.json
@@ -0,0 +1,29 @@
+{
+ "name": "Classic Chocolate Chip Cookies",
+ "servings": "36 cookies",
+ "prepTime": "15 min",
+ "cookTime": "11 min",
+ "ingredients": [
+ { "qty": "2 1/4", "unit": "cups", "item": "all-purpose flour" },
+ { "qty": "1", "unit": "tsp", "item": "baking soda" },
+ { "qty": "1", "unit": "tsp", "item": "salt" },
+ { "qty": "1", "unit": "cup", "item": "unsalted butter, softened" },
+ { "qty": "3/4", "unit": "cup", "item": "granulated sugar" },
+ { "qty": "3/4", "unit": "cup", "item": "packed brown sugar" },
+ { "qty": "2", "unit": "", "item": "large eggs" },
+ { "qty": "2", "unit": "tsp", "item": "vanilla extract" },
+ { "qty": "2", "unit": "cups", "item": "chocolate chips" },
+ { "qty": "1", "unit": "cup", "item": "chopped walnuts (optional)" }
+ ],
+ "steps": [
+ "Preheat oven to 375°F (190°C). Line baking sheets with parchment paper.",
+ "Whisk together flour, baking soda, and salt in a bowl; set aside.",
+ "Beat butter and both sugars together until light and fluffy, about 3 minutes.",
+ "Add eggs one at a time, then mix in vanilla extract.",
+ "Gradually blend in the flour mixture until just combined.",
+ "Stir in chocolate chips and walnuts if using.",
+ "Drop rounded tablespoons of dough onto prepared baking sheets, spacing 2 inches apart.",
+ "Bake for 9–11 minutes or until golden brown. Cool on baking sheet for 2 minutes before transferring to wire rack."
+ ],
+ "notes": "For chewier cookies, refrigerate the dough for at least 1 hour before baking."
+}
diff --git a/recipes/input/spaghetti_carbonara.txt b/recipes/input/spaghetti_carbonara.txt
new file mode 100644
index 0000000..7c97829
--- /dev/null
+++ b/recipes/input/spaghetti_carbonara.txt
@@ -0,0 +1,25 @@
+Spaghetti Carbonara
+
+Servings: 4
+Prep Time: 10 minutes
+Cook Time: 20 minutes
+
+INGREDIENTS:
+- 400g spaghetti
+- 200g pancetta or guanciale, diced
+- 4 large eggs
+- 100g Pecorino Romano, finely grated
+- 100g Parmigiano-Reggiano, finely grated
+- 2 cloves garlic
+- Salt and black pepper to taste
+
+INSTRUCTIONS:
+1. Bring a large pot of salted water to a boil and cook spaghetti until al dente.
+2. Meanwhile, cook the pancetta in a large skillet over medium heat until crispy, about 8 minutes.
+3. In a bowl, whisk together the eggs and half the cheese. Season generously with black pepper.
+4. Remove the skillet from heat. Add the drained pasta, tossing to coat in the pancetta fat.
+5. Pour the egg mixture over the pasta, tossing quickly so the eggs don't scramble. Add pasta water a splash at a time to create a creamy sauce.
+6. Serve immediately topped with remaining cheese and extra black pepper.
+
+NOTES:
+Do not add cream — traditional carbonara gets its creaminess from the eggs and cheese.
diff --git a/recipes/output/recipes.html b/recipes/output/recipes.html
new file mode 100644
index 0000000..95c5024
--- /dev/null
+++ b/recipes/output/recipes.html
@@ -0,0 +1,408 @@
+
+
+
+
+
+ My Recipe Book
+
+
+
+
+
+
+ Contents
+
+ Banana Bread
+ Caesar Salad
+ Chicken Tikka Masala
+ Classic Chocolate Chip Cookies
+ Spaghetti Carbonara
+
+
+
+
+
+
+
+ Servings: 1 loaf (8 slices)
+ Prep: 15 minutes
+ Cook: 60-65 minutes
+
+
+
Ingredients
+
+ 3 very ripe bananas
+ 1/3 cup melted butter
+ 3/4 cup sugar
+ 1 egg, beaten
+ 1 tsp vanilla
+ 1 tsp baking soda
+ Pinch of salt
+ 1 1/2 cups all-purpose flour
+
+
Instructions
+
+ Preheat your oven to 350°F (175°C). Grease a 4x8-inch loaf pan.
+ In a mixing bowl, mash the ripe bananas with a fork until smooth.
+ Stir the melted butter into the mashed bananas.
+ Mix in the sugar, beaten egg, and vanilla extract.
+ Sprinkle the baking soda and salt over the mixture and stir.
+ Add the flour and mix until just combined — do not overmix.
+ Pour the batter into the prepared loaf pan.
+ Bake for 60–65 minutes, or until a toothpick inserted in the center comes out clean.
+ Let cool in the pan for a few minutes before turning out onto a wire rack.
+
+
Notes
+
The riper and blacker the bananas, the sweeter and more flavorful the bread will be!
+
+
+
+
+
+
+ Servings: 2
+ Prep: 20 minutes
+ Cook: 0 minutes
+
+
+
Ingredients
+
+ 1 head romaine lettuce (washed and torn)
+ 0.5 cup Parmesan cheese (freshly grated)
+ 1 cup croutons
+ 2 anchovy fillets (minced)
+ 1 garlic clove (minced)
+ 1 tsp Dijon mustard
+ 1 tsp Worcestershire sauce
+ 2 tbsp lemon juice
+ 0.5 cup mayonnaise
+ 0.25 cup Parmesan cheese (grated)
+ Salt and black pepper to taste
+
+
Instructions
+
+ Whisk together anchovies and garlic in a large bowl until a paste forms.
+ Add mustard and Worcestershire sauce; whisk to combine.
+ Whisk in lemon juice then mayonnaise until smooth and creamy.
+ Stir in 1/4 cup Parmesan and season generously with salt and pepper.
+ Add romaine lettuce and toss well to coat every leaf with dressing.
+ Top with croutons and remaining Parmesan. Serve immediately.
+
+
Notes
+
A classic Caesar salad. Use fresh Parmesan for best results.
+
+
+
+
+
+
+ Servings: 4
+ Prep: 30 minutes (plus 2 hours marinating)
+ Cook: 40 minutes
+
+
+
Ingredients
+
+ 1 cup plain yogurt
+ 2 tbsp lemon juice
+ 2 tsp cumin
+ 1 tsp cinnamon
+ 2 tsp cayenne pepper
+ 2 tsp black pepper
+ 1 tbsp minced fresh ginger
+ 1 tsp salt
+ 3 boneless chicken breasts, cut into bite-sized pieces
+ 3 tbsp butter
+ 1 clove garlic, minced
+ 1 jalapeno pepper, finely chopped
+ 2 tsp cumin
+ 2 tsp paprika
+ 1 tsp salt
+ 1 can (8 oz) tomato sauce
+ 1 cup heavy cream
+ 1/4 cup fresh cilantro, chopped
+
+
Instructions
+
+ Combine yogurt, lemon juice, and marinade spices in a bowl. Add the chicken, cover, and refrigerate for at least 2 hours (overnight preferred).
+ Thread marinated chicken onto skewers and grill or broil until slightly charred, about 5 minutes per side. Set aside.
+ Melt butter in a large heavy skillet over medium heat. Sauté garlic and jalapeno for 1 minute. Add cumin, paprika, and salt; stir for 1 minute.
+ Pour in the tomato sauce and simmer for 15 minutes, stirring occasionally.
+ Stir in heavy cream and simmer until the sauce thickens, about 10 minutes.
+ Add the grilled chicken to the sauce and simmer for 10 minutes.
+ Garnish with fresh cilantro and serve over basmati rice or with naan bread.
+
+
Notes
+
The longer you marinate the chicken, the more tender and flavorful it will be. Overnight marinating gives the best results.
+
+
+
+
+
+
+ Servings: 36 cookies
+ Prep: 15 min
+ Cook: 11 min
+
+
+
Ingredients
+
+ 2 1/4 cups all-purpose flour
+ 1 tsp baking soda
+ 1 tsp salt
+ 1 cup unsalted butter, softened
+ 3/4 cup granulated sugar
+ 3/4 cup packed brown sugar
+ 2 large eggs
+ 2 tsp vanilla extract
+ 2 cups chocolate chips
+ 1 cup chopped walnuts (optional)
+
+
Instructions
+
+ Preheat oven to 375°F (190°C). Line baking sheets with parchment paper.
+ Whisk together flour, baking soda, and salt in a bowl; set aside.
+ Beat butter and both sugars together until light and fluffy, about 3 minutes.
+ Add eggs one at a time, then mix in vanilla extract.
+ Gradually blend in the flour mixture until just combined.
+ Stir in chocolate chips and walnuts if using.
+ Drop rounded tablespoons of dough onto prepared baking sheets, spacing 2 inches apart.
+ Bake for 9–11 minutes or until golden brown. Cool on baking sheet for 2 minutes before transferring to wire rack.
+
+
Notes
+
For chewier cookies, refrigerate the dough for at least 1 hour before baking.
+
+
+
+
+
+
+ Servings: 4
+ Prep: 10 minutes
+ Cook: 20 minutes
+
+
+
Ingredients
+
+ 400 g spaghetti
+ 200 g pancetta or guanciale, diced
+ 4 large eggs
+ 100 g Pecorino Romano, finely grated
+ 100 g Parmigiano-Reggiano, finely grated
+ 2 cloves garlic
+ Salt and black pepper to taste
+
+
Instructions
+
+ Bring a large pot of salted water to a boil and cook spaghetti until al dente.
+ Meanwhile, cook the pancetta in a large skillet over medium heat until crispy, about 8 minutes.
+ In a bowl, whisk together the eggs and half the cheese. Season generously with black pepper.
+ Remove the skillet from heat. Add the drained pasta, tossing to coat in the pancetta fat.
+ Pour the egg mixture over the pasta, tossing quickly so the eggs don't scramble. Add pasta water a splash at a time to create a creamy sauce.
+ Serve immediately topped with remaining cheese and extra black pepper.
+
+
Notes
+
Do not add cream — traditional carbonara gets its creaminess from the eggs and cheese.
+
+
+
+
+
+Recipe Book © 2026 · Open in Safari on iPad for best experience
+
+