Let the human be the analytics API

Ghostwriter · No. 136

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=True on an argument breaks every mode that doesn’t use it. The original outcome script declared --outcome with required=True, which was correct while scoring was the only mode. Adding --list-unscored made it a trap: argparse rejects the listing command before your code runs, with error: the following arguments are required: --outcome. The escape is to demote the argument to optional and re-validate it yourself with ap.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 | jq keeps 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 ts field above, even if today’s checks only read the date.

Sources

Changelog

  • ghostwriter 0.17.0 — reach overhaul: all 20 posts stalled under ~300 impressions (#231) (a91a028)