Let the human be the analytics API
Shipped
Ghostwriter 0.17.0 is a reach overhaul. Every one of the 20 posts the skill had published sat under roughly 300 impressions, so this release rewrites the reach guidance around tagged evidence, adds a recovery protocol, and caps how often the branded card appears. But the diagnosis and the recovery both hang on one small mechanism: the outcome loop went numeric. A post’s impressions, reactions, and comments now get recorded next to its publish record, typed in by me. That loop is worth building for any pipeline whose platform keeps the performance numbers to itself, and it is the piece this guide walks through.
The number your code can’t fetch
Ghostwriter publishes to LinkedIn through the official share API, which will happily create a post for you and then tell you nothing about how it did. LinkedIn does have a member post analytics API now, but it requires the r_member_postAnalytics permission, which my publishing app does not have. Scraping the numbers instead is off the table: the User Agreement prohibits using “software, devices, scripts, robots or any other means or processes” to “scrape or copy the Services”, and bots that “drive inauthentic engagement” are named right next to that.
The irony is that this is exactly the platform where you most need measurement. LinkedIn spent this year cracking down on automated content; its stated bar is that “your posts and comments need to represent your voice”, and since July there is a literal “Seems like AI slop” report button that feeds reduced distribution for flagged posts. If a publishing tool is quietly producing content the feed suppresses, the only way to find out is the numbers, and the only compliant path to the numbers is a human reading them off the app’s own analytics view.
So the design goal is narrow: make it nearly free for that human to type a number into a place code can read, and make it visible when they haven’t. Three pieces do it: append a record at publish time, score it by id later, and list what’s still unscored.
Append a record the moment you publish
Publishing is the one moment your code knows everything about the post: the slug, the platform id, the first line. Write it down right then, as JSON Lines: one JSON object per line, UTF-8, so new records are a file append and old records are never rewritten by the hot path.
# publish_log.py
import json
import os
import time
from pathlib import Path
LOG = Path(os.environ.get("PUBLISH_LOG", Path.home() / ".myapp" / "published.jsonl"))
def record_publish(slug: str, first_line: str, urn: str = "") -> None:
"""Append the publish record. Never fail the publish; the post is already live."""
record = {
"date": time.strftime("%Y-%m-%d"),
"ts": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"slug": slug,
"first_line": first_line[:120],
"urn": urn,
}
try:
LOG.parent.mkdir(parents=True, exist_ok=True)
with LOG.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except OSError as e:
import sys
print(f"WARNING: could not write {LOG}: {e}", file=sys.stderr)
Two choices here earn their keep later. The try/except OSError is because this function runs after the post is live; a full disk should produce a warning, not a traceback that makes a successful publish look failed. And first_line is for the human: when they come back a week later to score posts, a slug alone is not enough to remember which post was which.
Score a post by hand, later
The outcome command is what the human runs after reading the analytics view. It needs to find the right record three ways: by platform id, by slug, or by “the newest one I haven’t scored yet”, which is the mode you actually use day to day because it requires remembering nothing.
# outcome.py
import argparse
import json
import sys
import time
from pathlib import Path
from publish_log import LOG
OUTCOMES = ("great", "normal", "flopped")
def load_records(log_path: Path) -> list[dict]:
if not log_path.exists():
sys.exit(f"ERROR: no publish log at {log_path}.")
records = [
json.loads(line)
for line in log_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
if not records:
sys.exit("ERROR: the publish log is empty.")
return records
def pick_record(records: list[dict], urn: str | None, slug: str | None, latest: bool) -> dict:
if urn:
for rec in records:
if rec.get("urn") == urn:
return rec
sys.exit(f"ERROR: no record with urn {urn}.")
if slug:
for rec in records:
if rec.get("slug") == slug:
return rec
sys.exit(f"ERROR: no record with slug {slug}.")
# --latest: the most recent record still missing an outcome; else the newest overall.
unscored = [r for r in records if not r.get("outcome")]
return (unscored or records)[-1]
def list_unscored(records: list[dict]) -> None:
unscored = [r for r in records if not r.get("outcome")]
if not unscored:
print("All published posts have an outcome recorded.")
return
for rec in unscored:
first = (rec.get("first_line") or "")[:60]
print(f"{rec.get('date', '?')} {rec.get('slug') or rec.get('urn')} {first}")
print(f"({len(unscored)} unscored of {len(records)} published)")
list_unscored is the piece that keeps the dataset honest. A feedback loop a human closes by hand decays the moment scoring becomes a memory exercise; a command that prints “3 unscored of 20 published” turns the gap into a visible to-do.
The write path
The entry point, appended to the same outcome.py, ties it together. The subjective rating stays required, the numeric fields are optional, and the rewrite goes through a temp file:
def main() -> None:
ap = argparse.ArgumentParser(description="Record how a published post actually did.")
which = ap.add_mutually_exclusive_group(required=True)
which.add_argument("--urn", help="Platform id of the post, from the publish log.")
which.add_argument("--slug", help="Slug of the post, from the publish log.")
which.add_argument("--latest", action="store_true",
help="Score the most recent post with no outcome yet.")
which.add_argument("--list-unscored", action="store_true",
help="List posts with no outcome recorded, oldest first.")
ap.add_argument("--outcome", choices=OUTCOMES)
ap.add_argument("--notes", default="")
ap.add_argument("--impressions", type=int,
help="Impression count read off the post's analytics view.")
ap.add_argument("--reactions", type=int)
ap.add_argument("--comments", type=int)
ap.add_argument("--log", default=str(LOG))
args = ap.parse_args()
log_path = Path(args.log)
records = load_records(log_path)
if args.list_unscored:
list_unscored(records)
return
if not args.outcome:
ap.error("--outcome is required unless --list-unscored is used.")
rec = pick_record(records, args.urn, args.slug, args.latest)
rec["outcome"] = args.outcome
if args.notes:
rec["outcome_notes"] = args.notes
for field in ("impressions", "reactions", "comments"):
value = getattr(args, field)
if value is not None:
rec[field] = value
rec["outcome_date"] = time.strftime("%Y-%m-%d")
tmp = log_path.with_suffix(".jsonl.tmp")
tmp.write_text(
"".join(json.dumps(r, ensure_ascii=False) + "\n" for r in records),
encoding="utf-8",
)
tmp.replace(log_path)
print(f"Recorded: {rec.get('slug') or rec.get('urn')} -> {args.outcome}")
if __name__ == "__main__":
main()
Scoring rewrites the whole file, so it must not die halfway and leave a truncated log behind. Writing to published.jsonl.tmp and swapping with Path.replace means the real file is “unconditionally replaced” in a single rename; POSIX guarantees the destination name stays visible throughout and refers to either the old or the new file, never to a half-written one.
Make the same log pay twice
Once the log exists, the publish side can read it too. Ghostwriter 0.17.0 uses it for advisory cadence warnings, on the reach guidance that a second post the same day splits the platform’s test-audience evaluation across both posts; its recovery protocol caps publishing at two to three posts a week.
# cadence.py
import sys
import time
from outcome import load_records
from publish_log import LOG
def warn_cadence(now: time.struct_time | None = None) -> None:
"""Advisory only. Warnings go to stderr; never block the publish."""
if not LOG.exists():
return
now = now or time.localtime()
dates = [r.get("date", "") for r in load_records(LOG)]
today = time.strftime("%Y-%m-%d", now)
if today in dates:
print("NOTE: already published today; consider holding.", file=sys.stderr)
week_floor = time.strftime("%Y-%m-%d", time.localtime(time.mktime(now) - 7 * 86400))
recent = sum(1 for d in dates if d > week_floor)
if recent >= 3:
print(f"NOTE: {recent} posts in the trailing 7 days; consider holding.",
file=sys.stderr)
if __name__ == "__main__":
warn_cadence()
The string comparison d > week_floor is deliberate: ISO 8601 dates sort lexicographically, so comparing them as strings is comparing them as dates, with no parsing. And these are notes, not gates. A publish tool that refuses to run because it disagrees with your timing gets worked around within a week; a NOTE: on stderr gets read.
Run it
In a scratch directory with both files present, point the log somewhere local and simulate two publishes:
export PUBLISH_LOG=./published.jsonl
python3 -c "from publish_log import record_publish
record_publish('2026-08-14-tool-budget', 'Your agent pays for its tool list on every request.', 'urn:li:share:1101')
record_publish('2026-08-18-autofix', 'An autofix that edits the file it was told to review.', 'urn:li:share:1102')"
python3 outcome.py --list-unscored
I ran exactly this while writing; the output was:
2026-08-20 2026-08-14-tool-budget Your agent pays for its tool list on every request.
2026-08-20 2026-08-18-autofix An autofix that edits the file it was told to review.
(2 unscored of 2 published)
Now score the newest post with real numbers, then check the to-do list again:
export PUBLISH_LOG=./published.jsonl # repeat this if you're in a new shell
python3 outcome.py --latest --outcome flopped --impressions 210 --reactions 4 --comments 1
python3 outcome.py --list-unscored
python3 cadence.py
Recorded: 2026-08-18-autofix -> flopped
2026-08-20 2026-08-14-tool-budget Your agent pays for its tool list on every request.
(1 unscored of 2 published)
NOTE: already published today; consider holding.
The scored record in published.jsonl now carries "outcome": "flopped", "impressions": 210, "reactions": 4, "comments": 1 alongside its publish fields, and any script can aggregate the file with a few lines of JSON parsing.
Gotchas
- A rating vocabulary can hide a systemic failure. Ghostwriter’s loop ran for 20 posts capturing only
great/normal/flopped, and it bit me: each post individually read as ordinary variance, and nothing in the log could show that no post had ever cleared roughly 300 impressions, which was the actual diagnosis (distribution never leaving my immediate network). The escape is to record the platform’s own numbers even when a human has to type them, and to judge the trend across several posts rather than any single one; 0.17.0’s recovery protocol requires an impressions count for every post for exactly this reason. required=Trueon an argument breaks every mode that doesn’t use it. The original outcome script declared--outcomewithrequired=True, which was correct while scoring was the only mode. Adding--list-unscoredmade it a trap: argparse rejects the listing command before your code runs, witherror: the following arguments are required: --outcome. The escape is to demote the argument to optional and re-validate it yourself withap.error(...)in the modes that need it, which is exactly the shape of the diff that landed in 0.17.0.- Warnings printed to stdout corrupt machine-readable output. The publish script’s dry-run mode prints the request payload as JSON on stdout; the first draft of a warning that printed there too would have broken anything parsing that JSON. Ghostwriter writes every advisory line as
NOTE:on stderr, so--dry-run | jqkeeps working and the warnings still reach a human’s terminal. - The log’s grain decides which questions you can ever ask it. Ghostwriter’s publish log stores dates, not times, so when 0.17.0 wanted cadence checks, only per-day questions were answerable (“already published today”, “3 posts in the trailing 7 days”); the time-of-day posting window had to come from aggregate platform data instead of the tool’s own history. The escape is cheap and retroactive-proof: record a full timestamp at write time, like the
tsfield above, even if today’s checks only read the date.
Sources
- Member Post Statistics, LinkedIn Marketing API — the member analytics API and its
r_member_postAnalyticspermission - LinkedIn User Agreement — the prohibition on scraping and automated access
- TechRadar on LinkedIn’s AI-slop crackdown — LinkedIn’s “represent your voice” bar for AI-assisted posts
- Fortune on the “Seems like AI slop” button — the July 2026 report option and reduced distribution for flagged posts
- JSON Lines — the one-record-per-line format the publish log uses
- pathlib.Path.replace, Python documentation — the unconditional single-rename file swap
- rename(), POSIX.1-2017 — the destination name stays valid throughout the rename
Changelog
- ghostwriter 0.17.0 — reach overhaul: all 20 posts stalled under ~300 impressions (#231) (a91a028)