
The one thing to know before anything else. Nothing is published that a person has not approved, one reply at a time. Approval is not a setting, a default, or a dialog you can hold Enter through: it is a keystroke against one specific reply, taken immediately before that one reply is sent. There is no--yes, no--all, and no config key that changes it. Not defaulted off. Absent.
reply, skip or escalate, each with a one-line reason you can disagree with.escalate, not a guess.pip install works the same way if you would rather not use uv.run writes out/review.csv and prints a run report: how many replies, skips and escalates, what the run cost, and which drafts repeat each other. review turns the CSV into out/review.html, which is the page below.docs/bakeoff.md, which put thirty comments through three models. Eleven of those rows are shown here, chosen so that all three decisions and the awkward cases are visible at once rather than the first seven rows, which all happen to be replies. Your own run produces the same page from out/review.csv with whatever it measured.--approved. Without it, every page carries a red banner saying the drafts were approved by nobody, and it stays until you pass the flag, which you pass after reading every row and not before:commentdraft pull write it:pull needs a read credential and nothing else. A config with a [source] table and no [publish] table reads comments and drafts replies for them and cannot publish anything at all, which is the posture to start in while a platform decides whether to grant a write scope, and the posture some people should stay in.--state is what keeps a scheduled pull from drafting the same comment twice: a small JSON file holding every comment id pulled so far, and a comment already in it is never written a second time. Without it, every pull writes every comment it can see, every time, and the command says so rather than leaving it to a bill. docs/configuration.md covers the table, the flags, and the cases where a duplicate is still possible.docs/writing-a-voice.md has a short section on using them to check an edit to your voice file before spending on a full run.config.toml what you sell, how it behaves, which model, what it costs Knowledge a text file you supply the only thing a draft may state as fact Voice prompts/voice.md, prompts/examples.md your rules and your worked examples, in your language.py file to change it. No string literal under src/ contains a non-ASCII character, and a test walks the AST of every module to keep it that way.docs/limits.md with who reads each item, and none of it enters a reply as your voice.tests/fixtures/nazzef-kit-ar is a third product, config, voice, worked examples and comments, written entirely in Arabic, and it passes the same acceptance tests as the two English examples above. docs/writing-a-voice.md is the chapter to read next.[model].base_url names, as part of the request that drafts a reply. That is the one place your data leaves the machine running commentdraft.load_config refuses to start a run unless every model entry, the default one and every [[bakeoff.models]] entry alike, sets params.provider.data_collection = "deny" in its own table. That is an instruction sent with the request, not something this tool can enforce once the request has left it: whether the endpoint honors it is between you and whoever operates it. Nothing else about your product, your audience or their comments leaves this machine: no telemetry, no analytics call, and no second server this tool talks to.commentdraft pull reads with the first and can write nothing at all. The code that sends exists in exactly one module, and reaching it costs one keystroke per reply. commentdraft publish shows you one comment and one draft at a time and waits: y sends that one, e opens it in $EDITOR and sends what comes back, s skips, q stops, and Enter does nothing at all. commentdraft publish --dry-run prints what each send would carry, asks for no keystroke, and needs no publish credential, so you can read it before granting any write scope anywhere._send_approved in src/commentdraft/approve.py. The marker is the anchor rather than the filename on purpose: the version of this claim that read --exclude=approve.py --exclude-dir=platforms excluded the only two paths a send would ever live in, so it proved the send was not somewhere it had never been. tests/test_guarantees.py::test_the_send_marker_appears_once_in_the_package_and_sits_on_the_send asserts there is one and that it sits in that function.tests/test_guarantees.py::test_the_only_reference_to_publish_reply_in_the_package_is_the_call_inside_the_gate runs the version a grep cannot: it walks the AST of every module and fails the build if the send is named anywhere else, or named without being called, which is how post = platform.publish_reply would otherwise carry it into a loop. The tests beside it assert that every send sits inside a branch reached by a value that came out of the prompt itself, that no loop sits between that keystroke and the send, that the prompt has no default action and no conditional expression standing in for one, and that the config vocabulary is a frozen allowlist, so a key of any name that could stand in for a keystroke fails the build until somebody writes it down.tests/test_typeahead.py drives the real gate under a pty and asserts it. And the text shown to the reviewer and the text sent to the platform are one string rather than two that agree, so a reply cannot hide half of itself behind an escape sequence on the way past a reader.docs/architecture.md says where the line is.commentdraft bakeoff runs your own comments through several models and builds a blind judging page. That is the whole feature. If you want an evaluation harness with datasets, assertions and CI gates, use promptfoo. It is the right tool for that job and this is not trying to be it.load_config validates shape and never content. A wrong price travels straight through to a human reviewer, which is the correct place to catch it.plug_cap is an alarm threshold. It reports the share of replies matching one of your configured plug_markers, a case-insensitive substring test, and flags a run that goes over. It does not know what a link or a price looks like on its own; it only recognizes the exact markers you listed. Nothing stops mid-run. What holds the rate down is what you wrote in your voice file. docs/limits.md covers the false positives and false negatives that come with a substring test.docs/architecture.md the cached prefix, why there is no retrieval, and where that stops working docs/configuration.md every key config.toml accepts, which are required, and what each does docs/comments-csv.md the input CSV: every column, which are required, and what happens to the rest docs/writing-a-voice.md writing your rules and examples, in your language, placeholder by placeholder docs/sources.md adding a knowledge source handler docs/bakeoff.md comparing models blind, one measured run against the example, and one parameter that lies docs/platform-policy.md which clause each safety property exists for docs/limits.md what this cannot do, stated before you find out docs/platforms/index.md the eight platform guides, what each one costs to reach, and the order worth trying them in docs/platforms/facebook.md connecting a Facebook Page: scopes, tokens, the contested reply path, and what breakstests/test_guarantees.py::test_every_send_sits_inside_a_branch_an_explicit_keystroke_reaches fails the build on any arrangement that weakens it.load_config validates the shape of your configuration and never its truth: a wrong price passes straight through, on purpose, to the reviewer who can recognize it.docs/platform-policy.md maps this behavior, clause by clause, to the platform and legal provisions it was built to satisfy; that mapping is not repeated here.knowledge/ and any *.pdf file are covered by that rule, and they should stay that way in any fork or deployment of this project.provider.data_collection = "deny", and a test asserts it on every entry. Whether the provider honors it is between you and the provider. The request is the part this project controls, and it makes that part unambiguous.bot_disclosure_text is a required, non-empty setting: the exact sentence used when someone asks whether a person wrote a reply. It is validated non-empty and there is no evasive mode; an earlier version of this design had one and it was removed rather than defaulted off, because a setting that exists is a setting somebody eventually turns on.docs/platform-policy.md maps each property here to the specific clause it was built against, so you can check the reasoning rather than trust this summary.docs/bakeoff.md: one bake-off run against the shipped example on 2026-08-01, three models, with the command that reproduces it and the same dating rule applied to every number in it.CONTRIBUTING.md. In short: tests first, no non-ASCII string literals under src/, no posting path that is not gated behind a keystroke per reply, and no dash typography in prose. make check runs what CI runs.LICENSE and NOTICE.Posted Aug 19, 2026
CLI that triages comments into reply, skip, or escalate and drafts grounded replies. Nothing posts without a human keystroke per reply. On PyPI.