falconet

module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT

README

falconet

A falconet is a small cannon: one precise shot, aimed by hand, at a target you picked on purpose. This one turns a plain-language infrastructure request into a pull request a person can review — and then stops, because planning is the repository's plan bot's job and applying is a human's.

Status: it works, and it is one binary. One static Go binary runs in CI, where every job installs it from a release and checks its digest, and on a laptop, where falconet init does the install and falconet doctor checks it. It has run live on a real consumer since 2026-08-21 and reached pull requests. Each live run has also found a wiring bug that no unit test could see, and each is now a case in the suite. See Where this stands.

What it does

Someone files an issue that says, in ordinary words, what they want changed. falconet:

  1. assigns itself the issue and opens a branch
  2. runs one agent pass with a deliberately narrow toolset — it edits config and writes a commit message, and holds no shell and no push token
  3. commits through deterministic guards that an agent cannot talk its way past
  4. pushes the branch the moment a commit exists, and opens a pull request whose body is the agent's own account of the change, labelled for human review

Every exit is a terminal state: a pull request, a question for the requester, or a hand-off to a human. A request never disappears into a green run that produced nothing.

falconet does not plan. The plan a reviewer reads is posted on the pull request by the plan bot your repository already runs on every pull request — Atlantis or dflook/terraform-github-actions are the known-good options — from credentials falconet never holds. The pull-request body carries no plan and the agent is told not to describe one: the evidence is the bot's comment, and branch protection on its status is what stands between the pull request and an apply.

What it will not do is apply anything. The gate at the end is a person.

Install it in your repository

Four steps, and each ends with Check: — how to see that it worked before you go on.

  1. Install the binary
  2. Mint FALCONET_SETUP_TOKEN
  3. Run falconet init
  4. File the canary

Nothing is vendored and nothing of falconet's is checked out into your repository: the caller workflow names a tag of this repository, and every job installs the binary that tag vouches for. Upgrading is changing the tag. The eight things init does and doctor checks are each a command in the appendix — the manual path, and the numbering init and doctor use when they print a line like MISSING 5. label needs-info.

1. Install the binary

Two ways, and one snag on macOS.

From the release page.

Your machine Asset
Apple silicon Mac falconet_darwin_arm64
Intel Mac falconet_darwin_amd64
Linux x86-64 falconet_linux_amd64
Linux arm64 falconet_linux_arm64

Pick the tag from the releases page, then:

tag=v0.2.0
asset=falconet_darwin_arm64            # from the table above
base="https://github.com/zetlen/falconet/releases/download/$tag"

curl -fsSL -O "$base/$asset"
curl -fsSL -O "$base/checksums.txt"

# checksums.txt is sha256sum's own format, so the tool checks it for you.
shasum -a 256 --ignore-missing -c checksums.txt   # Linux: sha256sum --ignore-missing -c

chmod +x "$asset"
mkdir -p ~/.local/bin
mv "$asset" ~/.local/bin/falconet                 # anywhere on your PATH

Verify the checksum rather than trusting the download. A release tag is a mutable pointer and an asset can be replaced — the same reason action.yml pins gitleaks by digest as well as by version. falconet's own linux_amd64 digest is committed in this tree at release/falconet_linux_amd64.sha256, written before the tag exists; the release workflow rebuilds those bytes on a runner and publishes nothing at all if they differ.

The macOS quarantine snag. A file a browser downloads gets a com.apple.quarantine attribute, and Gatekeeper will not run an unsigned, un-notarised binary that carries one. Observed on macOS 26: it does not fail with a message — the process simply hangs. curl does not set the attribute, so the recipe above never trips over this; if a browser fetched the file, clear it before the first run:

xattr -d com.apple.quarantine ~/.local/bin/falconet

Clearing it after a denial did not reliably help in testing — Gatekeeper had already made up its mind about that file. Clear it first, or re-download with curl. This is documented rather than solved: signing and notarising means an Apple Developer account and a signing identity in CI, the level of commitment docs/operating.md declines everywhere else — as it declines a Homebrew tap and a curl … | sh install script.

With Go.

go install github.com/zetlen/falconet/cmd/falconet@v0.2.0

Nothing is quarantined this way: the file is compiled locally rather than arriving through a browser. It builds with your Go, not the pinned one — GOTOOLCHAIN=auto, the default, is a floor and not a pin — so falconet version may report a Go newer than the release assets do. That is fine here, where nothing is compared against a digest; it is exactly what the release workflow must not do, and does not.

Check: falconet version prints the tag and the Go it was built with — a v0.2.0 build on an Apple-silicon Mac says:

falconet v0.2.0 (go1.26.7 darwin/arm64)

A go install of a commit rather than a tag reports the pseudo-version the go command recorded instead of a tag, and a build from a checkout says dev; either runs, and both matter in step 3, where init pins the caller workflow to the version it reports.

2. Mint FALCONET_SETUP_TOKEN

init writes to your repository through GitHub's API — the labels, the secrets, the App — and doctor reads through it. Both authenticate with one variable, FALCONET_SETUP_TOKEN, and nothing else.

On github.com → Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token:

Field Set it to
Repository access Only select repositories → the one you are installing into.
Expiration 7 days. A setup credential is powerful and short-lived: this one is for steps 2–4 and never again.
Repository permissions The four below, and nothing else. (Metadata: read comes with every fine-grained token.)
Permission Level For
Administration Read the Actions-policy checks (appendix step 1)
Actions Read the same
Secrets Read and write the three secrets (appendix steps 3–4)
Issues Read and write the four labels (appendix step 5)

A classic token needs repo. Neither kind needs Contents or Workflows: init commits the files it writes locally and never pushes.

Export it in the shell you will run init from, without putting it in that shell's history:

read -rs FALCONET_SETUP_TOKEN && export FALCONET_SETUP_TOKEN   # paste the token, press Enter; nothing is echoed

Deliberately its own name. GITHUB_TOKEN and GH_TOKEN are not read — in CI they are the Actions token, which cannot do this and must never be asked to; on a laptop they are whatever you set for something else — and a credential this powerful should be named for what it is.

Check: in a clone of the repository, falconet doctor. Without the token every remote line says cannot tell … (no FALCONET_SETUP_TOKEN) and the permission table above is printed on stderr; with it, those lines answer — ok, or MISSING with the command that fixes it on the next line, which is the expected state before step 3. A token short of a permission says which one:

cannot tell  3. secret FALCONET_APP_ID (403 Resource not accessible by personal access token — needs Secrets: read)
3. Run falconet init

From the root of a clean clone — untracked files included, because the one commit init makes must carry only what it wrote — with the token exported:

falconet init

falconet init -h lists the flags: an App registered by hand, a name for the one it registers, --no-browser, --no-commit.

What it does, in this order. Every read comes before any write, and the first write is the one that is harmless to repeat, so a token short of a permission fails before anything hard to undo has happened:

  1. Reads. The tree is clean (a dirty one is refused, exit 1, before anything else); an existing config parses — then, through the token, the repository, its issues, its Actions policy, its secrets and its labels. Issues disabled, or an Actions policy that refuses outside workflows (appendix step 1), is reported MISSING and left for you: init never changes a repository setting.
  2. The labels (appendix step 5): infra-request, needs-info, ready-for-human, needs-plan-review, each created unless it exists.
  3. The secret that is a value (appendix step 4). ANTHROPIC_API_KEY is read from a no-echo prompt when stdin is a terminal, and from stdin otherwise — falconet init … < key-file, or piped — never from an argument, which would sit in shell history; an empty answer skips it. It is sealed to the repository's public key and stored; the value is never echoed, and can never be read back. A secret that already exists is left alone unless --replace-secrets.
  4. The App (appendix step 3), by manifest. init serves a page on localhost and opens it in your browser. The page sends the App's configuration to GitHub — the three repository permissions, no webhook, installable only on this account — and you click Create GitHub App there. GitHub sends the browser back with a code; init exchanges the code for the App's ID and private key and seals both straight into FALCONET_APP_ID and FALCONET_APP_PRIVATE_KEY. The key never touches disk: there is no .pem to download and nothing to delete afterwards. Then the App's install page opens; click Install, then Only select repositories, and pick this repository, and init waits — ten minutes by default, --app-timeout — until it sees the installation. For an App you registered by hand instead: --app-id N --app-key file.pem. --no-browser prints each URL for you to open; --no-app leaves step 3 for you.
  5. The files (appendix steps 2, 6 and 7), then one commit: .falconet/ in .gitignore; .github/falconet.json naming the prompt; prompts/implement.md, the shipped prompt copied in so you can edit its standing-facts block; and .github/workflows/infra-requests.yml, the caller, with uses: pinned to the version of the binary that wrote it — @v0.2.0 from a release build. (A dev build, or a go install of an untagged commit, has no tag to name and pins main, which doctor then notes as unpinned; put the tag there yourself, step 4.) Committed, never pushed: pushing a workflow file through the API needs a scope the token does not have and should not, pushing over your own git credentials needs nothing, and the last step stays in your hands.

Every step is one line on stdout in doctor's format, then a summary, then Left for you: — the push first, then anything it skipped, then the canary, then the check. A run through the manifest flow with a v0.2.0 build, against the test suite's fake GitHub (the App ID and the key id are the fake's fixtures; yours will differ):

ok           1. the working tree is clean
ok           1. the repository has issues enabled
ok           1. allowed_actions is all
note         1. default_workflow_permissions is read (fine: the caller workflow grants what it needs)
done         5. label infra-request created
done         5. label needs-info created
done         5. label ready-for-human created
done         5. label needs-plan-review created
done         4. secret ANTHROPIC_API_KEY stored (sealed to key 568250167242549743)
done         3. secret FALCONET_APP_ID stored (sealed to key 568250167242549743)
done         3. secret FALCONET_APP_PRIVATE_KEY stored (sealed to key 568250167242549743)
done         3. the GitHub App falconet-zetlen-wayfinders-infra (ID 12345) is registered, installed on zetlen/wayfinders-infra, and its two secrets are stored
done         2. .falconet/ added to .gitignore
done         6. .github/falconet.json written (prompts.implement: prompts/implement.md)
done         6. prompts.implement names prompts/implement.md, copied from the shipped prompt
done         7. .github/workflows/infra-requests.yml written (uses zetlen/falconet/.github/workflows/falconet.yml@v0.2.0)
done         committed "Install falconet" (4 files)
init: 3 ok, 13 done, 0 skipped, 0 missing, 0 cannot tell

Left for you:
  1. git push origin main
  2. step 6 — edit the standing-facts block in prompts/implement.md: it describes the repository falconet was extracted from (its registrar sandbox, its scratch tenant), and the agent will believe it of this one until it says what is true here
  3. step 8 — file a canary issue: the smallest change the repository can carry (one DNS record, one tag), labelled infra-request, then watch the run; once it has reached a pull request, pin the ref in uses: to the SHA or tag you ran
  4. then: falconet doctor

Without a token init still writes the files and commits them, and lists steps 3–5 under Left for you: in the appendix's words — it degrades to the manual path, never to nothing. A run that ends early says where: a refused write is stopped at step N; what was done before it stands, and a second run carries on from here, exit 1; a browser that never came back leaves the App under Left for you: and exits 0. Every step is idempotent, so the answer to anything unfinished is the same command again.

Do the Left for you: list in order. The push is its first item; the edit to prompts/implement.md's standing-facts block (appendix step 6) is worth making before it, in the same push.

Check: falconet doctor, in the same clone, after the push. Every line ok and exit 0. It never writes anything — every call it makes is a read. This is its output on the clone the init run above left, against the same fake GitHub (which is why nothing is missing):

ok           1. the repository has issues enabled
ok           1. allowed_actions is all
note         1. default_workflow_permissions is read (fine: the caller workflow grants what it needs)
note         1. runners must be Linux x64 (not checked: runs-on is the caller's input, and ubuntu-latest is the default)
ok           2. .falconet/ is gitignored
ok           3. secret FALCONET_APP_ID exists (a value can never be read back, so the name is the check)
ok           3. secret FALCONET_APP_PRIVATE_KEY exists (a value can never be read back, so the name is the check)
ok           4. secret ANTHROPIC_API_KEY exists (a value can never be read back, so the name is the check)
ok           5. label infra-request
ok           5. label needs-info
ok           5. label ready-for-human
ok           5. label needs-plan-review
ok           6. .github/falconet.json parses
ok           6. prompts.implement names prompts/implement.md, which exists
ok           7. .github/workflows/infra-requests.yml exists
ok           7. it uses zetlen/falconet/.github/workflows/falconet.yml@v0.2.0
ok           7. permissions grants contents: write, issues: write, pull-requests: write
doctor: 15 ok, 0 missing, 0 cannot tell

note lines are not checks. A MISSING line carries the command that fixes it on the next line; a cannot tell says why — no token, or a permission the token is short of. Two things doctor cannot see. That the App is installed: it holds no key to ask with, so init's done 3. line — which it prints only once it has seen the installation — is the check, and a run that fails at actions/create-github-app-token with Could not find installation is the other way to find out. And that the workflow is registered on GitHub: gh workflow list after the push, or the Actions tab.

4. File the canary

Pick the smallest change your repository can carry — one DNS record, one tag — and file it the way a requester would, via the form or:

gh issue create --label infra-request \
  --title "Canary: add a TXT record for falconet" \
  --body "Please add a TXT record named falconet-canary on example.com with the value \"hello\"."

Then watch. gh run watch follows it, or the Actions tab:

When What you should see
within a minute A comment on the issue: Thanks — this request has been picked up and is being worked on automatically. That is gate saying ready: eligibility passed, the issue is assigned and the branch exists.
next implement: one agent pass, then every guard, then the commit. The agent's only output that outlives the run is its commit message.
next publish: the push first — issue-<n>-canary-add-a-txt-record-for-falconet appears on the remote before anything else happens — then the pull request.
within ~15 minutes One of exactly three endings on the issue, below.
always contain runs whatever happened above, and if the issue is still open with neither a pause label nor an open PR, it pauses it ready-for-human with a link to the run.

The three endings:

Ending What it looks like What to do
A pull request, labelled needs-plan-review Title is the agent's commit subject. Body is its explanation, and nothing else; your plan bot's comment with the plan follows. Read the plan the bot posted. It should show the canary's resources and nothing else — anything else is drift, not the agent. Then close the PR without merging unless you mean to apply it; in a repository that deploys on merge, the merge is the apply. Delete the branch, close the issue.
A question, labelled needs-info A comment asking the requester something. Answer it in a comment. That comment re-enters the pipeline: the label is cleared and the same issue is worked again with the answer in hand.
A hand-off, labelled ready-for-human A comment saying why a person is needed, linking the branch if one was pushed and the run. Read the reason. It is one of the guards refusing, and the text names which.

The ending that is not on that list — a red run and an issue with only the acknowledgment, or nothing at all — is a failed gate, and it is silent. See Troubleshooting.

Pin a tag. The ref in uses: is the one coordinate: the workflow at @v0.2.0 installs, in every job, the binary whose digest the tree at v0.2.0 holds. init wrote the tag of the binary that ran it. If you wrote the caller by hand, put the tag there — never main, which moves, and which doctor notes as unpinned:

    uses: zetlen/falconet/.github/workflows/falconet.yml@v0.2.0

If you are upgrading a caller from the bash era, delete its falconet-ref: input as well. It no longer exists — there is no checkout left for it to choose — and a reusable workflow rejects an input it does not declare when the caller's file is loaded, so the run is a startup_failure with nothing on the issue. doctor says so:

MISSING      8. falconet-ref is no longer an input; remove it
             the run would be a startup_failure: a reusable workflow rejects an input it does not declare when the caller's file is loaded

Check: one of the three endings on the issue — and on a pull request, your plan bot's comment showing the canary's resources and nothing else. No comment means the bot is not planning falconet's pull requests; that is the bot's configuration, and it has to be fixed before the next request.

How it is built

Four verbs, one per stage. They never call each other; they pass files through the handoff directory, so the same sequence runs in CI and on a workstation. Uniform exit codes: 0 an outcome was determined, 1 refused or a check failed, 2 usage. The verbs that decide something print exactly one word on stdout.

Verb What it does Words
prepare --issue N eligibility gate, assignment, branch, the handoff ready in-flight ineligible
commit every guard, then the commit the agent cannot make success needs-info failure
push --branch B the branch onto the remote, the moment a commit exists —
pause --issue N --label L a terminal state, said where the requester reads it success failure

Three more are for a person at a keyboard, and they are the whole of the install path above:

Verb What it does Exit
doctor checks the repository it stands in against the appendix's steps 1–7, read-only, one line per check 0 every check ok; 1 otherwise
init does the appendix's steps 2–7, each idempotent and reported one line, then one commit and never a push 0 everything attempted succeeded (a skipped step is not a failure); 1 a dirty tree, a refused write, a repository that cannot be reached
version the tag and the Go it was built with 0

prompt, config and scan exist unlisted — public in that they work, not vocabulary, by the register's criterion that a thing is a verb if and only if a caller invokes it directly. -h on any of them says what it does.

The reusable workflow runs the four as four jobs — gate, implement, publish, contain — and the boundaries between the jobs are the security model: the agent's job has permissions: {} and no secret but the model key; the scripted jobs hold the token and do the mechanics. .github/workflows/falconet.yml documents the trade it makes and why.

What a run needs, in CI: git, gitleaks and the binary, and nothing else. action.yml installs the two as the first step of every job — gitleaks and falconet by version and digest, falconet's digest being the one committed in this tree at release/. No OpenTofu: the plan bot brings its own. On a workstation, the same, plus a browser for init's App step. The binary needs neither jq nor gh: the verbs speak to GITHUB_API_URL themselves. (The workflow still uses gh in two of its own run: steps, on GitHub's runner, where it already is.)

The same verbs run by hand, against the repository you are standing in — no workflow. prepare and pause read GH_TOKEN (then GITHUB_TOKEN) and GITHUB_REPOSITORY=owner/name; prepare falls back to the origin remote, and pause — which operates on an issue rather than a tree — deliberately does not. The rest need nothing:

falconet commit
Design commitments
  • Deterministic mechanics, agent judgment. The agent decides what the change is. The binary does the branching, committing, pushing and the pull request — so those steps cannot be skipped, improvised, or argued out of.
  • Guards are incident-shaped. Every one of them exists because something went wrong once. They are documented with the incident that caused them, in the comment above the guard, and the port moved those comments into Go verbatim: the operator reads Go, and the guards are the product.
  • One agent, one context. An earlier design ran a second reviewing agent; measurements showed the second cold context cost more than it caught.
  • The plan is the evidence, and it is not falconet's. The plan bot posts it whole on the pull request; the agent is told not to describe it and the body carries none, because a human approving a summary of evidence is not review. Since 2026-08-26 falconet runs no tofu at all: a fifth of the tree had reimplemented what Atlantis and dflook do, and it was cut.
  • Opinionated on purpose. GitHub and Claude Code are assumed. An OpenTofu or Terraform repository is the shape. Being agnostic across forges is an explicit non-goal.

Why it exists

It was built inside an OpenTofu repository and worked well enough that the surrounding repo became mostly pipeline: ~4,700 lines of workflow and shell against ~1,000 lines of actual infrastructure. Two attempts to escape that — adopting an off-the-shelf agentic workflow, or trimming in place — both concluded the same way: the workflow is a good tool wearing a repository as a costume. So it becomes a tool. The founding record has the measurements that killed the off-the-shelf option.

Where this stands

Piece State
cmd/falconet/, internal/ — the binary four verbs, two setup verbs and version; the standard library plus golang.org/x/crypto/nacl/box for the sealed box the secrets API demands
tests/ 13 files through the binary (make test), with go test ./... beside it; the wiring invariants are tests/contract.test.sh
release/ + .github/workflows/release.yml the digest in the tree before the tag, four assets and checksums.txt per tag
the plan not falconet's: the repository's plan bot, on the pull request
prompts/ embedded in the binary; the standing-facts block is the origin's
Live runs yes, on a real consumer, on the bash (2026-08-21) and on the binary since v0.2.0. Each found a bug that only integration finds. The plan side was removed on 2026-08-26 and has not yet run live in the new shape against a plan bot

The charter is what falconet is for, in one page: the six invariants that hold, the non-goals, and the line between those and everything that is merely how it is built today. The decision register holds every live decision, with the invariant it serves, the observation that should retire it, and why. docs/history/ is how those decisions were reached, kept for its incidents and measurements; it is not a description of the tree. operating covers the credentials only the operator can create; AGENTS.md is what to read before changing anything here.

Running the tests

make test                    # build, go test ./..., then the suite through dist/falconet
bash tests/run.sh            # the suite alone (make build first)
bash tests/run.sh prepare    # just the files whose name contains "prepare"
make check                   # go vet, staticcheck, errcheck, govulncheck at ci.yml's pins

The suite is the acceptance bar and the incident record. Every case spawns $FALCONET <verb> — dist/falconet, or another build of the same contract — and reads stdout, the exit code and files on disk; nothing reaches inside its subject. It stubs gitleaks with a bash script handed in through $GITLEAKS, whose argv is part of the contract. GitHub is tests/fixtures/fake-github.py, a loopback server that answers from fixtures and records what it was asked, with GITHUB_API_URL pointing at it. Pushes land only in bare repositories under a temp directory; nothing touches the network, GitHub or any credential. No test stubs gh anywhere — the files that once did put a tripwire on PATH, so a verb that shelled out to it would fail loudly before the real one could carry a test token anywhere.

go test ./... covers what the suite cannot see from outside a process: unit and property tests (testing/quick) beside the guard logic — a pause comment's truncation never splits a line and never exceeds its budget, the fence outruns every backtick run, the denylist matches in config order, the config merge, the slug and the in-flight pattern, the sealed box opening with the private key, the App manifest and its JWT, the dispatcher's lists in step with what it implements. go vet, staticcheck, errcheck and govulncheck run in CI beside it, and make check runs the same four at the same pinned versions on a laptop: an ignored error is a red build. The suite needs bash, git, jq, awk and python3 (stdlib only); go test needs Go.

Appendix: the manual path

The eight steps falconet init does, by hand, and what falconet doctor checks against — the specification of each write and each check, and the numbering both verbs use. Every gh command here runs from inside the repository you are installing into; gh and jq are the manual path's tools, on your machine, not things falconet needs.

It stays in this file on purpose, at its full length. It is the honest measure of what installing this thing costs a person, and shortening the document does not shorten the install — every step here is one init has to do correctly, and one doctor has to be able to check. Move it to its own file and the cost stops being visible; the length is the point.

  1. Check the repository qualifies
  2. Ignore the handoff directory
  3. Create the GitHub App and store its two secrets
  4. Store the Anthropic API key
  5. Create the four labels
  6. Write .github/falconet.json
  7. Add the caller workflow
  8. Run a canary issue
1. Check the repository qualifies
  • A plan bot on pull requests. Atlantis, dflook's terraform-plan, or whatever already posts a plan when a person opens a pull request; it must plan pull requests opened by the App from step 3 too. falconet never runs tofu, and a pull request nothing plans is a pull request nobody can review. Not checked by doctor — it cannot see another bot — which is why the canary in step 8 ends by reading the bot's comment.
  • Issues enabled. gh api repos/{owner}/{repo} --jq .has_issues → true.
  • Actions may run workflows from outside the repository. gh api repos/{owner}/{repo}/actions/permissions --jq .allowed_actions must be all, or selected with zetlen/falconet, actions/* and anthropics/claude-code-action in the list. A repository restricted to local actions stops before any of this runs.
  • Linux x64 runners. The action installs pinned linux_x64 release assets of gitleaks and of falconet itself and checks their digests, so macOS or ARM fails the checksum.
  • A clean tree on a fresh checkout. Two verbs read git status. If a hook or generator leaves untracked files behind on checkout, gitignore them.

If gh api repos/{owner}/{repo}/actions/permissions/workflow says default_workflow_permissions is read — the default for new repositories — that is fine: step 7's caller workflow grants what it needs explicitly.

doctor checks the second and third bullets — issues are enabled, the Actions policy admits those three — and reports the policy as MISSING when it is wrong; the runner is a note (it is the caller's runs-on), and the clean tree on a fresh checkout is not checked. Neither doctor nor init changes a repository setting.

2. Ignore the handoff directory

The verbs pass files to each other through .falconet/, which must never be part of a change. Add it to .gitignore and commit:

printf '.falconet/\n' >> .gitignore
git add .gitignore && git commit -m "Ignore falconet's handoff directory"

Check: git check-ignore -v .falconet/ names the line.

In CI the workflow excludes this path per clone whether or not you did this. The entry is for running the verbs by hand — where commit would otherwise refuse its own scratch files as paths outside the allowlist — and for the human who runs git add -A.

3. Create the GitHub App and store its two secrets

A GitHub App registered purely as a credential: no webhook, nothing hosted. init registers one by manifest from your browser and the private key never touches disk (step 3 of the install above). By hand, on github.com → Settings → Developer settings → GitHub Apps → New GitHub App (under the organisation's settings if the repository belongs to one):

Field Set it to
GitHub App name Anything unique across GitHub. Comments and pull requests are authored as <this name>[bot].
Homepage URL The repository's URL; it is required and unused.
Webhook → Active Untick.
Repository permissions Contents: Read and write, Issues: Read and write, Pull requests: Read and write. Nothing else.
Where can this GitHub App be installed? Only on this account.

After Create GitHub App, on the App's page:

  1. Note the App ID near the top.
  2. Under Private keys, Generate a private key. A .pem downloads.
  3. In the left sidebar, Install App → your account → Only select repositories → this repository → Install.

Then, from inside the repository:

gh secret set FALCONET_APP_ID --body '<the App ID>'
gh secret set FALCONET_APP_PRIVATE_KEY < ~/Downloads/<app-name>.<date>.private-key.pem
rm ~/Downloads/<app-name>.<date>.private-key.pem

The whole PEM, header and footer lines included. Or hand the two to init, which seals them and runs the rest of the steps: falconet init --app-id <the App ID> --app-key ~/Downloads/<the .pem>.

Check: gh secret list shows both, and the repository's Settings → GitHub Apps lists the App as installed. A run that fails at actions/create-github-app-token with Could not find installation has the App registered but not installed here.

An App rather than a PAT or GITHUB_TOKEN because pull requests opened with GITHUB_TOKEN do not trigger workflows — your CI would never run on the PRs falconet opens — and App-token pushes do. operating.md says why each credential is the kind it is.

4. Store the Anthropic API key
gh secret set ANTHROPIC_API_KEY

An API key from the Anthropic console, not a Claude Code subscription token: if you already run anthropics/claude-code-action with claude_code_oauth_token, that secret is a different thing and will not work here. A dedicated key keeps falconet's spend a separate number — set a budget alert on it. The agent pass is capped at 40 turns and 30 minutes.

Check: gh secret list shows ANTHROPIC_API_KEY.

5. Create the four labels
for l in infra-request needs-info ready-for-human needs-plan-review; do
  gh label create "$l" 2>/dev/null || echo "$l already exists"
done
Label Applied by Config key
infra-request a person, to queue a request issue.queue_label
needs-info falconet, pausing a question back to the requester labels.needs_info
ready-for-human falconet, pausing a run a person has to take over labels.human
needs-plan-review falconet, on the pull request it opens labels.pr

All four before the first run: pause says failure and fails its step when the label it was asked for cannot be put on the issue, which is at precisely the moment falconet is trying to tell somebody something. init creates them with a colour and a description each; doctor reports each one that is missing.

An issue form with labels: ["infra-request"] in its front matter means requesters never have to label anything. A checkbox whose text is Not eligible for AI agents (issue.opt_out_text) lets them keep a request away from the agent.

Check: gh label list --json name --jq '.[].name' | grep -cxE 'infra-request|needs-info|ready-for-human|needs-plan-review' → 4.

6. Write .github/falconet.json

Optional — every key has a default. init writes only the prompt override:

{
  "prompts": {
    "implement": "prompts/implement.md"
  }
}

The file is merged over the defaults: naming one key changes one thing. Arrays replace wholesale rather than append, because an allowlist that grows by accident is not an allowlist. A malformed file is a hard failure with the parse error, never a silent fall back to defaults.

Every key, with its default:

Key Default What it is
paths.allow ["*.tf"] Globs the agent's change must stay inside; * crosses /, so *.tf matches dns/records.tf. Anything outside is refused and nothing is committed.
paths.deny_content data "external", provisioner, local-exec, remote-exec, templatefile(, filebase64(, file( Constructs refused anywhere in a changed .tf, in this order.
issue.queue_label infra-request The label that makes an issue eligible.
issue.blocking_labels needs-info, ready-for-human, do-not-apply, wontfix Any of these present and the issue is ineligible. Need not exist.
issue.opt_out_text Not eligible for AI agents A ticked checkbox with this text makes the issue ineligible.
issue.branch_prefix issue- Branches are <prefix><number>-<slug>.
issue.in_flight_prefixes ["issue-", "claude/issue-"] An open PR from a branch with any of these prefixes and this number means "already in flight".
labels.needs_info / labels.human / labels.pr needs-info / ready-for-human / needs-plan-review Step 5's labels, if you named them differently.
prompts.implement the shipped prompts/implement.md, embedded in the binary Path, relative to your repository root, of a prompt of your own for the agent. Absent, the shipped one is used.
prompts.pause_needs_info the shipped prompts/pause-needs-info.md, embedded in the binary Likewise, for the question posted back to a requester.
handoff_dir .falconet Where the verbs leave files for each other. Gitignore it if you move it.

The one default that does not transfer is the prompt. The shipped prompts/implement.md carries a "standing facts" block describing the repository this came from — its registrar sandbox, its scratch tenant — and the copy embedded in the binary is that one. To change it, copy the file into your repository as prompts/implement.md (which is what init does — byte for byte, so the two placeholders below stay placeholders), replace that block with what is true of yours, and point prompts.implement at the copy. {handoff} and {workspace} in it are substituted at run time, by falconet prompt implement — which is why that command's output is not the copy to commit: it has already put this machine's paths where the placeholders were.

Check: jq -e . .github/falconet.json > /dev/null && echo parses, and every prompts.* path names a file under the repository root — doctor's 6. lines.

7. Add the caller workflow

One file, .github/workflows/infra-requests.yml, and this is the whole of it — init writes exactly this, with uses: pinned to its own version:

name: infra requests

on:
  issues:
    types: [opened, labeled, reopened]
  issue_comment:
    types: [created]

# One run per issue. `opened` and `labeled` arrive seconds apart on a freshly
# filed request, and without this they are two runs racing to open two pull
# requests for the same issue.
concurrency:
  group: falconet-${{ github.event.issue.number }}
  cancel-in-progress: false

# A called workflow can only narrow the caller's token, never widen it, so
# each of these must be at least what the widest job inside declares —
# `publish` declares `contents: write` to push. That check happens when the
# file is LOADED: grant less and the run is a `startup_failure` with no jobs,
# no logs and nothing on the issue.
#
# It is narrower than it reads. `implement`, the job that runs the agent,
# declares `permissions: {}` and holds no token at all; `gate` and `contain`
# narrow themselves back to `contents: read`. Only `publish` receives this,
# and it pushes with the App token in any case.
permissions:
  contents: write
  issues: write
  pull-requests: write

jobs:
  falconet:
    uses: zetlen/falconet/.github/workflows/falconet.yml@main
    with:
      issue: ${{ github.event.issue.number }}
    secrets:
      app-id: ${{ secrets.FALCONET_APP_ID }}
      app-private-key: ${{ secrets.FALCONET_APP_PRIVATE_KEY }}
      anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
Input Required Default What it is
issue yes — The issue number to work.
config no .github/falconet.json Path to the config file.
runs-on no ubuntu-latest Must stay Linux x64.

Three things about this file that are not obvious:

  • It triggers on every issue event and decides eligibility inside. A job-level if: evaluates before checkout and can never read .github/falconet.json, so gating there would fork eligibility into YAML-in-CI and nothing-locally. prepare decides instead, reading the same config a workstation reads, and an ineligible event costs runner-seconds and stops. Eligible means: the issue is open, carries the queue label, carries none of the blocking labels, has no ticked opt-out box, and has no open pull request already on a branch for that number. A comment from a bot, or on a pull request, is never a way in. A comment from a person on an issue paused needs-info is the way back in.
  • The ref in uses: must be a literal — GitHub does not expand expressions there — and it is the one coordinate: the workflow at that ref installs, in every job, the binary whose digest that ref's tree holds. main is where the template starts and it moves; put a tag there — @v0.2.0 — which is what init writes, and what step 4 of the install says. There is no falconet-ref input any more: the bash-era caller passed it to choose which falconet the jobs checked out, nothing is checked out now, and a caller still passing it is rejected when the file is loaded.
  • It coexists with a stock claude.yml. If you already run anthropics/claude-code-action on issue events, that one starts on an @claude mention and this one on the queue label. Don't write @claude in an infra request unless you want both.

Check: after pushing, gh workflow list shows infra requests. Before pushing, falconet doctor's three 7. lines: the file exists, it uses the reusable workflow, and its permissions: block grants what the widest job declares.

8. Run a canary issue

Step 4 of the install above, unchanged: file the smallest change the repository can carry, watch the table, expect one of the three endings, read the plan bot's comment on the pull request, and make sure the ref in uses: is a tag.

Troubleshooting
What you see Why Do
The run is startup_failure: no jobs, no logs, and nothing on the issue at all The caller grants less than a job inside declares, or passes an input the workflow does not declare — falconet-ref, from a bash-era caller. GitHub checks both when the workflow file is loaded, so nothing runs and nobody is told — including the requester. Until 2026-08-21 this README prescribed contents: read, which publish exceeds. Step 7's permissions: block, verbatim; no falconet-ref:. falconet doctor reports both.
gate is red and the issue has no comment prepare hard-failed before the acknowledgment — the one failure the requester never hears about, because contain is conditioned on the gate having said ready. Open the run; the last lines of Prepare name the cause. The usual one is the next row.
A pull request with no plan comment on it Your plan bot is not planning pull requests the App opens — a bot that only plans a member's pull requests, or a path filter falconet's branch does not match. The bot's configuration. Nothing in falconet decides this.
prepare: working tree is dirty before the agent ran:, listing paths Something in your repository creates untracked files on checkout. Gitignore them.
init: the working tree is dirty, and the commit init makes must carry only what it writes; commit or stash these first: init refuses a dirty clone for the same reason. Commit or stash, then init again.
init: could not create label infra-request: POST …/labels: 403 Resource not accessible by personal access token — the token needs Issues: write, or init: could not store secret …: … — the token needs Secrets: write, then stopped at step N; what was done before it stands, and a second run carries on from here FALCONET_SETUP_TOKEN lacks a permission from the table in step 2 of the install. init writes the labels first so this happens before anything hard to undo. Regenerate the token with the permission named, export it, init again.
falconet doctor says cannot tell … (403 … — needs Secrets: read), or Issues: read, Administration: read The same token, read side. The same.
init: state mismatch — refusing the code on the terminal, falconet init: state mismatch — refusing the code in the browser GitHub's redirect carried a state that is not the one this run sent — a stale tab from an earlier init, most likely. One is refused and init keeps waiting for the right redirect; a second ends step 3 as skipped 3. … (the App was not registered: two redirects arrived with the wrong state), with nothing stored. Close old tabs and run init again; it carries on from where it stopped.
skipped 3. … (the App was not registered: no redirect from GitHub within 10m) The browser never came back: the page was not opened, or Create GitHub App was not clicked in time. init again. --no-browser prints the URL to open by hand; --app-timeout lengthens the wait.
cannot tell 3. the App is installed (timed out after 10m — install it at https://github.com/apps/<name>/installations/new, then run falconet doctor) The App is registered and both secrets are stored; the install click did not happen in time. Open that URL → Install → Only select repositories → this repository. Left for you: repeats it.
Could not find installation at create-github-app-token The App exists but is not installed on this repository, or the App ID is wrong. The row above; or step 3.
Resource not accessible by integration The caller's permissions: block is missing, or the App lacks one of its three permissions. Steps 3 and 7.
sha256sum: WARNING: 1 computed checksum did NOT match in the install step The runner is not Linux x64 — both gitleaks' and falconet's pinned assets are the Linux x86-64 ones, and the digest is checked before anything is installed — or a release asset was replaced, which is what the digest in the tree exists to catch. runs-on: ubuntu-latest. A replaced asset is not yours to fix; do not run it.
the installed falconet reports '…', not v0.2.0 in the install step The asset at that release runs but is not the version the tree pins. The same as the row above.
Paused ready-for-human: The agent changed files it is not allowed to change … Refused paths: .falconet/… A run by hand with the handoff directory not ignored. Step 2.
could not add label <name> to #N: … in a pause step, and the word failure The label could not be put on the issue: one of step 5's labels is missing, or the App lacks Issues: write. The comment was still posted if it could be, and contain tries again. Step 5; then step 3's permissions.
Two runs, two PRs, one issue The caller lacks the concurrency block. Step 7.
The PR's explanation talks about a sandbox or a tenant you do not have The shipped prompt's standing facts are the origin's. Step 6, prompts.implement.
Known limits
  • The change is not validated before the pull request. No tofu validate, no tofu fmt: a syntactically broken change reaches the pull request, and the plan bot is what says so. The guards that remain are the path allowlist, the content denylist and the secret scan.
  • The plan bot is yours to run. falconet cannot tell whether one is configured, or whether it plans the App's pull requests; the canary is the check.
  • A failed gate is silent to the requester. See the first troubleshooting row. Watch the first run.
  • @main moves. Pin a tag; init does.
  • doctor cannot see the installation. It holds no App key to ask with. init confirms the install when it makes it; afterwards, the repository's Settings → GitHub Apps, or the first run.
  • Never put issue text in args. If you call action.yml directly, its args input is split on whitespace and reaches a shell. Issue titles, bodies and comments are attacker-controlled, and the reason every verb takes files rather than strings is so that text never travels that way.

Support

None promised. This is built for one operator's infrastructure repository and made public because someone else may find the shape useful. Issues and pull requests may go unanswered; fork freely.

License

MIT. See LICENSE.

Directories

Path Synopsis
cmd
falconet command
falconet — turn a plain-language infrastructure request into a pull request a person can review, and stop there.
falconet — turn a plain-language infrastructure request into a pull request a person can review, and stop there.
internal
appmanifest
Package appmanifest is README step 3 — the GitHub App — done by manifest, as pure functions: the App's name, the manifest GitHub is handed, the page the browser is sent to, the check on what GitHub sends back, and the two URLs a person is asked to open.
Package appmanifest is README step 3 — the GitHub App — done by manifest, as pure functions: the App's name, the manifest GitHub is handed, the page the browser is sent to, the check on what GitHub sends back, and the two URLs a person is asked to open.
commit
Package commit is the guards the commit verb is made of — the path allowlist, the content denylist, the reading of `git status`, the split of a message into a pull request's title and body, and the words each refusal says to the requester.
Package commit is the guards the commit verb is made of — the path allowlist, the content denylist, the reading of `git status`, the split of a message into a pull request's title and body, and the words each refusal says to the requester.
config
Package config is one JSON file, every key optional, with defaults that reproduce the origin repository's behavior exactly.
Package config is one JSON file, every key optional, with defaults that reproduce the origin repository's behavior exactly.
doctor
Package doctor is README "Install it in your repository" steps 1–8 as code: each step's Check: line, as a pure function over what the verb has already fetched or read.
Package doctor is README "Install it in your repository" steps 1–8 as code: each step's Check: line, as a pure function over what the verb has already fetched or read.
github
Package github is falconet's own GitHub client: net/http against GITHUB_API_URL, a token the caller hands in, and the handful of endpoints the verbs need.
Package github is falconet's own GitHub client: net/http against GITHUB_API_URL, a token the caller hands in, and the handful of endpoints the verbs need.
handoff
Package handoff is the directory the verbs talk through, and the one place that knows $GITHUB_ENV is optional.
Package handoff is the directory the verbs talk through, and the one place that knows $GITHUB_ENV is optional.
pause
Package pause is the hand-over comment the pause verb posts, and the two rules the verb holds it to — the pause-label allowlist and the cap — with the record of why above each.
Package pause is the hand-over comment the pause verb posts, and the two rules the verb holds it to — the pause-label allowlist and the cap — with the record of why above each.
prepare
Package prepare is the eligibility gate — the rules that decide whether an issue is this pipeline's to work — as pure functions over an issue snapshot, the event that woke the run, the config, and the open pull-request list; and the two things the ready path derives from the snapshot without judgment, the request in markdown and the branch name.
Package prepare is the eligibility gate — the rules that decide whether an issue is this pipeline's to work — as pure functions over an issue snapshot, the event that woke the run, the config, and the open pull-request list; and the two things the ready path derives from the snapshot without judgment, the request in markdown and the branch name.
repo
Package repo answers one question: which repository is this verb operating on?
Package repo answers one question: which repository is this verb operating on?
scan
Package scan reads the text this pipeline is about to publish and stops the run if any of it is shaped like a credential.
Package scan reads the text this pipeline is about to publish and stops the run if any of it is shaped like a credential.
secrets
Package secrets is the sealed box a repository secret travels in.
Package secrets is the sealed box a repository secret travels in.
setup
Package setup is what `falconet init` decides and what it writes, as pure functions: the labels to create, the stacks a repository holds and how the flags sort them, the bytes of each file, the commit message, and the words of what is left for a person.
Package setup is what `falconet init` decides and what it writes, as pure functions: the labels to create, the stacks a repository holds and how the flags sort them, the bytes of each file, the commit message, and the words of what is left for a person.
Package prompts is the shipped prompts, embedded in the binary.
Package prompts is the shipped prompts, embedded in the binary.
tools
docslint command
docslint holds the shape of this repository's records.
docslint holds the shape of this repository's records.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL