← back to Linkedin Voice Agent
linkedin-voice-agent: local Okara-style LinkedIn voice engine (draft-only, $0 Ollama)
b5c52a64588473e0c4a48e0400cab9034c84fcd1 · 2026-08-08 08:49:53 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A .gitignoreA SKILL.mdA data/drafts/.gitkeepA data/profiles/.gitkeepA references/daily-cadence.mdA scripts/_ollama.pyA scripts/build_profile.pyA scripts/generate.py
Diff
commit b5c52a64588473e0c4a48e0400cab9034c84fcd1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 8 08:49:53 2026 -0700
linkedin-voice-agent: local Okara-style LinkedIn voice engine (draft-only, $0 Ollama)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 13 +++
SKILL.md | 86 ++++++++++++++++++++
data/drafts/.gitkeep | 0
data/profiles/.gitkeep | 0
references/daily-cadence.md | 52 ++++++++++++
scripts/_ollama.py | 89 ++++++++++++++++++++
scripts/build_profile.py | 193 ++++++++++++++++++++++++++++++++++++++++++++
scripts/generate.py | 188 ++++++++++++++++++++++++++++++++++++++++++
8 files changed, 621 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..d371db0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+# generated artifacts — voice profiles + draft queues are per-user, not source
+data/profiles/*.json
+data/drafts/*
+!data/profiles/.gitkeep
+!data/drafts/.gitkeep
diff --git a/SKILL.md b/SKILL.md
new file mode 100644
index 0000000..4da8667
--- /dev/null
+++ b/SKILL.md
@@ -0,0 +1,86 @@
+---
+name: linkedin-voice-agent
+description: 'Persona-voice LinkedIn content engine — the local, build-it-don''t-buy-it answer to Okara''s "LinkedIn Agent v2". Analyzes your OWN past LinkedIn posts to learn your core themes/topics + voice, style, and tone into a reusable profile, then writes new LinkedIn post drafts in that voice — daily or on demand, about your themes, a topic, or a news item — scoring each draft''s hook strength so the strongest float to the top. Runs on LOCAL Ollama by default (zero cost). DRAFT-ONLY — never auto-posts; publishing an approved draft is handed to the linkedin-api skill and stays Steve-gated. Use when Steve says "linkedin voice agent", "/linkedin-voice-agent", "write LinkedIn posts in my voice", "learn my LinkedIn voice", "daily LinkedIn posts like Okara", "build a LinkedIn voice profile", "draft LinkedIn posts about X", or wants Okara-style LinkedIn ghostwriting run locally instead of paying for it.'
+---
+
+# linkedin-voice-agent
+
+Local clone of the capability Okara (@askOkara) sells as **LinkedIn Agent v2**:
+*"analyzes your past posts to learn your themes + voice/style/tone, then writes
+new LinkedIn posts for you every day."* Steve's directive was **"Do not buy.
+Build."** — so this runs entirely on local Ollama at **$0**, with no okara.ai
+subscription and no data leaving the Mac.
+
+Sibling to the `kartiseira` skill (same draft-only + voice-profile-JSON + local-
+Ollama architecture, but for **X/Twitter**). This one is **LinkedIn-native**:
+long-form professional register, and it hands publishing to the real
+`linkedin-api` skill.
+
+## Hard rail — DRAFT ONLY
+This skill NEVER posts to LinkedIn. Automated third-party posting violates
+LinkedIn's TOS, and outward publishing is a Steve-gated action regardless. It
+writes drafts to a local review queue. To publish an approved draft, hand its
+text to the **`linkedin-api`** skill (`scripts/post.py`) — Steve's go.
+
+## When to use
+- "linkedin voice agent" / "/linkedin-voice-agent"
+- "learn my LinkedIn voice" / "build a LinkedIn voice profile"
+- "write LinkedIn posts in my voice" / "daily LinkedIn posts like Okara"
+- "draft LinkedIn posts about <topic>" / "react to <news> on LinkedIn in my voice"
+
+Do NOT use for: actually posting (draft-only → `linkedin-api`), X/Twitter content
+(use `kartiseira`), Instagram/TikTok (use the DW marketing agents), or DW commerce
+copy (use `dw-marketing-copy`).
+
+## Workflow — two stages
+
+### Stage 1 — Learn the voice (once per person)
+Build a reusable JSON voice profile from real past posts.
+
+**Getting your past posts (the honest, TOS-clean, $0 path):** LinkedIn has no
+free API to read your own feed, so use LinkedIn's official **Download your data**
+export: linkedin.com → Settings → *Data privacy* → **Get a copy of your data** →
+tick **Posts** → wait for the email → unzip → `Shares.csv`.
+
+```bash
+python3 scripts/build_profile.py --export ~/Downloads/Shares.csv --name steve
+# or paste-in fallback: a .txt with posts split by a blank line or a --- line
+python3 scripts/build_profile.py --posts ~/Desktop/my_posts.txt --name steve
+```
+
+Writes `data/profiles/<name>.json` with: measured style **stats** (computed
+deterministically — length, line count, emoji/hashtag/question rates), plus
+Ollama-synthesised `core_themes`, `voice_summary`, `signature_moves`, `avoid`,
+and `sample_hooks`. Skim `voice_summary` and `signature_moves` — hand-refining
+them a little gives the sharpest generations. Falls back to a stats-only profile
+if Ollama is unreachable (records which in `_voice_source`).
+
+### Stage 2 — Generate drafts (daily or on demand)
+```bash
+python3 scripts/generate.py --profile steve --count 3 # about their own themes
+python3 scripts/generate.py --profile steve --topic "AI in interior design"
+python3 scripts/generate.py --profile steve --news "<headline or paragraph to react to>"
+```
+
+Generates N distinct posts in the captured voice, scores each draft's **hook
+strength 0-100** (deterministic first-line heuristic — free, reproducible),
+sorts strongest-first, and writes:
+- `data/drafts/<name>-<stamp>.md` — human review (hook score + angle + full text)
+- `data/drafts/<name>-<stamp>.jsonl` — machine-readable
+
+Review the `.md`, pick a winner, and publish via `linkedin-api` (Steve-gated).
+
+## Daily automation (the "every day" part) — Steve-gated
+Okara posts daily; the equivalent here is a launchd job that runs Stage 2 each
+morning and drops fresh drafts in the queue (still draft-only — a human still
+approves + publishes). Installing a scheduled job is a gated action, so the
+plist + install command are staged in `references/daily-cadence.md` for Steve to
+run, not auto-installed.
+
+## Config
+- `LVA_MODEL` (default `qwen3:14b`), `OLLAMA_URL` (default `http://localhost:11434`).
+- Fallbacks: qwen3 → hermes3 → gemma3 → qwen2.5 → llama3. All local, $0.
+
+## Cost
+Profile build + generation are **$0 (local Ollama)**. The only paid/gated step is
+publishing, which this skill never does — that's `linkedin-api` on Steve's go.
diff --git a/data/drafts/.gitkeep b/data/drafts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/data/profiles/.gitkeep b/data/profiles/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/references/daily-cadence.md b/references/daily-cadence.md
new file mode 100644
index 0000000..d3083ea
--- /dev/null
+++ b/references/daily-cadence.md
@@ -0,0 +1,52 @@
+# Daily cadence — the "every day" automation (Steve-gated)
+
+Okara auto-writes a LinkedIn post every day. The local equivalent is a launchd
+job that runs Stage 2 each morning and drops fresh drafts in `data/drafts/`.
+It is still **draft-only** — a human reviews and publishes. Installing a
+scheduled job is a gated action, so run this yourself when you want it on.
+
+## 1. Plist template
+Save as `~/Library/LaunchAgents/com.steve.linkedin-voice-agent.plist`
+(edit `--profile` to your profile name; adjust the hour in `StartCalendarInterval`):
+
+```xml
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
+ "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+ <key>Label</key><string>com.steve.linkedin-voice-agent</string>
+ <key>ProgramArguments</key>
+ <array>
+ <string>/usr/bin/python3</string>
+ <string>/Users/macstudio3/.claude/skills/linkedin-voice-agent/scripts/generate.py</string>
+ <string>--profile</string><string>steve</string>
+ <string>--count</string><string>3</string>
+ </array>
+ <key>StartCalendarInterval</key>
+ <dict><key>Hour</key><integer>7</integer><key>Minute</key><integer>30</integer></dict>
+ <key>StandardOutPath</key><string>/tmp/lva-daily.log</string>
+ <key>StandardErrorPath</key><string>/tmp/lva-daily.err</string>
+</dict>
+</plist>
+```
+
+## 2. Install / load
+```bash
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.linkedin-voice-agent.plist
+launchctl kickstart gui/$(id -u)/com.steve.linkedin-voice-agent # run once now to test
+```
+
+## 3. Review + publish (unchanged)
+Each morning, read the newest `data/drafts/steve-*.md`, pick a winner, and
+publish it via the `linkedin-api` skill (your go):
+
+```bash
+python3 ~/.claude/skills/linkedin-api/scripts/post.py --text "<approved draft text>"
+```
+
+## Uninstall
+```bash
+launchctl bootout gui/$(id -u)/com.steve.linkedin-voice-agent
+rm ~/Library/LaunchAgents/com.steve.linkedin-voice-agent.plist
+```
diff --git a/scripts/_ollama.py b/scripts/_ollama.py
new file mode 100644
index 0000000..4d1d82d
--- /dev/null
+++ b/scripts/_ollama.py
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+"""Shared local-Ollama helper for the linkedin-voice-agent skill.
+
+Zero external dependencies (stdlib urllib). Runs on Mac2/Mac1 Ollama at $0.
+Mirrors the call+fallback+think-strip pattern used by the kartiseira skill so
+behaviour is consistent across Steve's persona-voice tools.
+"""
+import json
+import os
+import re
+import sys
+import urllib.request
+
+DEFAULT_MODEL = os.environ.get("LVA_MODEL", "qwen3:14b")
+BASE = os.environ.get("OLLAMA_URL", "http://localhost:11434")
+# Preference order if the requested model isn't pulled locally.
+FALLBACKS = ("qwen3", "hermes3", "gemma3", "qwen2.5", "llama3.1", "llama3")
+
+
+def _available_models():
+ try:
+ with urllib.request.urlopen(f"{BASE}/api/tags", timeout=8) as r:
+ data = json.load(r)
+ return {m["name"].split(":")[0]: m["name"] for m in data.get("models", [])}
+ except Exception:
+ return {}
+
+
+def _resolve_model(want):
+ avail = _available_models()
+ if not avail:
+ return want # let the call fail loudly if ollama is truly down
+ if want in avail.values():
+ return want
+ for fam in FALLBACKS:
+ if fam in avail:
+ return avail[fam]
+ return next(iter(avail.values()))
+
+
+def ask(prompt, model=None, temperature=0.8, num_predict=1400):
+ """Return (text, model_used). Strips <think> blocks. Raises on hard failure."""
+ model = _resolve_model(model or DEFAULT_MODEL)
+ body = json.dumps({
+ "model": model,
+ "prompt": prompt,
+ "stream": False,
+ "options": {"temperature": temperature, "num_predict": num_predict},
+ }).encode()
+ req = urllib.request.Request(f"{BASE}/api/generate", data=body,
+ headers={"Content-Type": "application/json"})
+ with urllib.request.urlopen(req, timeout=240) as r:
+ out = json.load(r).get("response", "")
+ out = re.sub(r"<think>.*?</think>", "", out, flags=re.S | re.I).strip()
+ return out, model
+
+
+def extract_json(text):
+ """Pull the first JSON object/array out of a possibly-chatty model reply."""
+ text = text.strip()
+ m = re.search(r"```(?:json)?\s*(.+?)```", text, flags=re.S)
+ if m:
+ text = m.group(1).strip()
+ # Match whichever bracket appears FIRST — a top-level object may contain
+ # arrays (and vice-versa), so trying "[" unconditionally would grab an
+ # inner array out of an object.
+ pairs = [(o, c) for o, c in (("[", "]"), ("{", "}")) if text.find(o) != -1]
+ pairs.sort(key=lambda p: text.find(p[0]))
+ for opener, closer in pairs:
+ i = text.find(opener)
+ if i == -1:
+ continue
+ depth = 0
+ for j in range(i, len(text)):
+ if text[j] == opener:
+ depth += 1
+ elif text[j] == closer:
+ depth -= 1
+ if depth == 0:
+ try:
+ return json.loads(text[i:j + 1])
+ except Exception:
+ break
+ return None
+
+
+if __name__ == "__main__":
+ txt, used = ask(sys.argv[1] if len(sys.argv) > 1 else "Say hi in one line.")
+ print(f"[model: {used}]\n{txt}")
diff --git a/scripts/build_profile.py b/scripts/build_profile.py
new file mode 100644
index 0000000..61cec7c
--- /dev/null
+++ b/scripts/build_profile.py
@@ -0,0 +1,193 @@
+#!/usr/bin/env python3
+"""build_profile.py — distill a reusable LinkedIn VOICE PROFILE from your own
+past posts. This is the "analyzes your past posts to learn your themes + voice,
+style, and tone" half of what Okara's LinkedIn Agent v2 sells — run locally at
+$0 instead of paying for it.
+
+INPUTS (pick one):
+ --export <Shares.csv> LinkedIn "Download your data" export (the honest,
+ TOS-clean, $0 way to read YOUR OWN past posts).
+ Get it at: linkedin.com/mypreferences/d/download-my-data
+ → "Posts" → wait for email → unzip → Shares.csv
+ --posts <file.txt> Plain text: posts separated by a blank line or a
+ line of "---". Paste-in fallback.
+
+OUTPUT:
+ data/profiles/<name>.json the voice profile the generator consumes.
+
+The style STATS are computed deterministically in Python (free, reproducible);
+only the natural-language theme/voice synthesis calls local Ollama.
+
+Usage:
+ python3 scripts/build_profile.py --export ~/Downloads/Shares.csv --name steve
+ python3 scripts/build_profile.py --posts ~/Desktop/my_posts.txt --name steve
+"""
+import argparse
+import csv
+import datetime
+import json
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+import _ollama # noqa: E402
+
+ROOT = os.path.dirname(HERE)
+PROFILE_DIR = os.path.join(ROOT, "data", "profiles")
+
+EMOJI_RE = re.compile(
+ "[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0001F1E6-\U0001F1FF←-⇿⬀-⯿]"
+)
+
+
+def load_from_csv(path):
+ """LinkedIn Shares.csv → list of post texts. Column name has varied over
+ the years (ShareCommentary / Commentary / Text), so auto-detect."""
+ posts = []
+ with open(path, newline="", encoding="utf-8", errors="replace") as f:
+ reader = csv.DictReader(f)
+ cols = reader.fieldnames or []
+ # rank candidate text columns
+ cand = None
+ for want in ("ShareCommentary", "Commentary", "ShareText", "Text", "content"):
+ for c in cols:
+ if c and c.strip().lower() == want.lower():
+ cand = c
+ break
+ if cand:
+ break
+ if not cand:
+ sys.exit(f"Could not find a post-text column in {os.path.basename(path)}. "
+ f"Columns present: {cols}")
+ for row in reader:
+ t = (row.get(cand) or "").strip()
+ if t:
+ posts.append(t)
+ return posts
+
+
+def load_from_txt(path):
+ raw = open(path, encoding="utf-8", errors="replace").read()
+ # split on --- lines or blank-line gaps of 1+
+ chunks = re.split(r"\n\s*(?:-{3,}|\*{3,})\s*\n|\n{2,}", raw)
+ return [c.strip() for c in chunks if c.strip()]
+
+
+def compute_stats(posts):
+ n = len(posts)
+ def avg(f):
+ return round(sum(f(p) for p in posts) / n, 2) if n else 0
+ hashtags = [len(re.findall(r"#\w+", p)) for p in posts]
+ return {
+ "n_posts": n,
+ "avg_chars": avg(len),
+ "avg_lines": avg(lambda p: p.count("\n") + 1),
+ "avg_words": avg(lambda p: len(p.split())),
+ "pct_with_question": round(100 * sum(1 for p in posts if "?" in p) / n) if n else 0,
+ "pct_with_emoji": round(100 * sum(1 for p in posts if EMOJI_RE.search(p)) / n) if n else 0,
+ "pct_with_hashtag": round(100 * sum(1 for h in hashtags if h) / n) if n else 0,
+ "avg_hashtags": round(sum(hashtags) / n, 2) if n else 0,
+ "pct_with_list": round(100 * sum(1 for p in posts if re.search(r"(?m)^\s*(?:[-•*\d]|\d[.)])", p)) / n) if n else 0,
+ }
+
+
+def top_hooks(posts, k=12):
+ """First line of the longest / most-engaging-looking posts = hook exemplars."""
+ ranked = sorted(posts, key=len, reverse=True)
+ hooks, seen = [], set()
+ for p in ranked:
+ first = p.strip().splitlines()[0].strip()
+ if 8 <= len(first) <= 140 and first.lower() not in seen:
+ hooks.append(first)
+ seen.add(first.lower())
+ if len(hooks) >= k:
+ break
+ return hooks
+
+
+def synthesize(posts, stats, name):
+ sample = "\n\n---\n\n".join(p[:600] for p in posts[:25])
+ prompt = f"""You are a brand-voice analyst. Below are real LinkedIn posts written by {name}.
+Study them and return ONLY a JSON object (no prose) with these keys:
+
+ "core_themes": array of 4-8 short topic labels this person posts about
+ "voice_summary": 2-4 sentences describing their voice, tone, and register
+ (e.g. warm/direct, plain-spoken, wry, expert, first-person)
+ — specific enough to imitate, grounded in the samples.
+ "signature_moves": array of 3-6 concrete recurring stylistic habits
+ (e.g. "opens with a one-line contrarian hook",
+ "uses short 1-2 line paragraphs with whitespace",
+ "ends with a soft question", "no hashtags").
+ "avoid": array of 2-5 things that would break the voice
+ (things they never do — corporate buzzwords, hashtag spam, etc.)
+
+Measured style stats (respect these when describing habits): {json.dumps(stats)}
+
+POSTS:
+{sample}
+
+Return the JSON object only."""
+ try:
+ raw, model = _ollama.ask(prompt, temperature=0.4, num_predict=900)
+ data = _ollama.extract_json(raw)
+ if isinstance(data, dict) and data.get("voice_summary"):
+ data["_voice_source"] = f"ollama:{model} (local, $0)"
+ return data
+ except Exception as e:
+ print(f" (ollama synthesis failed: {e} — falling back to stats-only)", file=sys.stderr)
+ # graceful fallback: build a stats-derived profile so the tool still works
+ return {
+ "core_themes": [],
+ "voice_summary": (f"{name} writes ~{int(stats['avg_words'])}-word LinkedIn posts, "
+ f"{'often asking a question' if stats['pct_with_question'] > 40 else 'mostly declarative'}, "
+ f"{'emoji-friendly' if stats['pct_with_emoji'] > 40 else 'light on emoji'}, "
+ f"{'hashtag-light' if stats['avg_hashtags'] < 2 else 'hashtag-forward'}."),
+ "signature_moves": [],
+ "avoid": [],
+ "_voice_source": "stats-only (ollama unavailable)",
+ }
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ src = ap.add_mutually_exclusive_group(required=True)
+ src.add_argument("--export", help="LinkedIn Shares.csv from Download-your-data")
+ src.add_argument("--posts", help="plain .txt, posts split by blank line or ---")
+ ap.add_argument("--name", required=True, help="profile name, e.g. steve")
+ ap.add_argument("--max", type=int, default=200, help="cap posts analysed")
+ args = ap.parse_args()
+
+ posts = load_from_csv(args.export) if args.export else load_from_txt(args.posts)
+ if not posts:
+ sys.exit("No posts found in the input.")
+ posts = posts[: args.max]
+ print(f"Loaded {len(posts)} posts for '{args.name}'.")
+
+ stats = compute_stats(posts)
+ print("Computing voice profile via local Ollama ($0)...")
+ synth = synthesize(posts, stats, args.name)
+
+ profile = {
+ "name": args.name,
+ "source": os.path.basename(args.export or args.posts),
+ "built_at": datetime.datetime.now().isoformat(timespec="seconds"),
+ "stats": stats,
+ "sample_hooks": top_hooks(posts),
+ **synth,
+ }
+ os.makedirs(PROFILE_DIR, exist_ok=True)
+ out = os.path.join(PROFILE_DIR, f"{args.name}.json")
+ with open(out, "w") as f:
+ json.dump(profile, f, indent=2, ensure_ascii=False)
+
+ print(f"\n✅ Voice profile → {out}")
+ print(f" themes: {', '.join(profile.get('core_themes') or ['(none synthesised)'])}")
+ print(f" voice : {profile['voice_summary']}")
+ print(f" source: {profile['_voice_source']}")
+ print(f"\nNext: python3 scripts/generate.py --profile {args.name} --count 3")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/generate.py b/scripts/generate.py
new file mode 100644
index 0000000..0fcdf7e
--- /dev/null
+++ b/scripts/generate.py
@@ -0,0 +1,188 @@
+#!/usr/bin/env python3
+"""generate.py — write new LinkedIn posts in a captured voice. This is the
+"writes new LinkedIn posts for you every day" half of Okara's LinkedIn Agent v2,
+run locally at $0.
+
+Reads a voice profile built by build_profile.py, generates N post drafts (in
+that voice, constrained by the measured style + a topic/news angle), scores each
+draft's hook strength deterministically, sorts strongest-first, and writes a
+human-review queue.
+
+HARD RAIL — DRAFT ONLY. This never posts to LinkedIn. Automated 3rd-party
+posting violates LinkedIn's TOS, and outward publishing is Steve-gated anyway.
+To publish an approved draft, hand it to the `linkedin-api` skill (Steve's go).
+
+Usage:
+ python3 scripts/generate.py --profile steve --count 3
+ python3 scripts/generate.py --profile steve --topic "AI in interior design"
+ python3 scripts/generate.py --profile steve --news "headline or paragraph to react to"
+"""
+import argparse
+import datetime
+import json
+import os
+import re
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+import _ollama # noqa: E402
+
+ROOT = os.path.dirname(HERE)
+PROFILE_DIR = os.path.join(ROOT, "data", "profiles")
+DRAFT_DIR = os.path.join(ROOT, "data", "drafts")
+
+POWER_WORDS = {"how", "why", "what", "stop", "start", "never", "always", "most",
+ "nobody", "everyone", "truth", "mistake", "secret", "lesson",
+ "here's", "unpopular", "hot take", "i was wrong", "the hard way"}
+
+
+def load_profile(name):
+ p = os.path.join(PROFILE_DIR, f"{name}.json")
+ if not os.path.exists(p):
+ sys.exit(f"No profile '{name}'. Build one first:\n"
+ f" python3 scripts/build_profile.py --export <Shares.csv> --name {name}")
+ return json.load(open(p))
+
+
+def hook_score(text):
+ """Deterministic 0-100 hook-strength heuristic on the FIRST line.
+ Reproducible and free — no model call. Rewards curiosity + specificity."""
+ first = text.strip().splitlines()[0].strip()
+ s, low = 0, first.lower()
+ n = len(first)
+ # length sweet-spot for a scroll-stopping first line (~30-90 chars)
+ if 25 <= n <= 90:
+ s += 30
+ elif n < 25:
+ s += 12
+ elif n <= 130:
+ s += 18
+ if "?" in first:
+ s += 12
+ if re.search(r"\d", first):
+ s += 14 # a concrete number
+ if any(w in low for w in POWER_WORDS):
+ s += 16
+ if first[:1].isupper() and not first.isupper():
+ s += 6 # cased, not shouty
+ if first.count(" ") <= 14:
+ s += 8 # tight, not a run-on
+ if "#" in first:
+ s -= 10 # hashtag in the hook = weak
+ if low.startswith(("i am excited", "i'm excited", "excited to announce")):
+ s -= 12 # the cliché LinkedIn opener
+ return max(0, min(100, s))
+
+
+def build_prompt(profile, count, topic, news):
+ st = profile["stats"]
+ themes = ", ".join(profile.get("core_themes") or ["(their usual topics)"])
+ moves = "\n".join(f" - {m}" for m in (profile.get("signature_moves") or []))
+ avoid = "\n".join(f" - {a}" for a in (profile.get("avoid") or []))
+ hooks = "\n".join(f" - {h}" for h in (profile.get("sample_hooks") or [])[:8])
+
+ if news:
+ brief = f'React to / build on this in the author\'s own voice:\n"""{news}"""'
+ elif topic:
+ brief = f'Write about this topic: "{topic}".'
+ else:
+ brief = ("Write about the author's own core themes. Pick fresh, specific "
+ "angles — no generic advice.")
+
+ return f"""You ghostwrite LinkedIn posts in ONE specific person's voice. Imitate them exactly.
+
+VOICE OF {profile['name'].upper()}:
+{profile['voice_summary']}
+
+CORE THEMES: {themes}
+
+SIGNATURE MOVES (do these):
+{moves or ' - short paragraphs, first-person, plain language'}
+
+NEVER DO:
+{avoid or ' - corporate buzzwords, hashtag spam, humble-brag openers'}
+
+MEASURED STYLE (match it): ~{int(st['avg_words'])} words, ~{int(st['avg_lines'])} lines,
+avg {st['avg_hashtags']} hashtags, question in ~{st['pct_with_question']}% of posts,
+emoji in ~{st['pct_with_emoji']}% of posts.
+
+EXAMPLES OF THEIR REAL HOOKS (match this energy, don't copy):
+{hooks or ' - (none captured)'}
+
+TASK: {brief}
+Write {count} DISTINCT LinkedIn posts. Each: a scroll-stopping first line, then
+the body with whitespace between short paragraphs. Native LinkedIn — no markdown
+headers, no "**bold**". Sound like a human, not a brand.
+
+Return ONLY a JSON array of objects, each:
+ {{"angle": "<2-4 word description of the angle>", "text": "<the full post>"}}"""
+
+
+def generate(profile, count, topic, news):
+ prompt = build_prompt(profile, count, topic, news)
+ raw, model = _ollama.ask(prompt, temperature=0.9, num_predict=1800)
+ data = _ollama.extract_json(raw)
+ drafts = []
+ if isinstance(data, list):
+ for d in data:
+ if isinstance(d, dict) and d.get("text"):
+ drafts.append({"angle": d.get("angle", ""), "text": d["text"].strip()})
+ if not drafts:
+ # last-ditch: treat the whole reply as one post so the run isn't a dead end
+ drafts = [{"angle": "raw", "text": raw.strip()}]
+ for d in drafts:
+ d["hook_score"] = hook_score(d["text"])
+ d["chars"] = len(d["text"])
+ drafts.sort(key=lambda d: d["hook_score"], reverse=True)
+ return drafts, model
+
+
+def write_queue(profile, drafts, model, topic, news):
+ os.makedirs(DRAFT_DIR, exist_ok=True)
+ stamp = datetime.datetime.now().strftime("%Y-%m-%d_%H%M")
+ base = os.path.join(DRAFT_DIR, f"{profile['name']}-{stamp}")
+ # jsonl for machines
+ with open(base + ".jsonl", "w") as f:
+ for d in drafts:
+ f.write(json.dumps(d, ensure_ascii=False) + "\n")
+ # markdown for the human review
+ ctx = news or topic or "core themes"
+ lines = [f"# LinkedIn drafts — {profile['name']} ({stamp})",
+ f"_brief: {ctx} · model: {model} (local, $0) · DRAFT ONLY — not posted_\n"]
+ for i, d in enumerate(drafts, 1):
+ lines.append(f"## {i}. [{d['hook_score']}/100 hook] {d['angle']} · {d['chars']} chars\n")
+ lines.append(d["text"])
+ lines.append("\n---\n")
+ lines.append("To publish an approved draft (Steve's go): hand its text to the "
+ "`linkedin-api` skill →\n"
+ "`python3 ~/.claude/skills/linkedin-api/scripts/post.py --text \"<draft>\"`")
+ md = base + ".md"
+ with open(md, "w") as f:
+ f.write("\n".join(lines))
+ return md
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument("--profile", required=True, help="voice profile name")
+ ap.add_argument("--count", type=int, default=3)
+ ap.add_argument("--topic", help="topic to write about")
+ ap.add_argument("--news", help="a headline/paragraph to react to")
+ args = ap.parse_args()
+
+ profile = load_profile(args.profile)
+ print(f"Generating {args.count} drafts in {args.profile}'s voice (local, $0)...")
+ drafts, model = generate(profile, args.count, args.topic, args.news)
+ md = write_queue(profile, drafts, model, args.topic, args.news)
+
+ print(f"\n✅ {len(drafts)} drafts → {md}")
+ for i, d in enumerate(drafts, 1):
+ first = d["text"].splitlines()[0][:80]
+ print(f" {i}. [{d['hook_score']}/100] {first}")
+ print("\nDRAFT ONLY — review the .md, then publish an approved one via the "
+ "linkedin-api skill (Steve-gated).")
+
+
+if __name__ == "__main__":
+ main()
(oldest)
·
back to Linkedin Voice Agent
·
(newest)