Task-specific instructions. Read this before any task. CLAUDE.md holds the
always-on rules; this file holds the working set that evolves as the system
matures.
This file grows as the system matures. When you learn something new about
HN during a task — a working selector, a UI change, a new gotcha, a
recovery from a failure mode, an updated cadence observation, an HN
feature rollout, a story-type-specific quirk — edit this file (or
CLAUDE.md if the learning is invariant-shaped) before ending the task.
Future sessions inherit only what's written down here; anything that
lives only in the current conversation is lost on the next run.
- Edit the relevant existing section rather than appending a duplicate. If a prior note proved wrong or partial, correct it in place.
- Lead with the concrete fact, then the why, then the workaround.
- Put runnable recipes in fenced code blocks future-you can paste.
- Hard rules / invariants / forbidden actions / daily caps belong in
CLAUDE.md, not here. - If a learning contradicts something already in either file, fix the stale entry in the same edit — don't leave both versions standing.
Every HN interaction goes through the browser-use MCP (or the
browser-use CLI as a subprocess when MCP is misbehaving) attached to
the dedicated Chrome profile. This is non-negotiable per CLAUDE.md —
even though the harness is unauthenticated, the API mirrors are
forbidden because side-channel HTTP traffic to HN's APIs while a
browser is on the same machine is a noisy traffic pattern moderation
watches for.
Concrete "do this / not that" cheat sheet:
| Need to do… | ✅ Do | ❌ Never do |
|---|---|---|
| Search HN | browser_navigate https://hn.algolia.com/?q=…&type=story and read DOM |
Hit hn.algolia.com/api/v1/search?query=… |
| Browse a feed | Navigate to /news, /newest, /ask, /show, /best, /from?site=…, /threads?id=… |
Pull the same content from hacker-news.firebaseio.com/v0/topstories.json etc. |
| Read site guidelines | Navigate to /newsguidelines.html and /newsfaq.html |
curl them |
| Pull a story's body / comments | Navigate to /item?id=N and read the DOM |
Hit hacker-news.firebaseio.com/v0/item/N.json |
| Read a user's profile | Navigate to /user?id=<handle> and /threads?id=<handle> |
Hit /v0/user/<handle>.json |
The CLI form, when MCP is wedged:
CDP=http://127.0.0.1:9334
BU="uvx --from browser-use[cli] browser-use --cdp-url $CDP"
$BU open "<url>" # navigate
$BU state # accessibility tree + element indices
$BU eval "<js-iife>" # extract structured data via JS
$BU click <index> # click element by index from `state`
$BU type "<text>" # type into focused element
$BU scroll down # scroll pageNot the Firebase API, not Algolia, not RSS, not a separate playwright
driver — even those that use Chrome are out (different control surface,
the fingerprints don't line up perfectly enough).
- HN UI is plain server-rendered HTML, no shadow DOM, no SPA. This means selectors are stable across sessions. The site barely changes. When something breaks, suspect rate-limiting / shadowban / login wall before suspecting a UI change.
- The page header exposes login state.
<span class="pagetop"><a id="me" href="user?id=<handle>">handle</a> ...</span>indicates a logged-in session; aloginlink there means logged out. We don't depend on either state — drafts are account-agnostic — so don't branch on this. If a feed or thread fails to load, suspect rate limiting first, not login state. /item?id=<id>returns the same content for stories and comments. A comment ID resolves to its own page with its parents and replies. Useful for permalinks; the comment-as-permalink form ishttps://news.ycombinator.com/item?id=<comment-id>.- Reply forms are not always rendered. Threads older than ~2 weeks (the exact window varies) stop accepting replies even without an explicit close marker. Check for the textarea presence before attempting to compose; if absent, abort and surface to the user.
- Dead / flagged comments. Comments by shadowbanned users render with
a
[dead]marker for logged-in operators withshowdead=yes; for others they're invisible. Don't reply to dead comments — the reply itself often inherits the dead status. Check for the marker on the parent before drafting. - HN "search" via Algolia.
https://hn.algolia.com/?q=...is the user-facing search UI. It's a SPA but the results are server-rendered enough to extract withbrowser_get_state/browser_extract_content. The underlyinghn.algolia.com/api/v1/search?query=...endpoint is forbidden perCLAUDE.md.
This harness is unauthenticated by policy (see CLAUDE.md preamble).
There is no operating account to detect, and no draft field to fill
in with a handle. Do not run identity-detection snippets, do not ask
the user "which account?", do not error out when the profile shows
the login link. The user picks the posting account themselves at
post time.
Before the first browser tool of a session, probe
http://127.0.0.1:9334/json/version (or run scripts/verify-cdp.sh).
If it doesn't respond, run scripts/win-chrome.sh yourself — don't ask
the user. The launcher is idempotent (it refuses a second instance if
one is already running with the hn-profile), so a redundant call is
safe. After launching, poll /json/version every ~1s until it returns
a JSON body, then proceed. Only escalate to the user if the launcher
itself errors out.
Important caveat — MCP launch-order trap. The browser-use MCP
initializes its CDP root client once at MCP startup. If Claude Code
started before Chrome was up, the MCP is stuck in a "not initialized"
state: browser_navigate will return "Navigated to: ..." (it goes
through a different code path that auto-connects), but every read
primitive errors with Root CDP client not initialized,
Expected at least one handler to return a non-None result, or empty
browser_list_tabs. Bringing Chrome up mid-session does not heal
this — the MCP needs to be restarted. Ask the user to run /mcp and
restart browser-use, then retry. Going forward, prefer to launch
Chrome (via scripts/win-chrome.sh) before starting Claude Code so
the MCP boots into a healthy state.
browser_extract_content additionally requires an LLM API key
(OPENAI_API_KEY or equivalent) inside the MCP env. If it isn't set,
fall back to browser_get_html plus your own parsing, or to
browser-use --cdp-url … eval '<js>' from the shell.
Where the MCP actually reads cdp_url from. The --cdp-url flag on
uvx … browser-use --mcp … is a CLI-mode-only flag — it is silently
ignored when --mcp is also set. The MCP server reads cdp_url from
~/.config/browseruse/config.json (browser_profile.<default>.cdp_url).
Same file also holds keep_alive: true so the MCP doesn't try to close
the running Chrome on shutdown. If you change Chrome's port, edit that
file too — not just .mcp.json.
Concrete trap (observed 2026-04-28): that config file is shared
across harnesses on this machine, and the Reddit harness sets it to
http://127.0.0.1:9333. So in HN sessions the MCP boots pointing at
the Reddit port, every primitive errors with
connect() timed out after 15s — CDP connection to http://127.0.0.1:9333,
and the --cdp-url 9334 flag on the MCP command line buys you nothing.
Two acceptable workarounds:
- Drive the entire session via the
browser-useCLI subprocess (it honors--cdp-urldirectly). This is the path documented inCLAUDE.mdfor "MCP primitives are buggy" and works end-to-end — open / state / eval / click / type / scroll. The whole HN reply flow is pure CLI-friendly. - Before starting Claude Code, edit
~/.config/browseruse/config.jsonsobrowser_profile.<default>.cdp_urlreadshttp://127.0.0.1:9334, then start Chrome (scripts/win-chrome.sh), then start Claude Code so the MCP boots clean. Don't forget to reset to 9333 if you switch back to the Reddit harness.
The right long-term fix is per-profile config files (a
browser-use --config <path> flag) so the two harnesses don't fight
over a single global. Until then, default to option 1 — CLI subprocess
form is reliable and doesn't need any global-state edits.
MCP / CLI session-lock conflict. If the MCP is alive (even if
wedged) and the CLI is also being driven, you can hit
Session 'default' is already running with different config. Run \browser-use close` first.on the *second* CLI invocation in a session. Resolve by runningbrowser-use --cdp-url … closeonce, then re-issue the command. Doesn't recur within the sameopen->eval->click` chain — only when the MCP grabs the session in between.
Before exiting any cron-driven or otherwise unattended task, close the dedicated Chrome instance. The cron flow brings Chrome up at the start of each run and expects it gone at the end; a leaked process makes the next cron tick's launcher refuse to relaunch (the launcher's "already running" check matches by user-data-dir) and silently break the next hour's run.
Last step of every run, after all artifacts (comment draft in
drafts/<ts>.md, commit, push, PR) are flushed and the PR URL is
captured:
bash scripts/win-chrome-close.shThat shells out to a PowerShell Stop-Process -Force against any
chrome.exe whose CommandLine contains the hn-profile user-data-dir.
Idempotent - safe to call when Chrome isn't running. Same predicate
as launch-hn-chrome.ps1's "already running" check, so the match is
byte-identical.
This pairs with scripts/hourly_hackernews_cron.py: the cron brings
Chrome up at the start of each run; the agent tears it down at the
end. Skipping the close step leaks a Chrome process that the next
cron tick will treat as "already running" and refuse to relaunch,
which would silently break the next hour's run.
Use the same primitives a human would:
browser_navigate(url)— go to a feed, the search page, an item permalink.browser_get_state()— structured page state with indexed clickable elements; cheap, your default after every navigate.browser_get_html(selector)— when you need raw markup of a region (e.g., a single comment's table row).browser_extract_content(query, extract_links=true)— when you need the model to summarize/structure what's on the page.browser_scroll(direction)— for paginated feeds and Algolia search results; pause between scrolls.
HN doesn't have subreddits — it's one site with several feeds. To find threads where FailProof AI is relevant, sweep across:
/news— front page (top 30 by ranking). High-traffic, harder to break in mid-thread but high-leverage./newest— most recent submissions. Catch threads early before they're saturated; many die on/newestwithout making the front page, but the comment audience is engaged operators./ask— Ask HN. Direct match for "how do I keep my agent from doing X" style questions. Highest signal-to-noise for FailProof conversations./show— Show HN. Competing tools, reliability frameworks, hook managers, dev-tool launches. A measured "here's how this overlaps with FailProof AI" comment can land if it's substantive./best— high-engagement recent threads. Good for "what's the audience talking about today" without trawling./from?site=anthropic.com(orcursor.com,aider.chat,continue.dev, etc.) — every submission from a domain. Useful for catching every Anthropic / agent-tool launch story./threads?id=<handle>— a user's recent comments. Useful when researching who is posting in a topic, or skimming a specific user's recent take.https://hn.algolia.com/?q=…&type=story(or&type=comment) — Algolia HN search UI. Drive the form, read the result list withbrowser_get_state. Time filter via thedateRangequery param or the UI dropdown. Vary the filter across sessions so the same query doesn't repeat in the account's history./item?id=<id>— story or comment permalink. The "parent" link on a comment walks up the tree; the indented<table>rows are the reply children.
Same vocabulary as the claude-reddit playbook, adapted for HN's audience:
- Specific failure modes:
agent rm -rf,force push main,agent deleted,secrets leaked,claude destroyed,agent went off the rails. - Guardrail intent:
claude code hooks,agent guardrails,policy engine,pretooluse,babysitting claude,tool call policy. - Competing-product mentions:
cursor rules,aider conventions,claude code firewall,agent permissions. - Show HN scoping: prefix
Show HNto filter; useful for finding competing reliability-tool launches.
Rotate the search query across sessions so the same query doesn't
appear in the account's history twice in a row (CLAUDE.md rule 8 on
read cadence).
Before picking a thread to comment on, scan three surfaces for prior coverage:
drafts/on the current branch — proposed reply files Claude has already written here, awaiting manual post.comments/on the current branch — log of replies the user has manually posted on HN and asked Claude to archive (with the comment permalink). The user populates this; Claude does not.- Open PRs on this repo — proposed comments on other branches,
pending user review/post. Each PR commits a
drafts/<timestamp>.md, so the thread ID surfaces in the diff.
Don't drift into the writing step on a thread one of those already covers — the user would just be re-posting themselves into a duplicate. Concrete recipe:
THREAD_ID=<id-from-news.ycombinator.com/item?id=...>
# Local logs (current branch). Anchor on item?id= so the THREAD_ID
# can't false-match a substring elsewhere (handle name, body text,
# different field). Scan both drafts/ (in flight) and comments/
# (already posted).
grep -rl "item?id=$THREAD_ID" drafts/ comments/ 2>/dev/null
# Open PRs (proposed comments on other branches). gh pr diff shows the
# patch; if a comment for this thread is in flight there, it will
# mention the thread ID as part of an item URL in the new file.
gh pr list --state open --json number --jq '.[].number' \
| while read pr; do
gh pr diff "$pr" 2>/dev/null \
| grep -q "item?id=$THREAD_ID" \
&& echo "PR #$pr already covers id=$THREAD_ID"
doneIf any of those returns a hit, surface it to the user and pick a different thread. Same recipe runs again at the formal duplicate check (Writes step 3) as a belt-and-suspenders.
Per CLAUDE.md "Comments via PR (never direct post)", Claude does not
submit to HN. Every "write" — top-level comment, reply, story
submission — is produced as a drafts/<utc-timestamp>.md file,
committed on a fresh branch, pushed, and surfaced as a PR. The user
reviews on GitHub, posts manually to HN, then merges the PR. Voting and
favoriting are also paused.
The sibling comments/ directory is a log of replies that were
actually posted on HN, not a write target. A new file lands there
only when the user (after posting manually) asks for the posted reply
to be logged with its permalink. Claude does not write to comments/
on its own.
The flow:
-
Pull the page state with
browser_get_state. -
Confirm HN guidelines and the thread's local norms permit what you're proposing (read OP body and top 3-5 comments to gauge tone; for Show HN, check whether commenter affiliation is welcome).
-
Duplicate check (
CLAUDE.mdrule 7): re-run the three-surface coverage scan from the search section above (drafts/andcomments/on the current branch, and open PRs on this repo) for an entry pointing at the same thread ID. If any surface matches, abort the draft and surface the existing coverage to the user. (This duplicates the pre-selection check on purpose: a fresh PR might have landed on another branch since you picked the thread.) We do not match ona.hnuseragainst an operating handle — the harness is unauthenticated, so there's no handle to match. The user does the "have I personally commented on this thread already?" check manually before posting. -
Write the full text. No em-dashes, en-dashes, fancy ellipses, curly quotes, or unicode arrows (rule from
CLAUDE.mdbrand voice). HN pattern-matches these to LLM output even faster than Reddit does. -
Cross-thread duplicate guard: don't reuse the same body or a near-identical paraphrase across proposed comments on multiple threads, even on different topics. Each comment must materially engage with its thread's content. Skim
drafts/andcomments/(current branch) and the diffs of open PRs before writing. -
Save the comment to
drafts/<utc-timestamp>.md. Filename format: UTCYYYY-MM-DDTHHMMSSZ(filesystem-safe — no colons in the time portion). Example:drafts/2026-04-30T143022Z.md. Sort order = creation order. One file per intended post (top-level, reply, or submission). Do not write intocomments/— that's the posted-reply log, populated only when the user asks for a posted comment to be archived.Required sections:
- HN: thread URL (
https://news.ycombinator.com/item?id=<id>), plus parent comment URL if this is a reply (also of theitem?id=form). For a story submission, this points tohttps://news.ycombinator.com/submit. After the user posts and asks you to log the permalink, append the comment permalink as a second URL on this line. - Story / OP: one line each. No operating-account field — drafts are account-agnostic.
- The post: OP body, or a 2-3 sentence summary if very long. For replies, also include the parent comment being replied to, verbatim.
- My reply: the exact text to paste into the HN composer, in a fenced block. ASCII punctuation only.
- Insight for the FailProof team: one observation about the thread that's useful for product / marketing / engineering. Not "the comment is good" — something actionable: what framing landed, what gap in the product surfaced, what feature could ship from this signal, who else in this thread is asking the same question, what blog post would drop into this conversation naturally next time.
- Notes / findings: anything else worth recording. HN UI quirks, anti-bot signals, sub-thread norms, related threads worth following.
- HN: thread URL (
-
Commit, push, open PR. The four-step workflow from
CLAUDE.md"Comments via PR (never direct post)" andREADME.md"Strict comment workflow":- Commit the new
drafts/<ts>.mdon a fresh branch (never onmain). Commit message clearly identifies the thread or topic. git push -u origin <branch>.gh pr createwith title[claude-hackernews] <one-line summary of the proposed comment / thread>and a body that summarizes the target thread, parent (if reply), and the proposed text.- Surface the PR URL back in chat. The user reviews on GitHub, posts manually to HN, then merges the PR (merge = "I posted it"). After they've posted, they may ask you to append the comment-permalink to the HN: line and re-commit; wait for that ask, don't proactively edit.
- Commit the new
-
Do NOT click submit, type into a HN textarea, or otherwise interact with a write surface on HN. The composer recipe below (
Driving the HN comment composer) is currently inert perCLAUDE.md— preserved for the day the rule is lifted, but not to be run today.
Aborted writes (duplicate check, guidelines mismatch, user reject) do not get a file or a PR. Only saved comment files are tracked on disk; each one ships through its own PR.
Do not run this flow. Per CLAUDE.md "Comments via PR (never direct
post)", Claude does not click submit on HN. The recipe below is
preserved for the day the rule is lifted (it was working as of
2026-04-28: top-level comment, reply, edit, submit, vote). When that
day comes, the comment-file + PR flow above continues to govern what
gets posted; this recipe governs how.
Vastly simpler than Reddit's Lexical contentEditable. HN's comment
composer is a plain server-rendered HTML form — <textarea> plus a
<input type=submit>. The standard CDP primitives work directly.
Working flow (top-level new comment on a story):
CDP=http://127.0.0.1:9334
BU="uvx --from browser-use[cli] browser-use --cdp-url $CDP"
# 1. Navigate to the story permalink, then run the duplicate-check
# snippet from "Writes" step 3 above.
$BU open "$STORY_URL"
# 2. Locate and focus the top-level reply textarea. On a story page,
# the "add comment" form is at the bottom; the textarea has
# name="text". Sometimes there's a leading hidden form field
# (hmac, parent id) — leave those alone, just focus the textarea.
$BU eval '(()=>{const ta=document.querySelector("form textarea[name=\"text\"]");if(!ta)return JSON.stringify({err:"no textarea"});ta.scrollIntoView({block:"center",behavior:"instant"});const r=ta.getBoundingClientRect();ta.focus();return JSON.stringify({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2),focused:document.activeElement===ta});})()'
# 3. Inject the comment text. Decode base64 → UTF-8 to dodge shell
# escaping pain around quotes / backticks; set value via the native
# textarea prototype setter so any listener fires; then dispatch
# input + change events.
DRAFT_B64=$(base64 -w 0 < /tmp/draft.md)
$BU eval "(()=>{const ta=document.querySelector('form textarea[name=\"text\"]');const txt=new TextDecoder('utf-8').decode(Uint8Array.from(atob('$DRAFT_B64'),c=>c.charCodeAt(0)));Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ta),'value').set.call(ta,txt);ta.dispatchEvent(new Event('input',{bubbles:true}));ta.dispatchEvent(new Event('change',{bubbles:true}));return JSON.stringify({len:ta.value.length});})()"
# 4. Click the submit button. On a story page it's the only
# <input type=submit> inside the comment form; value="add comment".
$BU eval '(()=>{const btn=document.querySelector("form input[type=submit][value=\"add comment\"]");if(!btn)return JSON.stringify({err:"no submit"});btn.scrollIntoView({block:"center",behavior:"instant"});const r=btn.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x+r.width/2),y:Math.round(r.y+r.height/2)});})()'
$BU click $SUBMIT_X $SUBMIT_Y
# 5. Verify by re-fetching the thread and re-running the duplicate-check
# eval — operating handle should now appear in the comment list.
# If it doesn't but the textarea is empty, the submit went to a
# confirmation page; navigate back to the story and re-check.
$BU open "$STORY_URL"For replying to a specific comment (not the story root):
The "reply" link on a comment goes to /reply?id=<comment-id>&goto=….
Click that link via browser_click (or navigate directly to
/reply?id=<comment-id>). The reply page renders a single
<textarea name="text"> and a <input type=submit value="reply">.
Same recipe as above with the value selector adjusted (value="reply"
instead of value="add comment").
For editing your own comment, click the "edit" link in the comment
header (only visible for your own recent comments, ~2h window). The
edit page has the same form structure; submit value is update.
For story submission, navigate to /submit. Form fields:
<input name="title">— required, ≤ 80 characters by HN convention<input name="url">— fill this XORtext, not both<textarea name="text">— fill XORurl<input type=submit value="submit">
HN dedupes URLs aggressively: submitting a URL that another user
posted recently redirects you to their thread (/item?id=<theirs>).
After clicking submit, check location.pathname / re-read the page to
confirm whether you landed on /newest (your post live) or someone
else's /item.
For voting, each item has <a id="up_<id>"> (and <a id="down_<id>">
once your account has the karma threshold for downvoting). Clicking
toggles to a "nosee" / "unvote" state. Verify by re-fetching state
and checking the arrow's id changed.
Why a verbose recipe: the comment composer doesn't need this
much — a browser_click on the textarea and browser_type works in
the simple case. But the explicit native-setter + base64 dance avoids
the two known pitfalls that bit the Reddit harness: (a) shell escaping
on multi-line drafts with special characters, and (b) framework
listeners not firing on direct .value = assignments. Use the simple
path first; if a draft fails to commit, fall back to this recipe.
- Login wall.
/login?goto=…— HN gated something we tried to read. Don't log in to dismiss it; back off and surface to the user. Most read paths (/news,/newest,/item,/user, Algolia search UI) work logged out. - Rate limit. "We have a daily limit on new submissions" or "submitting too fast" — we don't submit, so this should not surface for our flow. If it does, the read sweep has been too aggressive; stop, wait ≥ 30 minutes, surface to user.
- Shadowban / flagged. Not applicable — the harness does not post, so it accumulates no comments to be marked dead or flagged. Whichever account the user posts from later is their concern.
- No reply form. Thread is too old or closed. Note this in the draft (so the user knows the thread is unrepliable before they try to paste); don't try to simulate the composer either way.
If the user has failproofai installed with the block-read-outside-cwd
policy, it pattern-matches URL substrings as filesystem paths and
blocks any bash command that contains https://news.ycombinator.com/...
or http://127.0.0.1:9334 as a literal. The error looks like:
Bash read outside project directory blocked: /news.ycombinator.com/item...
The browser-use CLI takes the URL on the command line, so a normal
invocation gets blocked. Workaround: base64-encode both the CDP URL and
the target HN URL into the bash command literal so the matched
substring never appears in the script string. The decoded values pass
to $BU as runtime args, which the policy doesn't see.
CDP=$(printf '%s' "aHR0cDovLzEyNy4wLjAuMTo5MzM0" | base64 -d) # http://127.0.0.1:9334
URL=$(printf '%s' "<base64-of-https-news-ycombinator-com-...>" | base64 -d)
BU="uvx --from browser-use[cli] browser-use --cdp-url $CDP"
$BU open "$URL"Encode each HN URL fresh per command (printf '%s' "<url>" | base64 -w 0).
Comment lines also matter: a # https://news.ycombinator.com/... comment
in the same bash invocation triggers the same match, so strip URL-shaped
comments before running. This is a workaround, not a fix — the policy's
matcher is too liberal, and the right long-term answer is to tighten its
pattern (anchor on actual cat/read/file-op verbs, or stat the
matched substring) so URL arguments don't false-positive. (Filed against
failproofai already from the Reddit harness; same fix lands here.)
.mcp.json connects to http://127.0.0.1:9334 — not
http://localhost:9334. On this WSL, getent hosts localhost resolves to
::1 (IPv6) only. Chrome's remote-debugging endpoint listens on IPv4
(127.0.0.1) only. browser-use's Python WebSocket client does not
happy-eyeball the mismatch. If you ever see "Root CDP client not
initialized" or empty browser_list_tabs after a successful navigate,
check .mcp.json is on 127.0.0.1, not localhost.
FailProof AI is an open-source policy / hook manager for AI coding agents. It sits between Claude Code (or the Anthropic Agents SDK) and the system, intercepting tool calls and applying reliability policies before and after each one. Use this section as ground truth whenever the topic of the conversation on HN involves the product — answering questions, writing posts, replying to comments.
- Repo: https://github.com/exospherehost/failproofai
- Docs: https://befailproof.ai
- npm:
failproofai - License: MIT + Commons Clause
- Made by: ExosphereHost Inc
- Slack community: linked from the README badges
- Runs entirely locally — no session content, file names, or tool inputs
leave the user's machine. Anonymous PostHog telemetry only (event names),
opt-out via
FAILPROOFAI_TELEMETRY_DISABLED=1. - Requirements: Node.js >= 20.9.0. Bun >= 1.3.0 is optional and only needed for building from source.
The easiest way to manage policies that keep AI coding agents reliable, on-task, and running autonomously - for Claude Code and the Agents SDK.
Claude Code exposes PreToolUse / PostToolUse / Notification / Stop
hooks. FailProof AI installs hook entries into ~/.claude/settings.json (or
project / local scope) that route every tool call through its policy engine.
Each policy returns one of:
| Function | Effect |
|---|---|
allow() |
Permit the operation |
allow(msg) |
Permit, send informational context to the agent |
deny(msg) |
Block the operation; message shown to the agent |
instruct(msg) |
Inject guidance into the agent's context, do not block |
- 39 built-in policies for the common agent failure modes - destructive commands, secret leakage, working outside project bounds, pushes to protected branches, accidental publishes, etc.
- Custom policies in JavaScript via the
allow/deny/instructAPI. Supportsasync, transitive local imports, andprocess.env. Fail-open on error (logged to~/.failproofai/hook.log; built-in policies keep running). - Three-scope config — global (
~/.failproofai/policies-config.json), project (.failproofai/policies-config.json), local — merged automatically (project -> local -> global). - Convention-based loading — drop
*policies.{js,mjs,ts}files into.failproofai/policies/, commit the directory, and every teammate gets the same guardrails on next pull. Files load alphabetically; prefix with01-,02-to control order. User-level~/.failproofai/policies/also loads (union with project-level). - Agent Monitor dashboard at
http://localhost:8020(start withfailproofai) — browse sessions, inspect every tool call, see exactly which policies fired and why.
- Block destructive ops —
block-sudo,block-rm-rf,block-curl-pipe-sh,block-force-push,block-push-master,block-work-on-main,block-failproofai-commands(no self-uninstallation). - Sanitize secrets out of agent context —
sanitize-api-keys,sanitize-jwt,sanitize-connection-strings,sanitize-private-key-content,sanitize-bearer-tokens. - Keep agents in-bounds —
block-env-files,protect-env-vars,block-read-outside-cwd,block-secrets-write. - Warnings on risky ops —
warn-destructive-sql,warn-schema-alteration,warn-large-file-write(configurablethresholdKb),warn-package-publish,warn-git-amend,warn-git-stash-drop,warn-all-files-staged,warn-background-process,warn-global-package-install.
Many policies expose params (allowPatterns, allowPaths,
protectedBranches, additionalPatterns, thresholdKb, hint) so users
can tune without writing code.
npm install -g failproofai
failproofai policies --install # writes hooks into ~/.claude/settings.json
failproofai # opens dashboard at http://localhost:8020
failproofai policies # list what's active
failproofai policies --install block-sudo block-rm-rf sanitize-api-keys
failproofai policies --uninstallScope flag: --scope project writes to .claude/settings.json,
--scope local writes to .claude/settings.local.json. Default is global.
import { customPolicies, allow, deny, instruct } from "failproofai";
customPolicies.add({
name: "no-production-writes",
description: "Block writes to paths containing 'production'",
match: { events: ["PreToolUse"] },
fn: async (ctx) => {
if (!["Write", "Edit"].includes(ctx.toolName ?? "")) return allow();
const path = ctx.toolInput?.file_path ?? "";
if (path.includes("production")) return deny("Writes to production paths are blocked");
return allow();
},
});The ctx object exposes: eventType, toolName, toolInput, payload,
session.cwd, session.sessionId, session.transcriptPath.
- "Is it open source?" Yes — MIT + Commons Clause. Source on GitHub.
- "Does it phone home?" Only anonymous PostHog telemetry (event names,
not content). Disable with
FAILPROOFAI_TELEMETRY_DISABLED=1. - "How is this different from Claude Code's built-in hooks?" Claude Code ships the hook mechanism; FailProof AI ships a curated set of 32 policies, a JS SDK for writing your own, three-scope config merging, and a dashboard for inspecting what fired. You could replicate it by wiring hooks yourself — failproofai is the batteries-included version.
- "Does it work with Cursor / Aider / continue.dev / Cline?" Today
the officially supported surfaces are Claude Code, OpenAI Codex, and
the Anthropic Agents SDK. Install hooks for both via
failproofai policies --install --cli claude codex(omit--clito auto-detect). Other agents only work if they call the same hook protocol. GitHub Copilot CLI integration is in beta (PR #236). - "What's the performance overhead?" Hooks run as a Node subprocess per tool call. Negligible for normal use; expensive custom policies will be felt.
- "What happens if a hook crashes?" Fail-open: the error goes to
~/.failproofai/hook.log, remaining policies continue, the agent is not blocked. - "Can I share policies with my team?" Yes — commit
.failproofai/policies/to the repo. Files there auto-load on every teammate's machine, no config flags needed. - "Why Commons Clause?" Standard reason — prevents a third party from reselling the project as a hosted service while keeping it free for developer use. The repo / docs are the source of truth on the exact carve-outs.
HN's audience is more skeptical of marketing language than Reddit's;
the voice rules from CLAUDE.md apply doubly here. The rules below
are non-optional. A draft that violates the structural rules
(Thread-fit gate, Length and shape, Forbidden in any reply)
does not get committed or pushed as a PR. Rewrite it or skip the
thread; skipping is free.
These rules came out of a post-mortem after the first flagged FailProof comment on HN (see Worked example: working vs flagged below). Don't soften them without re-running that comparison.
Only draft a FailProof-mentioning reply when the thread offers a concrete failure mode the product addresses OR is a Show HN that explicitly solicits product discussion. A reply that mentions FailProof on a thread where the parent did not ask a tool-shaped question is a pitch by definition, and HN flags pitches.
| Thread shape | Mention FailProof? |
|---|---|
| Concrete-failure post: "agent deleted my DB", "agent force-pushed to main", "agent leaked my .env", "rm -rf'd my node_modules" | Yes — name ONE policy + ONE snippet tied to that exact failure |
| Show HN of an adjacent product (sandbox, gateway, hook manager, policy engine) where the OP solicits design discussion | Yes — disclose affiliation, lead with substantive engagement on their design before mentioning FailProof |
| Ask HN about meta-topics: "is Claude Code getting worse?", "how do you balance meta-tools and shipping?", "what's your harness setup?", "what's your CLAUDE.md look like?" | No. Reply on the topic without the product, or skip the thread |
| Vent threads: "I'm done with X tool", "Y feels broken lately", "agents are mid" | No. Empathy + concrete diagnosis only; no product mention |
| Threads where the parent's pain is at the model layer (regression, context window, reasoning quality), not the tool layer | No. FailProof does not solve model regressions; saying so reads as opportunism |
| Long-tail / saturated front-page threads where mid-thread visibility is near-zero | No. A pitch buried in a 1000-comment thread is read as keyword-hunting, not as help |
If the thread fails the gate, the right move is no draft at all, not a softer pitch. A flagged comment costs the operating account karma and audience trust; not posting costs nothing.
A FailProof-mentioning reply has a strict shape, derived from what worked vs what got flagged:
- One disclosure line at the very top:
(disclosure: I work on FailProof AI: https://github.com/exospherehost/failproofai). Nothing softer ("so weight that accordingly", "for full transparency" read as hedges), nothing fancier. Plain parens. - One paragraph of substantive on-topic content that would stand on its own with the FailProof mention removed. If you can't write that paragraph, the comment is a pitch — abort.
- At most ONE policy name mentioned by name OR ONE custom-policy snippet, directly tied to the OP's described failure. Never both. Never a comma-list of policy names.
- Body under ~150 words. The successful first FailProof reply was ~110 words; the flagged one was ~220 and read as marketing copy. Length is a signal on HN — short comments with code carry more weight than long comments without.
- The repo URL appears once. If it's in the disclosure line, do not repeat it at the bottom.
These show up across the flagged drafts in this repo and are non- negotiable. If you find yourself writing one of these, the comment is already on the wrong path; back up and rewrite.
- Install / quick-start commands in the comment body. No
npm install -g failproofai, nofailproofai policies --install, nofailproofaidashboard-launch line. HN reads install commands in comments as ad copy. If the reader wants to install, they click the disclosure link and read the README. - Comma-listed policy names (
block-rm-rf, block-force-push, sanitize-api-keys, sanitize-jwt, warn-destructive-sql, ...). Pick exactly one, tied to the OP's failure. The reader does not need a feature catalog mid-thread. - Config-feature / version-number talk. No "three-scope merge",
"convention-based loading", "39 built-in policies", "latest release
v0.0.X", "fail-open on error", "MIT + Commons Clause",
~/.failproofai/path callouts, or anything else that lifts straight from the README's feature list. That's README content, not comment content. - Marketing-cadence connectives. "...and ~30 more", "The point is...", "you just turn it on", "we built X for", "exactly what we built X to solve", "the gap we built X for". Cut every one of these on review; if cutting one breaks a sentence, the sentence was a pitch.
- Two-link pattern. Disclosure link at the top + repo link at the bottom = two links to the same project in one comment. Once is enough; usually the disclosure line is the right place.
- Dashboard / UI plugs. Mentioning
http://localhost:8020or the Agent Monitor in a comment is feature-tour content. Skip it unless the OP literally asked "how do I see what fired?".
The two reference points live in this repo. Re-read them before drafting any FailProof-mentioning comment.
- Working shape:
comments/2026-04-29T043958Z.md(prod-DB thread, id=47911524). Posted, not flagged. Names exactly one policy (warn-destructive-sql) and one custom-policy snippet that solves OP's literal reported failure (DROP DATABASE→DROP TABLE). ~110 words. No install command. No feature dump. No three-scope talk. The product is the source of the snippet, not the subject of the comment. - Flagged shape:
drafts/2026-05-01T184439Z.md(Ask-HN-Claude- Code-getting-worse thread, id=47936579). Posted, [flagged] within ~40 minutes. Names nine policies in a comma-list, includes an install command in a fenced block, talks about three-scope merge and.failproofai/policies/checked-in semantics, and pivots to the product within two sentences. ~220 words. The product is the subject; the on-topic paragraph carries less than half the body.
The diff is a structural one, not a wording one. A draft that matches the flagged shape will get flagged; rewriting punctuation or softening adjectives does not save it. Either match the working shape or skip the thread.
HN explicitly asks commenters to disclose when they work on a
product they're discussing; not doing so is a fast track to being
flagged. The leading-line form above ((disclosure: I work on FailProof AI: <repo URL>)) is the norm. Do not capitalize
"Disclosure" as a sentence opener — disclosure: lowercased inside
parens reads more as an in-line aside than as a header.
When linking, use https://github.com/exospherehost/failproofai,
not https://befailproof.ai. HN clicks through to GitHub more
readily than to landing pages, and a marketing-site URL in a
comment looks more pitch-shaped on hover.
(Task-specific entries get appended below as the system matures.)