Turn one long-form asset into a full week of channel-native content — in a defined brand voice, with the source timecode attached to every piece.
🔗 Live demo — paste your own transcript, no signup, no API key (the demo runs the entire engine in your browser; nothing you paste leaves your machine)
Founders and marketing teams record podcasts, webinars, customer interviews and conference talks. Then they publish each one once, and the other twenty ideas inside that recording die in a Google Drive folder.
Repurposing is the highest-ROI content work there is, and almost nobody does it, for one boring reason: it is four hours of scrubbing through a recording trying to remember where the good part was. The thinking already happened in the room. What is missing is the machinery that turns 90 minutes of raw material into channel-native assets a human can approve in ten minutes.
contentforge is that machinery:
- In: a transcript, a caption file (
.srt/.vtt), or a long blog post. - Out: 3 LinkedIn posts (story / listicle / contrarian), an 8-12 post X thread, a newsletter section with subject-line options, 5 short-form video scripts with hooks, beats, on-screen text, B-roll notes and the exact source timecode to cut, a YouTube title/description/chapter package, 5 quote cards, and a 7-day publishing calendar — plus markdown / JSON / CSV exports.
- Every asset is checked against real platform limits and scored against a brand voice profile, with every violation surfaced and explained.
| Buyer | The job it does |
|---|---|
| Content agencies | Turn a per-recording retainer into a per-channel retainer. One recording becomes a deliverable pack a client can see the value of. |
| Podcasters and video creators | Stop publishing once. Clip suggestions come with timecodes, so the editor's job goes from "watch 90 minutes" to "cut these five ranges". |
| Founder-led B2B brands | The founder's actual opinions, in the founder's actual voice, on five channels a week, without the founder writing five posts a week. |
| Video editors upselling retainers | Add "and we'll write the posts" to an editing retainer with no extra headcount. |
| In-house marketing teams of one | The single-person content team's capacity problem is triage, not writing. This does the triage. |
- 1 recording → 20+ publishable assets in minutes (16 assets + a 7-day schedule from a normal-length recording; the count is configurable, and a very short source yields fewer quote cards rather than padded ones).
- Editor time per clip: ~40 min → ~5 min. The clip list arrives as real timecode ranges with a scored justification.
- Zero platform-limit failures. Every asset is validated against LinkedIn's 3,000 characters, X's 280 per post, YouTube's 100-character titles, the 60-character on-screen caption budget, and so on.
- Voice compliance is a number, not an opinion. Every asset carries a 0-100 score against the client's profile, with the exact phrase that broke the rule.
flowchart TD
A["Source<br/>.txt · .md · .srt · .vtt · pasted text"] --> B[Parser]
B -->|"cues + timecodes"| C["Segmenter<br/>vocabulary-drift boundaries"]
C --> D["Insight extraction<br/>claims · stats · stories · contrarian · quotes"]
C --> E["Clip scorer<br/>emotion · specificity · story · standalone · length"]
D --> F[Brief]
E --> F
G["Brand voice profile<br/>tone · banned words · reading level · CTA · POV"] --> F
F --> H{"LLMProvider"}
H -->|"DEMO_MODE=true (default)"| I["MockProvider<br/>deterministic extractive composition"]
H -->|"DEMO_MODE=false + API key"| J["AnthropicProvider<br/>claude-sonnet-5, tool-use structured output"]
I --> K[Generators]
J --> K
K --> L["LinkedIn ×3"]
K --> M["X thread"]
K --> N["Newsletter"]
K --> O["Short-form ×5 (timecoded)"]
K --> P["YouTube package"]
K --> Q["Quote cards ×5"]
L & M & N & O & P & Q --> R["Voice checker<br/>0-100 + violations"]
R --> S["Platform constraint checks"]
S --> T["7-day calendar<br/>per-channel cadence + optimal times"]
T --> U["Exports<br/>markdown · JSON · scheduler CSV · one file per asset"]
T --> V["SQLite pack store"]
Both provider paths share the same JSON Schema and the same validator, so the demo output and the production output are structurally identical — only the prose differs.
git clone https://github.com/wasay-09/contentforge.git
cd contentforge
npm install
# 1. Look at what the analyser sees (no key needed, nothing is sent anywhere)
npm run cli -- analyse fixtures/founder-podcast.srt
# 2. Generate a full pack to ./out
npm run cli -- forge fixtures/founder-podcast.srt --voice blunt-technical-founder --out ./out
open out/pack.md # markdown pack, JSON, schedule.csv, calendar.csv, one file per asset
# 3. Or run the web app
npm run build && npm start # http://localhost:3000Everything above works with no API key and no network access. That is the default, not a fallback.
# Optional: use Claude for the copywriting instead of the deterministic engine
cp .env.example .env
# set DEMO_MODE=false and ANTHROPIC_API_KEY=sk-ant-...
npm startDocker:
docker compose up --build # http://localhost:3000DEMO_MODE=true is the default. In this mode a MockProvider composes every asset extractively from the real analysis — it selects the strongest quotes, statistics, anecdotes and contrarian takes out of the actual source, arranges them into channel-native structures, applies the brand voice's connectives and CTA style, strips banned phrasing, and enforces platform limits.
It is not lorem ipsum and it is not a stub. It is the same code path, the same schema and the same validator the Claude path uses — which is exactly why it is the default: you can evaluate the whole product, on your own transcript, before spending a cent.
With DEMO_MODE=false and ANTHROPIC_API_KEY set, AnthropicProvider asks claude-sonnet-5 for the same structures via forced tool use, validates the response against the same schema, repairs malformed output by feeding the validation error back to the model, retries transient API failures with backoff, and falls back to the deterministic composition if the model cannot produce valid output — so a client pack is never empty.
Three profiles ship in src/voice/profiles.ts: blunt-technical-founder, warm-b2c-brand, formal-enterprise. A profile is plain data:
| Field | Type | What it does |
|---|---|---|
id / name / description |
string | Identity. description is fed to the model in the system prompt. |
tone |
string[] |
Tone adjectives, surfaced in the system prompt. |
bannedWords |
string[] |
Phrases that must never appear. Matched case-insensitively; each hit is an error in the compliance report and is stripped from borrowed source text by the deterministic composer. |
preferredVocabulary |
string[] |
Words the brand reaches for. Presence raises the compliance score (max +6). |
readingLevel.targetGrade / .tolerance |
number | Flesch-Kincaid target. Drift beyond tolerance is a warning; beyond 2× tolerance it is an error. |
emoji.policy / .maxPerAsset |
"none" | "sparing" | "liberal", number |
Hard cap on emoji per asset. none also strips emoji from borrowed text. |
cta.style / .examples |
"direct" | "soft" | "question" | "none", string[] |
CTA voice. The deterministic composer rotates the examples; the model is shown them as the target feel. |
pov |
first-person-singular | first-person-plural | second-person | third-person |
Drives connective phrasing ("Here's what actually happened" vs "What we observed") and a POV compliance check. |
sentenceMaxWords |
number | Sentences above this are flagged. |
formatting.allowHashtags / maxHashtags / allowExclamations / allowAllCaps |
boolean/number | Formatting policy, enforced by the checker. |
The compliance checker returns { score, grade, violations[], metrics }. Score starts at 100, loses 12 per error and 5 per warning, gains up to 6 for preferred vocabulary. grade is on-voice (≥85), close (≥70) or off-voice.
Adding a client voice is a 5-minute job — see DEPLOY.md § (d).
Short-form suggestions are only useful if you can see why something was suggested. The score is a transparent weighted sum of five normalised signals (src/analysis/clipScore.ts):
| Signal | Weight | Measures |
|---|---|---|
specificity |
0.26 | Numbers, named entities and concrete business nouns per word. |
emotion |
0.22 | Density of high-arousal language. |
standalone |
0.22 | Whether it survives being dropped into a feed with zero context (penalises anaphora and pronoun density). |
story |
0.18 | Density of narrative markers ("one day", "turns out", "we tried"). |
length |
0.12 | Proximity to a comfortable 30-75 second read (full marks 60-180 words). |
Every segment returns its components and a plain-English explanation of what drove the number. The word lists live in src/analysis/lexicon.ts and are meant to be edited per client domain.
| Channel | Assets | Constraints enforced | Timecoded |
|---|---|---|---|
| 3 posts: story, listicle, contrarian | 3,000 char post limit; hook must stand alone in the 210-char pre-fold window | ✅ source range | |
| X / Twitter | 1 thread, 8-12 posts | 280 chars per post including the n/m counter |
✅ source range |
| Newsletter | 1 section + 5 subject lines + preview | 60-char subjects, 100-char preview, ~350-word body | ✅ source range |
| Short-form video | 5 scripts: 3-second hook, beats, on-screen text, B-roll, CTA, target duration | 60-second runtime, 60-char on-screen captions | ✅ exact cut range |
| YouTube | 5 title options + description + chapters | 100-char titles, 5,000-char description, first chapter pinned to 00:00 |
✅ chapters from real cues |
| Quote cards | 5 cards + visual treatment | 220 chars | ✅ interpolated to the sentence |
Sources without timecodes (blog posts) still get chapters and clip suggestions — derived from spoken-word pacing and clearly labelled as estimated rather than presented as exact.
| Format | Command / endpoint | Shape |
|---|---|---|
| Markdown pack | out/pack.md, GET /api/packs/:id/export/md |
Client-ready document: analysis table, every asset, the calendar. |
| JSON | out/pack.json, .../export/json |
The full ContentPack object — analysis, assets, voice reports, constraints, calendar. |
| Scheduler CSV | out/schedule.csv, .../export/csv |
Buffer / Hypefury-shaped: channel, scheduled_at_utc, scheduled_local, timezone, text, title, asset_id, format, voice_score, source_timecode. |
| Calendar CSV | out/calendar.csv, .../export/calendar.csv |
Day-by-day plan with the rationale for each slot. |
| One file per asset | out/assets/<channel>/NN-<format>-<slug>.txt |
For handing individual pieces to individual people. |
| Method | Path | Purpose |
|---|---|---|
GET |
/ |
Web UI |
GET |
/health |
Status, active provider, available voices |
GET |
/api/voices |
Voice profiles |
GET |
/api/samples, /api/samples/:id |
Bundled sample sources |
POST |
/api/analyse |
{source | sampleId, filename?} → parse + segment analysis only |
POST |
/api/forge |
{source | sampleId, voice?, timezone?, startDate?, filename?} → full pack (persisted) |
POST |
/api/voice-check |
{text, voice?} → compliance report for arbitrary copy |
GET |
/api/packs, /api/packs/:id, DELETE /api/packs/:id |
Pack store |
GET |
/api/packs/:id/export/:format |
md · json · csv · calendar.csv |
- Voice capture (half a day). Take 10-20 pieces of the client's best-performing existing copy. Write the profile: banned phrases come from what makes them wince, preferred vocabulary from what they actually say, reading level from measuring their own posts. Commit it as
src/voice/profiles.tsentry or a JSON file in their repo. - Calibrate the lexicon (1 hour).
src/analysis/lexicon.tsships a general business vocabulary. Add the client's domain nouns toCONCRETE_MARKERSso specificity scoring recognises their world (SKU, cohort, admission, claim, dispatch…). - Deploy.
docker compose up -don their box, or Railway/Render/Fly from DEPLOY.md. Demo mode is fine for the pilot; switch onANTHROPIC_API_KEYwhen they want the copy polished by a model. - Wire the intake. Their editor drops the transcript (most transcription tools export
.srt/.vtt) into the UI or posts it to/api/forge. Pack lands in SQLite with a permalink. - Review loop. The client reviews the markdown pack. Anything scoring below 85 on voice gets read first — that is where the checker is telling you the profile and the copy disagree. Feed the corrections back into the profile; the score is the retro.
- Publishing. Export the scheduler CSV into Buffer/Hypefury, or hand the per-asset files to whoever owns each channel. The short-form scripts go to the editor with the timecodes already attached.
Retainer framing that works: the pilot is one recording, priced as a one-off. The retainer is per-recording thereafter, because the value scales with volume and the setup cost is paid once.
npm run typecheck # tsc --noEmit, strict
npm test # vitest
npm run verify:demo # rebuild docs/index.html from fixtures and run its engine headlessly
npm run check # all threeLayout:
src/parse/ transcript + caption parsing (SRT/VTT/markdown/text)
src/analysis/ segmentation, insight extraction, clip scoring, lexicon
src/voice/ brand voice profiles + compliance checker
src/generators/ one module per channel + shared brief and platform constraints
src/llm/ LLMProvider interface, MockProvider, AnthropicProvider
src/schedule/ 7-day calendar with per-channel cadence and timezone maths
src/export/ markdown, JSON, CSV, per-asset files
src/db/ SQLite pack store
src/web/ web UI served by the app
scripts/ static demo build + headless verification
docs/index.html the live demo (generated; commit the output)
- The deterministic engine is extractive. It selects, arranges and frames the source's own sentences — it does not invent new phrasing. That is a deliberate trade (it cannot hallucinate a statistic), but the prose has the cadence of the speaker, not of a copywriter. Turn on the Anthropic path when you want it rewritten rather than rearranged.
- The analysers are tuned for English business/marketing speech. Other languages parse fine but the lexicon-driven signals (emotion, story, contrarian) will under-fire until the word lists are localised.
- Clip suggestions are segment-level, not frame-level. They tell an editor which 30-90 second range to open, not the exact in/out points.
- Optimal publishing times are sensible defaults, not your audience's analytics. Treat them as a starting cadence and replace them with the client's own data when you have it.
- The Anthropic path is written carefully against the current SDK but was developed without a key available, so the mock path is the one with test coverage.
- The
Dockerfileanddocker-compose.ymlare written against a standard Node 22 + better-sqlite3 build, but were not executed on the machine this was built on (no Docker daemon available). The compileddist/output they run is verified.
MIT.