Inspiration
Anyone who posts consistently ends up with the same two problems. The first is that a good idea has to be rewritten four times, because a post that works on X reads like a stranger's on LinkedIn. The second is worse and quieter: after a few months you start repeating yourself, saying the same thing you already said in March, and nothing in your workflow notices.
Most AI writing tools make the first problem faster and the second one worse — they'll happily hand you three variants that are one take reworded, and they have no memory of what you already published. We wanted a tool that treats a feed as something with a history, where "have I said this already?" and "what actually worked last time?" are questions the system answers, not the writer.
What it does
PostPilot takes a raw idea and runs it through a Google ADK graph workflow to finished, platform-specific drafts — then keeps going: review, publish, measure, and turn the winners into the next round.
Drafts. One idea becomes a structured brief, optionally grounded with Google Search, then N variants per platform, each written to that platform's own rules and each committed to a different angle assigned in Python before any writing starts. Checks mechanically, then editorially. Character limits, hashtag counts, hook length, CTA policy, banned phrases and repetition are all enforced in Python before a single token is spent on critique. The LLM critic is told those checks already ran, so it only judges what a model is actually good at: voice, audience, and whether the variants are genuinely distinct. Waits for a human. Every variant lands as a draft and stays there until someone approves it in the review UI. Publishes exactly once. Approved posts go to LinkedIn/X through Buffer on a click, with an idempotency guarantee PostPilot owns because Buffer doesn't offer one. Measures. Engagement comes back onto the corpus and gets grouped by the things a writer actually controls — angle, format, hook length, hashtag count, posting hour. Iterates. Point at a winner and get fresh variations drawn only from angles that topic hasn't spent yet. The web UI streams the graph executing node by node, then gives you a review queue, a calendar of what's going out when, and an analytics view.
How we built it
A Workflow graph on Google ADK over Gemini 3.5, not a SequentialAgent — the pipeline needs a conditional branch (skip research when the brief is already concrete) and two bounded revision cycles, and a linear chain can't express either.
Three decisions shaped everything else:
Python assembles the post, not the model. The writer emits parts — hook, body, cta, hashtags, slides — and compose.py joins them. That turns hashtag count, hook length and CTA presence into structural facts the validator reads straight off the draft, instead of things it has to regex back out of prose.
One registry, read twice. Every rule in platforms.yaml is rendered into the writer's prompt and enforced by validate(). When a limit lives in a prompt and in Python separately, the two drift, and the critic ends up defending text the validator is about to reject.
Routing is never an LLM decision. Every branch in the graph is a Python function node. It's testable that way and it costs nothing.
On top of that: hook-first drafting (ask for eight opening lines, throw most away in a pure-Python filter — a hook costs about ten tokens, and a post lives or dies on its first line), per-platform revision budgets, platform writers fanned out concurrently, and a repetition guard that only counts posts that actually went out. FastAPI serves the UI and streams the run over SSE; Buffer's GraphQL API is the publishing transport; a Dockerfile ships it to Cloud Run.
Challenges we ran into
ADK's structured output and built-in tools don't mix on an API key. output_schema plus tools is Vertex-only; on a Gemini API key, ADK injects a set_model_response declaration alongside the native google_search built-in — exactly the combination that isn't allowed. So the research node emits plain prose, and a callback harvests real source URLs off the grounding metadata instead. The model is never asked to type its own citations, which turned out to be the better design anyway.
Concurrent writers shared one state dict. ADK's ctx.state is flat, with no per-branch namespacing, so two platform writers sharing an output_key raced and the last one to finish won. They return their results instead. Related: State tracks a pending delta, so mutating a nested dict in place isn't seen as a change — the revision counter had to be reassigned, not incremented.
One revision budget for four platforms. The first platform to fail spent everyone else's. Splitting it per platform, and tagging every validation error platform/message so each writer only sees its own, is what made the fan-out usable.
Asking for "genuinely distinct variants" reliably produced three phrasings of one take. Prompting could not fix it. Angles are now drawn deterministically from a hash of the topic and assigned before anyone writes — and distinctness is checked mechanically after. We calibrated the threshold against real output: three approved variants scored 0.18–0.24 against each other, while the same sentence with swapped synonyms scored 0.91.
That determinism then broke iteration. Hashing the topic meant asking for angles on an already-written topic returned the same angles — an iteration run would have faithfully reproduced the post it was meant to vary. Tracking spent angles through the corpus lineage was the fix.
Publishing has no undo. Buffer answers a rejected post with HTTP 200 and an error member of a union, so branching on status code alone reads a failure as a success. It also issues no idempotency key, so "this post goes out at most once" is a guarantee we had to build ourselves — a stored Buffer id plus an in-flight set to close the double-click window the stored id can't.
Metrics are more ambiguous than they look. Buffer refreshes daily, and a missing metric doesn't mean zero — it means the network hasn't said yet. Nothing in the pipeline ever turns an absence into a 0, because a young post counted as a zero drags down every group it lands in.
Accomplishments that we're proud of
The whole thing is built so the expensive part is the smallest part. Every deterministic check runs before any model call, routing costs nothing, hook filtering is free, and the analytics arithmetic is pure Python — the LLM analyst reads the aggregate, never the raw rows.
We're also proud of the parts that refuse to guess. Two Buffer channels mapping to one platform is genuinely ambiguous, so it raises instead of picking one. A group below three posts is reported but flagged as too thin to build a finding on, and the analyst is explicitly told that a thin group is not evidence — zero findings with an honest caveat is a valid report. The RunResult contract is versioned, and the Postgres schema the file-backed store will become is already written.
And the human gate is real: a post starts counting for the repetition guard the moment a person approves it — not when it's published — because that's the moment it becomes certain to go out.
What we learned
That the interesting work in an agent pipeline is mostly not in the prompts. Almost every quality problem we hit was solved by moving a decision out of the model and into Python: angle assignment, hook filtering, distinctness, branch selection, post assembly. The model is best used for the one thing only it can do — writing — and worst used for anything with a checkable answer.
We also learned how much of a real integration is failure semantics rather than endpoints. The Buffer transport is a few hundred lines; understanding what "accepted" means, what a missing metric means, and where the idempotency boundary sits took far longer than calling the API.
And we learned to make schema shapes explicit for the framework, not just for ourselves — a bare list[Draft] silently produced no response format at all, and the model quietly free-wrote prose until validation exploded.
What's next for PostPilot
Threads, chains and carousels draft but don't publish yet. Buffer takes one post per call, and a carousel is a document post its API can't create. Chaining is the next transport feature; the UI already tells you on the card before you click. A semantic repetition guard. Today's is lexical — SequenceMatcher standing in for pg_trgm — so it catches near-duplicate phrasing but not the same point made in entirely different words. That gap is the trigger for adding pgvector. Postgres. The corpus is a JSON file and isn't concurrency-safe. The migrations are already written and store.py is designed as the seam: four function bodies, no caller changes, no prompt changes, no graph changes. A test suite and CI, focused on the pure-Python core — assembly, validation, the status lifecycle and the analytics arithmetic are all model-free and network-free by design. Auth and multi-user. The API is deliberately single-user and local today; a hosted instance needs a real boundary before it can publish on anyone else's behalf. Local-time scheduling. Analytics reports weekday and hour in UTC off Buffer's send time — useful for spotting a pattern, not yet for picking a posting slot.
Built With
- gemini
- vertex
Log in or sign up for Devpost to join the conversation.