basecamp / recipes.py
jpanasuk's picture
v1.0.5 rev: boot ALWAYS rescans (stale-config fix), tavern status lists ALL discovered services with link:port, SOUL tells agent to use tavern status for service questions
e725fbe
Raw
History Blame Contribute Delete
24.5 kB
#!/usr/bin/env python3
"""Basecamp wiring recipes β€” per-service knowledge for fixing and wiring
the AI services Basecamp discovers. Single source of truth for both the
stack skill (inline recipes for discovered services) and the full
references/wiring-recipes.md written into the container's HERMES_HOME.
Each recipe has:
where β€” where the service's configuration lives (env / file / UI / API)
keys β€” the exact config keys / env vars / API endpoints involved
fix β€” the one-line (or one-step) fix to wire it correctly
verify β€” how to confirm the wiring actually works
"""
WIRING_RECIPES = {
# ── Inference engines ──
"ollama": {
"where": "Container env vars + CLI. Clients connect to :11434 (OpenAI-compatible /v1).",
"keys": "OLLAMA_HOST, OLLAMA_MODELS, OLLAMA_FLASH_ATTENTION, OLLAMA_KV_CACHE_TYPE; API: GET /api/tags, POST /api/copy, /v1/chat/completions",
"fix": "Set OLLAMA_HOST=http://0.0.0.0:11434 to accept remote clients. PERF (verified 2026-08-10): do NOT set OLLAMA_CONTEXT_LENGTH globally β€” a forced 64K pre-allocates a ~6GB KV cache on EVERY load, starving weights of VRAM and dropping speed ~9x (8B: 9 tok/s vs 80). Use OLLAMA_FLASH_ATTENTION=1 + OLLAMA_KV_CACHE_TYPE=q8_0; ollama sizes KV per-request. Hermes's context_length config is an advertised ceiling, not a per-request demand.",
"verify": "curl http://<host>:11434/api/tags returns {\"models\": [...]}; benchmark: ollama run <model> --verbose shows eval rate (expect 60-100 tok/s for an 8B on a 12GB GPU with FA).",
"pitfalls": "VERIFIED 2026-08-10: forced 64K = 6GB KV cache = 8B mostly on CPU (9 tok/s). Removing it + FA + q8 KV = 80 tok/s, 33/33 layers on GPU, KV 272MB. Check offload: docker logs <ollama> | grep offloaded. Keep models resident with keep_alive to avoid reload penalties.",
},
"tabbyapi": {
"where": "config.yml (model_dir, model_name, max_seq_len) + api_tokens.yml (api_key, admin_key), both mounted from the host.",
"keys": "config.yml: model.model_name, model.max_seq_len, model.cache_8bit; api_tokens.yml: api_key, admin_key; API: /v1/models, /v1/chat/completions (Bearer)",
"fix": "Edit config.yml to point model.model_name at a model present in model_dir, restart the container. Clients use Bearer <api_key> against http://<host>:5000/v1. max_seq_len is VRAM-bound (64K KV cache needs ~16GB for 8B).",
"verify": "curl -H 'Authorization: Bearer <api_key>' http://<host>:5000/v1/models returns the loaded model list.",
},
"vllm": {
"where": "CLI launch flags (no config file).",
"keys": "--model, --served-model-name, --host, --port, --max-model-len; API: /v1/models, /v1/chat/completions",
"fix": "Relaunch with --host 0.0.0.0 --port 8000 --served-model-name <name> so the served name matches what clients request. Hermes needs --max-model-len >= 65536.",
"verify": "curl http://<host>:8000/v1/models lists the served model id.",
},
"litellm": {
"where": "config.yaml (model_list) + master key env var.",
"keys": "model_list: [{model_name, litellm_params: {model, api_base, api_key}}]; env: LITELLM_MASTER_KEY; API: /v1/models, /v1/chat/completions (Bearer)",
"fix": "Add the upstream model to model_list with correct api_base, restart. Clients use Bearer <master_key> against http://<host>:4000/v1.",
"verify": "curl -H 'Authorization: Bearer <key>' http://<host>:4000/v1/models lists configured model_names.",
},
"localai": {
"where": "Env vars + model files in the models directory.",
"keys": "MODELS_PATH, THREADS, DEBUG; API: /v1/models, /v1/chat/completions (OpenAI-compatible)",
"fix": "Drop GGUF models into the models dir (or set MODELS_PATH), restart. Clients use http://<host>:8080/v1 (or the mapped port).",
"verify": "curl http://<host>:8080/v1/models lists models.",
},
"llamacpp": {
"where": "CLI flags (llama-server).",
"keys": "-m <model>, --host, --port, -c <context>, --api-key; API: /health, /v1/models, /v1/chat/completions",
"fix": "Relaunch with --host 0.0.0.0 so other containers can reach it; -c 65536 for Hermes. If --api-key is set, clients must send it.",
"verify": "curl http://<host>:<port>/health returns 'ok'.",
},
"text-generation-webui": {
"where": "Settings files + runtime API (model loaded per-session).",
"keys": "API: GET/POST /api/v1/model (load model), /v1/chat/completions; settings in settings.yaml",
"fix": "POST /api/v1/model with {\"model_name\": \"<model>\"} to load a model; clients use http://<host>:5000/v1.",
"verify": "curl http://<host>:5000/api/v1/model returns the loaded model_name.",
},
"koboldcpp": {
"where": "CLI flags.",
"keys": "--model, --host, --port, --contextsize; API: /api/v1/model, /v1/models, /v1/chat/completions",
"fix": "Relaunch with --host 0.0.0.0 and --contextsize 65536 for Hermes. Clients use http://<host>:5001/v1.",
"verify": "curl http://<host>:5001/api/v1/model returns a result with the model name.",
},
"lmstudio": {
"where": "In-app GUI only (no server-side config file for the API).",
"keys": "Settings β†’ Developer β†’ Local Server (port 1234); API: /v1/models, /v1/chat/completions",
"fix": "The user must enable the local server in the app GUI (Settings β†’ Developer β†’ Start Server). No remote fix exists β€” explain the 3 clicks.",
"verify": "curl http://<host>:1234/v1/models lists loaded models.",
},
"sglang": {
"where": "CLI flags.",
"keys": "--model-path, --host, --port, --served-model-name; API: /v1/models, /v1/chat/completions",
"fix": "Relaunch with --host 0.0.0.0 --served-model-name <name> matching what clients request; --context-length >= 65536 for Hermes.",
"verify": "curl http://<host>:8002/v1/models (response contains 'sglang').",
},
"llamafile": {
"where": "CLI/env of the single-file binary.",
"keys": "-ngl <layers> (GPU offload), --host, --port; API: /v1/models, /v1/chat/completions",
"fix": "Launch with --host 0.0.0.0 (default is localhost only). Clients use http://<host>:8080/v1.",
"verify": "curl http://<host>:8080/v1/models lists the model.",
},
"exo": {
"where": "config.yaml + peer discovery.",
"keys": "config.yaml: api_port, peers; API: /v1/models, /v1/chat/completions",
"fix": "Ensure api_port is reachable on the docker network; add peer IPs to config.yaml. Clients use http://<host>:8000/v1.",
"verify": "curl http://<host>:8000/v1/models lists the cluster's models.",
},
"text-generation-inference": {
"where": "CLI flags (launcher).",
"keys": "--model-id, --port, --hostname, --max-total-tokens; API: /v1/models, /info",
"fix": "Relaunch with --hostname 0.0.0.0 --model-id <hf-repo>. Clients use http://<host>:8080/v1.",
"verify": "curl http://<host>:8080/info returns model_id.",
},
"aphrodite": {
"where": "CLI flags (vLLM-style).",
"keys": "--model, --served-model-name, --host, --port; API: /v1/models, /v1/chat/completions",
"fix": "Relaunch with --host 0.0.0.0 and --served-model-name matching client requests.",
"verify": "curl http://<host>:8000/v1/models (response contains 'aphrodite').",
},
"llamapool": {
"where": "config.toml.",
"keys": "[routes] entries mapping route names to upstream endpoints",
"fix": "Add/edit a [routes] entry pointing at the upstream inference server, restart. Clients use the route name as the model id.",
"verify": "curl http://<host>:<port>/v1/models lists route names.",
},
# ── UIs / chat frontends ──
"open-webui": {
"where": "Container env vars (primary) + admin Settings β†’ Connections (runtime override).",
"keys": "OLLAMA_BASE_URL, OPENAI_API_BASE_URL, OPENAI_API_KEY; admin UI: Settings β†’ Connections; API: GET /api/config (public), /api/models (auth), /api/v1/auths/signin",
"fix": "Set OLLAMA_BASE_URL=http://ollama:11434 (and/or OPENAI_API_BASE_URL + key) in the container env, OR have the admin set it in Settings β†’ Connections. This is THE buried needle-in-a-haystack setting β€” name it explicitly.",
"verify": "curl http://<host>:8080/api/config returns status:true; authenticated GET /api/models lists engine models.",
},
"sillytavern": {
"where": "config.yaml (config dir mounted) for server auth; per-user API config in the UI.",
"keys": "config.yaml: listen, whitelist, basicAuthUser, basicAuthPassword; UI: extensions β†’ connection settings β†’ API URL + model; API: /api/status (401 = alive behind auth)",
"fix": "Server auth lives in config.yaml (default admin:<password>). The API URL is per-user: UI β†’ extensions β†’ connection settings β†’ set to http://<engine>:<port>/v1 and pick the model.",
"verify": "curl -u admin:<pw> http://<host>:8000/api/status returns JSON.",
},
"librechat": {
"where": "librechat.yaml (endpoints/agents) + .env.",
"keys": "librechat.yaml: endpoints.agents.<name> {provider, baseURL, apiKey}; .env: END_USER_* vars; API: /api/health",
"fix": "Add an endpoints.agents block with provider=openai (or custom), baseURL=http://<engine>:<port>/v1, apiKey. Restart. Set in the UI's Admin β†’ Providers as an alternative.",
"verify": "curl http://<host>:3080/api/health returns ok; the model appears in the UI model list.",
},
"lobechat": {
"where": "In-app settings (model providers) + env vars.",
"keys": "UI: Settings β†’ Language Model β†’ providers; env: OPENAI_API_KEY etc; API: /api/status",
"fix": "In the UI: Settings β†’ Language Model β†’ add provider with base URL http://<engine>:<port>/v1 + key (if any). No server-side config file.",
"verify": "curl http://<host>:3001/api/status returns 200; model selectable in chat.",
},
"anythingllm": {
"where": "In-app Settings (LLM provider + vector DB) + .env.",
"keys": "UI: Settings β†’ LLM Preference β†’ provider + base URL + key; Settings β†’ Vector Database; API: /api/system/endpoints",
"fix": "In the UI: Settings β†’ LLM Preference β†’ pick Ollama/OpenAI, set base URL to the engine, set the key. Vector DB similar (defaults to LanceDB β€” fine for noobs).",
"verify": "curl http://<host>:3001/api/system/endpoints lists configured endpoints.",
},
"dify": {
"where": ".env file (backend) + per-app model config in UI.",
"keys": ".env: OPENAI_API_KEY, OPENAI_API_BASE; UI: Settings β†’ Model Provider; API: /health",
"fix": "Set OPENAI_API_BASE + OPENAI_API_KEY in .env (or use the UI Model Provider page) to point at the engine, restart. Model per-app: Settings β†’ Model Provider β†’ add.",
"verify": "curl http://<host>/health returns healthy.",
},
"flowise": {
"where": "UI credentials store + env.",
"keys": "UI: Settings β†’ Credentials; API: /api/v1/ping, /api/v1/credentials",
"fix": "UI: Settings β†’ Credentials β†’ add the provider credential with base URL + key. Nodes then reference it. No server-side wiring file.",
"verify": "curl http://<host>:3000/api/v1/ping returns pong.",
},
"n8n": {
"where": "UI credentials store + env (auth).",
"keys": "env: N8N_BASIC_AUTH_USER, N8N_BASIC_AUTH_PASSWORD; UI: Credentials β†’ OpenAI/Ollama; API: /healthz",
"fix": "UI: Credentials β†’ add the AI provider credential (base URL + key) and reference it in nodes. Set basic auth envs if exposed publicly.",
"verify": "curl http://<host>:5678/healthz returns ok.",
},
"langflow": {
"where": ".env + UI components.",
"keys": "UI: Settings β†’ Global Variables; env: OPENAI_API_KEY etc; API: /api/v1/configs",
"fix": "UI: Settings β†’ Global Variables β†’ add the provider base URL + key; use them in component nodes.",
"verify": "curl http://<host>:7860/api/v1/configs returns 200 (or auth wall).",
},
"ragflow": {
"where": "service_conf.yaml + .env; models configured in UI.",
"keys": ".env: MODEL providers; service_conf.yaml: es/storage; UI: Settings β†’ Model providers; API: /api/v1/version",
"fix": "UI: Settings β†’ Model providers β†’ add the engine (base URL + key). Backend needs the configured embedding+chat models.",
"verify": "curl http://<host>/api/v1/version returns a version.",
},
"koboldai": {
"where": "Settings files in the data dir.",
"keys": "API: /api/v1/config/status, /api/v1/model",
"fix": "Legacy app; connect by setting the API URL in the client to http://<host>:5000/v1 and loading a model via its UI.",
"verify": "curl http://<host>:5000/api/v1/config/status returns config.",
},
# ── Vector DBs ──
"qdrant": {
"where": "config.yaml or QDRANT__* env vars.",
"keys": "QDRANT__SERVICE__API_KEY, QDRANT__SERVICE__GRPC_PORT; API: /readyz, /collections",
"fix": "Set API key via env if auth needed; ensure clients use http://<host>:6333. Point your RAG app's vector DB URL here.",
"verify": "curl http://<host>:6333/readyz returns ok.",
},
"milvus": {
"where": "milvus.yaml + etcd dependency.",
"keys": "milvus.yaml: etcd.endpoints, proxy port 19530; API: /healthz (http :9091)",
"fix": "Milvus needs etcd running and reachable; point your RAG app at grpc://<host>:19530.",
"verify": "curl http://<host>:9091/healthz returns {\"status\":\"OK\"}.",
},
"chroma": {
"where": "Env vars.",
"keys": "CHROMA_SERVER_HOST, CHROMA_SERVER_PORT, CHROMA_AUTH_*; API: /api/v2/health",
"fix": "Set CHROMA_SERVER_HOST=0.0.0.0 to accept remote clients; point your RAG app at http://<host>:8000.",
"verify": "curl http://<host>:8000/api/v2/health returns 200.",
},
"weaviate": {
"where": "Env vars.",
"keys": "PERSISTENCE_DATA_PATH, AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED, AUTHENTICATION_APIKEY_ENABLED + APIKEY_ALLOWED_KEYS; API: /v1/meta",
"fix": "Enable anonymous access or set an API key via env; point your RAG app at http://<host>:8081.",
"verify": "curl http://<host>:8081/v1/meta returns version + model info.",
},
# ── Image / media ──
"comfyui": {
"where": "extra_model_paths.yaml (model folder locations).",
"keys": "extra_model_paths.yaml: checkpoints, loras, vae paths; API: /system_stats, /prompt (POST)",
"fix": "Edit extra_model_paths.yaml to point at the real model dirs (or use the default structure), restart. Clients POST workflows to /prompt.",
"verify": "curl http://<host>:8188/system_stats returns GPU/RAM stats.",
},
"stable-diffusion-webui": {
"where": "webui-user.sh (COMMANDLINE_ARGS) + models dir.",
"keys": "webui-user.sh: COMMANDLINE_ARGS=--api; API: /sdapi/v1/options, /sdapi/v1/txt2img",
"fix": "Add --api to COMMANDLINE_ARGS and restart, or the API endpoints 404. Model picker via /sdapi/v1/options (sd_model_checkpoint).",
"verify": "curl http://<host>:7860/sdapi/v1/options returns options JSON.",
},
"invokeai": {
"where": "invokeai.yaml + UI model manager.",
"keys": "invokeai.yaml: host, port; API: /api/v1/app/version",
"fix": "Ensure host is 0.0.0.0 in invokeai.yaml; models added via the UI Model Manager (Auto-scan a folder).",
"verify": "curl http://<host>:9090/api/v1/app/version returns a version.",
},
# ── Speech ──
"whisper": {
"where": "CLI flags of the whisper server (faster-whisper-server / whisper.cpp).",
"keys": "--host, --port, --model (size); API: /v1/audio/transcriptions (OpenAI-compatible)",
"fix": "Launch with --host 0.0.0.0; point clients at http://<host>:<port>/v1. Pick model size for the GPU.",
"verify": "curl http://<host>:<port>/health returns ok.",
},
"piper": {
"where": "CLI flags of the TTS server.",
"keys": "--host, --port; API: / (synthesize GET/POST)",
"fix": "Launch with --host 0.0.0.0; point clients at http://<host>:<port> with the voice model.",
"verify": "curl http://<host>:<port>/health returns ok.",
},
# ── Gateways / proxies / hubs ──
"openrouter": {
"where": "Cloud API β€” key only.",
"keys": "API key; base https://openrouter.ai/api/v1",
"fix": "Clients send Bearer <key> to https://openrouter.ai/api/v1. No local config.",
"verify": "curl -H 'Authorization: Bearer <key>' https://openrouter.ai/api/v1/models works.",
},
"kobold-horde": {
"where": "Cloud crowd API β€” key optional.",
"keys": "API key (optional); API: /api/v1/status, /api/v1/generate",
"fix": "Clients set the horde URL to http://<host>:2323 (or the public horde) and optionally their kudos key.",
"verify": "curl http://<host>:2323/api/v1/status returns queue stats.",
},
# ── Basecamp's own companions ──
"mcpo": {
"where": "config.json (mounted) β€” the MCP server list.",
"keys": "config.json: servers [{name, transport, route, api_key}]; API: /openapi.json, /mcp (SSE)",
"fix": "Edit mcpo/config.json to add/point MCP servers (name + route + key), restart the container. Basecamp connects to it via SSE at /mcp.",
"verify": "curl http://<host>:8000/openapi.json lists the proxied MCP server routes.",
},
"searxng": {
"where": "settings.yml (mounted) β€” search engines + formats.",
"keys": "settings.yml: search.formats (json), engines list; API: /search?q=...&format=json",
"fix": "Ensure 'json' is in search.formats in settings.yml or the JSON API returns 400. Engines added/removed in the engines section.",
"verify": "curl http://<host>:8080/ returns the SearXNG page.",
},
# ── Coding starter pack ──
"code-server": {
"where": "Container env + entrypoint extension installs. The IDE itself is VS Code in the browser.",
"keys": "env: PASSWORD (login); entrypoint: code-server --install-extension Continue.continue / TabbyML.vscode-tabby; API: /healthz, / (web UI)",
"fix": "Login with the PASSWORD env at http://<host>:8443. Continue.dev and the Tabby extension are pre-installed in the Basecamp pack β€” configure Continue's model to http://ollama:11434/v1 (or tabbyapi:5000/v1) in its config.json, and point the Tabby extension at http://<host>:8082.",
"verify": "curl http://<host>:8443/healthz returns 200; the browser UI loads at :8443.",
"pitfalls": "Extension installs run on first container start β€” give it ~60s before the IDE is fully ready. Login password comes from the PASSWORD env (default basecamp) β€” change it.",
},
"tabby": {
"where": "CLI flags at container start + /data volume for models.",
"keys": "command: serve --device cpu|--device cuda --model <name> --chat-model <name>; env: TABBY_MODEL_CACHE_DIR; API: /v1/health, /v1/completions (code completion)",
"fix": "DO NOT USE in CPU-only containers β€” BROKEN as of 2026-08-09 (verified on 20260330 pin): tabby's bundled llama-server dlopens libcuda.so.1 at startup EVEN with --device cpu (binary is CUDA-compiled), and crashes with exit 127 'cannot open shared object file' when the container lacks NVIDIA libs. Needs --gpus all + CUDA libs. Removed from the starter pack; re-add only with GPU access or a true CPU-only tabby build.",
"verify": "curl http://<host>:8082/v1/health β€” expected to FAIL in CPU-only containers until tabby ships a CPU-only build.",
"pitfalls": "VERIFIED 2026-08-09: (1) 'latest' images spawn Nomic-Embed with -ngl 9999 β†’ exit 127 on constrained GPUs; (2) even the 20260330 pin crash-loops in CPU-only containers: llama-server dlopens libcuda.so.1 β†’ 'cannot open shared object file' β†’ exit 127. (3) StarCoder2-3B is completion-only (no chat template) β€” a separate instruct model is required for --chat-model. Ripped out of the pack.",
},
"meilisearch": {
"where": "Env vars.",
"keys": "MEILI_MASTER_KEY, MEILI_ENV; API: /health, /indexes, /search",
"fix": "Set MEILI_MASTER_KEY so clients can authenticate; index your code/docs via the API and search at /search with the key.",
"verify": "curl http://<host>:7700/health returns {\"status\":\"available\"}.",
},
"librechat": {
"where": "Env vars + REQUIRES MongoDB (not self-contained).",
"keys": "MONGO_URI (required), OPENAI_API_KEY, OPENAI_API_BASE; API: /api/health",
"fix": "LibreChat CRASHES on boot without MONGO_URI (verified 2026-08): 'Please define the MONGO_URI environment variable'. It needs a mongo container β€” the Basecamp pack ships one (starter-mongo, mongo:7) and sets MONGO_URI=mongodb://mongo:27017/LibreChat. Image lives on GHCR (ghcr.io/danny-avila/librechat), NOT Docker Hub.",
"verify": "curl http://<host>:3080/api/health returns ok AFTER mongo is up.",
"pitfalls": "Image is ghcr.io/danny-avila/librechat:latest β€” the Docker Hub 'danny-avila/librechat' repo 404s. First boot takes ~60s. Depends on mongo; start mongo first (depends_on in compose).",
},
"flowise": {
"where": "Env vars (PORT, FLOWISE_USERNAME, FLOWISE_PASSWORD) + /root/.flowise volume.",
"keys": "PORT=3000, FLOWISE_USERNAME, FLOWISE_PASSWORD; API: /api/v1/ping",
"fix": "DO NOT USE β€” BROKEN UPSTREAM as of 2026-08-09 (verified on latest AND 3.1.4): crashes at boot with ERR_PACKAGE_PATH_NOT_EXPORTED β€” @langchain/langgraph-checkpoint requires './utils/uuid' which @langchain/core doesn't export. Removed from the starter pack. Re-add only when upstream fixes their dependency tree.",
"verify": "curl http://<host>:7860/api/v1/ping β€” expected to FAIL until upstream fixes the langchain dep.",
"pitfalls": "KNOWN BUG (verified 2026-08-09, both latest and 3.1.4): ERR_PACKAGE_PATH_NOT_EXPORTED './utils/uuid' in @langchain/core crashes node at boot (ReActAgentChat/ReActAgentLLM nodes). Not a compose problem β€” the image's dependency tree is broken. Ripped out of the pack.",
},
"postgres": {
"where": "Container env (db/user/password) + data volume.",
"keys": "POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD; port 5432; pgvector extension for embeddings",
"fix": "This is Hermes's long-term memory backend (Postgres + pgvector). Point hermes at it: hermes memory configure --backend postgres --dsn 'postgresql://<user>:<pass>@starter-postgres:5432/<db>'. The pgvector image ships the extension β€” create it with: docker exec starter-postgres psql -U hermes -d hermes -c 'CREATE EXTENSION IF NOT EXISTS vector;'",
"verify": "docker exec starter-postgres pg_isready -U hermes -d hermes returns 'accepting connections'.",
},
"mongo": {
"where": "Container env + /data/db volume. Database for LibreChat (and other apps).",
"keys": "MONGO_URI=mongodb://mongo:27017/<db>; port 27017; image mongo:7",
"fix": "Point dependent apps at mongodb://<host>:27017/<dbname>. LibreChat requires MONGO_URI or it crashes on boot (verified 2026-08). No auth by default on the pack's instance β€” add a root user for anything public.",
"verify": "docker exec starter-mongo mongosh --quiet --eval 'db.runCommand({ping:1}).ok' returns 1.",
},
}
def wiring_recipe_markdown(service_types=None):
"""Render WIRING_RECIPES as markdown, optionally filtered to a type list.
Returns a list of markdown lines (no trailing newline joining) ready to
be embedded in a SKILL.md or written as a standalone reference file.
"""
types = service_types or list(WIRING_RECIPES.keys())
lines = []
for t in sorted(types):
r = WIRING_RECIPES.get(t)
if not r:
continue
lines.append(f"### {t}")
lines.append(f"- **Config lives in:** {r['where']}")
lines.append(f"- **Keys/endpoints:** {r['keys']}")
lines.append(f"- **Fix:** {r['fix']}")
lines.append(f"- **Verify:** {r['verify']}")
if r.get("pitfalls"):
lines.append(f"- **Pitfalls (verified):** {r['pitfalls']}")
lines.append("")
return lines