ARCHITECTURE
A graph, not an agent.
Springy is a deterministic pipeline. Every run takes the same path: opportunity → anchor → critique loop → human gate → parallel fan-out → critique-per-platform → human gate → draft writer → publish queue → API-or-browser publisher. No LLM is deciding which tool to call next. That's the entire design.
┌─ Discovery ─┐ │ RSS, briefings, competitor scrapes │ ↓ RANKED OPPORTUNITY (scored, persisted) │ ▼ ANCHOR ESSAY (generator + critique loop ×3) │ ╔═════╧═════╗ ║ HITL #1 ║ ← you approve / revise / abort ╚═════╤═════╝ │ ┌────────┬───────┼────────┬────────┐ ▼ ▼ ▼ ▼ ▼ LinkedIn Thread Bluesky Reel TikTok … (each: critique loop → humanize — in parallel) │ ╔═════╧═════╗ ║ HITL #2 ║ ← you pick which platforms ship ╚═════╤═════╝ │ DRAFT WRITER (writes to content/drafts/) │ ▼ PUBLISH QUEUE (SQLite, durable) │ ▼ API PUBLISHER → BROWSER FALLBACK (on auth / rate-limit errors) │ ▼ METRICS COLLECTOR (1h / 4h / 24h / 7d polls) │ ▼ feeds back into voice-RAG + variant scoring
Why a graph beats an agent.
Agents are great for exploration. They're not great at "do the same thing reliably every day, flag anything weird, never post something off-brand."
A graph is auditable: you can trace every decision back to a specific node. It's testable: each node is a pure function over the previous output. It's debuggable: the JSONL trace file records every step. And it's cheap: no extra tool-call round trips.
Springy does use LLMs inside each node — the critic is Claude Haiku or GPT-5-mini, the generator is Claude Sonnet 4.5 or GPT-5 — but the node boundaries are code. The LLM never picks what happens next.
The five nodes that do the work.
Discovery + Ranker
data/briefings/*.md, RSS feeds, and optional competitor scrapes. Ranks candidates by pillar fit, recency, and perceived novelty. Persists to the opportunities_ranked table.
Anchor + Critique Loop
CRITIQUE_BUDGET_USD cap prevents runaway spend.
Parallel Fan-out
Queue + Publisher
The voice layer.
The differentiator vs. a generic LLM wrapper is the brand-voice RAG. On voice:seed, springy embeds your approved past posts with Voyage-3 into a local LanceDB store. On every generation, it retrieves the 5 closest past examples for the current platform + pillar and injects them as few-shot context.
You're not writing in GPT-voice. You're extending your own voice. Even on a cold corpus (5-10 examples), the output shifts noticeably. The corpus grows every time you approve a new post.
Voice-RAG is strongly recommended but optional. Without it, the generator works from brand.yaml rules alone — usable, less distinctive. Springy will warn you loudly on startup if you haven't seeded.
What's stored where.
| Path | Contains | Safe to delete? |
|---|---|---|
| config/brand.yaml | Voice rules, pillars, handles, audience. | Back it up first. |
| config/.*-tokens.json | OAuth / API tokens per platform (0o600). | Yes — re-auth. |
| data/analytics.db | Queue, posts, metrics, variants, costs. | Loses history. |
| data/voice-rag/ | LanceDB vectors of your past posts. | Yes — re-seed. |
| data/traces/*.jsonl | One-line-per-event cascade trace. | Yes. Auto-pruned >90 days. |
| content/drafts/ | Generated platform-specific drafts. | Yes, if already published. |
Trust boundaries.
Everything runs on your laptop by default. The only network traffic is to (a) your LLM provider, (b) optional Voyage / Replicate / ElevenLabs, (c) the platforms you publish to. Telemetry is opt-in and has an aggressive sanitizer — no content, no paths, no token-shaped strings.
Plugins under plugins/ execute as trusted code. springy plugins:list reads a static plugin.json manifest only, so inventory is safe. Actual loading happens only when a cascade uses the plugin.
See SECURITY.md for the full threat model.