Skip to content

Tutorial

A hands-on, task-oriented walkthrough. You will ingest an existing corpus into an OKF v0.2 bundle, inspect and gate it, then author a fresh corpus and stamp its lifecycle and verification metadata. Every command below runs against a shipped binary (binder throughout, and the vendor-neutral okf in the final cross-check), and the output shown is real.

For the concise landing page see the README; for the exhaustive per-flag reference see the user guide. This tutorial is the guided path between them.

Install binder with Homebrew:

Terminal window
brew install ghchinoy/tap/binder

…or build it from source (requires Go 1.26.1+, the floor declared in go.mod):

Terminal window
git clone https://github.com/ghchinoy/binder.git
cd binder
make build # -> bin/binder
export PATH="$PWD/bin:$PATH"

Dependencies are pinned via go.mod/go.sum and fetched from the Go module proxy at build time (network required). Either way, check the binary:

Terminal window
binder --version
binder/<version>

A Homebrew or direct-download install of v0.3.0 prints exactly binder/0.3.0. The banner is always binder/<version> and never carries a leading v. That banner string is the exact one binder stamps into every concept’s generated.by, so the value you see here is the value that will show up in your bundles.

A source build prints something longer, like binder/0.2.2-0.20260816074947-7f4ca6b4c816. That is the Go module pseudo-version, and the reason is the build command, not the clone: make build runs a plain go build with no -ldflags, so nothing injects the release version and binder falls back to what the module graph knows. A fully tagged clone behaves identically. Install a release if you want a clean stamp, or pass the ldflag yourself; see docs/RELEASING.md.

Two habits make every run in this tutorial reproducible:

  • Pin the clock with SOURCE_DATE_EPOCH so any synthesised timestamp (generated.at, a resolved stale_after, a verified.at) is byte-stable.
  • Read the structured --json output, not the prose, when you script or gate.

Throughout, keep binder’s central design goal in mind — the commitment its design is built to hold: not to fabricate trust. Binder derives trust tiers from the frontmatter you provide, stamps an honest generated provenance for content it produced, and requires an explicit verifier you supply — the --verified-by flag or a verified_by default in your own global config — before it will write a verified attestation, so it never auto-stamps one and never invents a source. A verified attestation is something you asked for, and Part 2 shows exactly what counts as asking.

Part 1: brownfield, ingesting a corpus you already have

Section titled “Part 1: brownfield, ingesting a corpus you already have”

Brownfield means the knowledge already exists (a docs site, an Obsidian vault, a folder of runbooks) and the job is to get it into OKF with its relationships intact. The binder repository carries a small sample corpus with three deliberate triage cases (an unresolved link, a file with no title, and files with no frontmatter). Use it as the corpus for this part.

The corpus lives in the git repository, not in the release archive: a Homebrew or direct-download install gives you the binder binary plus LICENSE and README.md, and nothing else. So grab the repo, whichever way you installed:

Terminal window
git clone --depth 1 https://github.com/ghchinoy/binder.git /tmp/binder-src
CORPUS=/tmp/binder-src/plugins/okf-convert/skills/okf-convert/assets/sample-corpus

If you built from source above you already have the clone; point CORPUS at your own checkout instead. Every command below uses "$CORPUS", so nothing else changes. The pasted output shows the path from the shallow clone; yours will differ in that one line.

Preview the conversion before anything lands on disk. A dry run reports the concept count, the resolved and unresolved link counts, and any files whose frontmatter had to be recovered:

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder convert "$CORPUS" --dry-run
binder convert --dry-run (no files written)
source: /tmp/binder-src/plugins/okf-convert/skills/okf-convert/assets/sample-corpus
output:
concepts: 5
links: 3 (resolved 2, unresolved 1)
Concepts:
README.md [type=Note]
notes/scratch.md [type=Note]
topics/architecture.md [type=Reference]
topics/glossary.md [type=Reference]
topics/onboarding.md [type=Playbook] (1 unresolved links)
Unresolved links:
topics/onboarding.md -> /topics/deploy.md

The one unresolved link is a fact about the corpus: onboarding points at a deploy.md that does not exist. binder reports it and keeps it in place rather than dropping it. You decide whether to fix the source or accept it.

convert supplies defaults (a missing type becomes Note, a missing title is humanized from the filename), so some gaps are invisible once a bundle exists. binder lint reads the corpus as authored, before those defaults are applied, and writes nothing:

Terminal window
binder lint "$CORPUS"
binder lint
corpus: /tmp/binder-src/plugins/okf-convert/skills/okf-convert/assets/sample-corpus
concepts: 5
broken links: 1
topics/onboarding -> /topics/deploy.md
missing titles: 1
topics/glossary
schema violations: 2
README: missing type
notes/scratch: missing type
entrypoints (no inbound links): 1
README
orphans (no inbound or outbound links): 2
notes/scratch
topics/glossary
stale: 0

An orphan here is a concept with no inbound and no outbound resolved edge: a document no reader will reach by following links, and one that leads nowhere. Treat the orphan list as a to-do list for the corpus.

A concept with nothing pointing at it is an entrypoint rather than an orphan when any of three things holds: it links out to something, it is a root README.md, or you named it with --entrypoint (repeatable, and it tolerates a trailing .md). README here qualifies on the second count: it is the corpus-root README.md. It has no outbound edges of its own (every path it mentions sits inside a code span or a fenced block, so none of them is a markdown link), and renaming the file makes it a true orphan. binder reports entrypoints separately and does not count them as findings, so a corpus with a legitimate front door is not penalised for having one, and --strict never gates on them.

One edge of that rule is worth knowing: “root” means root, and README.md is the only name recognized this way. A nested docs/README.md is not recognized automatically and stays a true orphan until you pass --entrypoint docs/README.md, and a root index.md is not recognized in any spelling; it is classified on its edges like any other concept (convert renames an authored lowercase index.md to index-note.md first).

binder review applies the same rule to a bundle, but over a different graph: lint reads the corpus as authored, review reads the emitted bundle, and conversion happens in between. The two usually agree about what is an orphan, but agreement is not guaranteed: convert --fm-ref-keys related, for instance, materializes edges out of a frontmatter key that lint has no flag to read, so a note lint calls an orphan can reach review as an entrypoint.

The dry run typed almost everything Note, because that is the fallback when a file declares no type:. binder infer reads the corpus and proposes a --type-map from what it finds: directory names, filename patterns, and the types files already declare. It is proposal-only and writes nothing:

Terminal window
binder infer "$CORPUS"
notes=Note,topics=Reference

That single line is the whole prose output, which is what makes it composable. Ask --json for the reasoning behind each mapping:

Terminal window
binder infer "$CORPUS" --json | jq '.result.mappings'
[
{
"dir": "notes",
"suggested_type": "Note",
"source": "folder",
"rationale": "inferred from directory name \"notes\"",
"sample_files": [
"notes/scratch.md"
]
},
{
"dir": "topics",
"suggested_type": "Reference",
"source": "frontmatter",
"rationale": "majority of files carry authored type \"Reference\"",
"sample_files": [
"topics/architecture.md",
"topics/glossary.md",
"topics/onboarding.md"
]
}
]

Read the proposal before you use it: infer suggests a mapping, and you decide whether to adopt it. When you agree with it, feed it straight into the conversion:

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder convert "$CORPUS" -o /tmp/tut-typed \
--type-map "$(binder infer "$CORPUS")"

On this corpus that produces a bundle byte-identical to the plain conversion in Step 3, because the proposal simply reproduces the types the files already declare. infer earns its keep on a corpus where the types are implied by structure rather than written down — a docs/ tree of guides, an adr/ tree of decisions — where the alternative is hand-writing the map.

By default infer uses deterministic offline signals only, so it needs neither a network nor an API key. --gemini opts into an extra semantic tier that calls a Gemini model; that is the one place in binder where a model enters the loop, and it is off unless you ask for it.

convert leaves the source untouched when the bundle goes elsewhere; an -o that resolves to the source would rewrite it in place. Write the bundle to a separate directory:

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder convert "$CORPUS" -o /tmp/tut-bundle

The report is the dry run’s, with two differences: the header loses its “(no files written)” banner, and output: now names the bundle directory. The bundle has a root index.md declaring okf_version: "0.2", per-directory navigation, and one concept per source file.

review summarizes the finished bundle. Pin the staleness date for a stable report:

Terminal window
binder review /tmp/tut-bundle --today 2026-08-15
binder review
bundle: /tmp/tut-bundle
concepts: 5
by type:
Note: 2
Playbook: 1
Reference: 2
trust tiers:
human-reviewed: 0
machine-confirmed: 0
unverified: 5
stale (as of 2026-08-15): 0
attested computations: 0
unparsed frontmatter (recovered as body): 0
entrypoints (no inbound links): 1
README
orphans (no inbound or outbound links): 2
notes/scratch
topics/glossary
unresolved links: 1
topics/onboarding -> /topics/deploy.md

validate checks the bundle’s concept files against the OKF v0.2 §11 conformance rules. The only hard requirement is that every non-reserved concept has a parseable frontmatter block with a non-empty type; everything else it checks is an advisory:

Terminal window
binder validate /tmp/tut-bundle
echo "exit=$?"
bundle: /tmp/tut-bundle
concepts: 5, reserved files: 3
scope: reserved-file structure (index.md, log.md) not validated; verdict covers concept files only
RESULT: conformant (OKF 0.2)
exit=0

The scope: line tells you how far to trust the verdict. The bundle’s three reserved files (the generated index.md in each directory) were counted, but their structure was not examined, so conformant here is a claim about the five concept files and nothing else. That line appears only when a bundle contains reserved files; under --json the same fact is the reserved_structure_checked field, which every result carries (always false) regardless of the reserved count. It is a disclosure: it sits outside findings and changes neither the verdict nor the exit code.

The bundle is conformant and exits 0 even though it still has broken links and orphans. That is the never-reject rule in action: advisories are reported, and they gate only when you ask for gating.

Every command maps its outcome onto a stable exit-code contract, identical in prose and --json mode:

Code Meaning
0 Success. Advisories may be present but never gate unless --strict is set. The read-boundary normalization advisory never gates at all.
1 Gating findings: validate §11 non-conformance (always), or, under --strict, a command’s advisory set, which excludes the read-boundary normalization advisory (see Strict mode).
2 Usage error — anything wrong with the command line, or with the .binder.yaml that feeds it: an unknown subcommand or flag, the wrong number of arguments, an invalid actor, a malformed --today/--type-map/--status-map/--stale-after-map value, an unknown graph --format, and an unreadable corpus for lint/enrich/infer.
3 I/O or internal error — an unreadable bundle or source for convert/validate/index/review/graph, or a write failure.

Never-reject governs the corpus: binder will not refuse your documents for being imperfect. The inputs that configure the run have their own contract, and binder will refuse a value it cannot parse rather than quietly computing against something you did not mean. That applies to the config file too: a bad verified_by: in .binder.yaml is resolved before the subcommand runs, so every command exits 2 until you fix it, even one that never uses the value.

--json wraps the report in a deterministic envelope (schema binder.report/v1) with a stable field order and a trailing newline, so two runs on the same input are byte-identical. The order is declaration order, not alphabetical — only map-valued objects such as by_type have their keys sorted. Gate on the source corpus before conversion with lint --strict, which turns advisories into a non-zero exit:

Terminal window
binder lint "$CORPUS" --strict --json > /tmp/lint.json
echo "exit=$?"
jq '{broken_links: .result.broken_links, missing_titles: .result.missing_titles}' /tmp/lint.json
exit=1
{
"broken_links": [
{
"concept": "topics/onboarding",
"detail": "/topics/deploy.md"
}
],
"missing_titles": [
"topics/glossary"
]
}

Under --strict, binder also prints a one-line summary to stderr (binder: lint found 6 finding(s) (--strict)); the JSON on stdout is unaffected. That is 1 broken link + 1 missing title + 2 schema violations + 2 orphans; the lone entrypoint is reported but not counted. A clean corpus exits 0 even with --strict set, so the flag is safe to leave on permanently in CI.

--strict is available on convert, enrich, validate, review, lint, and infer. Use validate --strict to gate on trust well-formedness in addition to hard conformance, and convert --strict to gate on unresolved links or recoveries.

When your corpus is one repository among sibling repositories and you want file:// links that point into those siblings treated as internal edges rather than external references, widen the resolution boundary with --workspace-root:

Terminal window
binder convert repo-a/docs -o out --workspace-root /path/to/monorepo

The boundary defaults to the corpus root. A file:// link that resolves inside the boundary is rewritten to a bundle-relative edge, and no absolute machine path leaks into the output. A link that points to another host, or escapes the root through .. or a symlink, stays external and is tolerated as an advisory. See the user guide for the full resolution rules.

Part 2: greenfield, authoring OKF from the start

Section titled “Part 2: greenfield, authoring OKF from the start”

Greenfield means you are writing OKF fresh. The shipped path is binder enrich, which adds the required frontmatter (type, title, generated) to a source tree in place, frontmatter only, with no body rewriting. Because it mutates the source, run it on a clean git tree and review the diff.

Work somewhere outside any binder checkout (including the shallow clone Part 1 made), because enrich mutates the tree it is pointed at and the advice above is to run it on a clean git tree:

Terminal window
cd /tmp # anywhere outside a binder checkout
mkdir -p kb/drafts
cd kb
cat > payments.md <<'EOF'
# Payments
How we process customer payments. See [refunds](refunds.md).
EOF
cat > refunds.md <<'EOF'
---
type: Playbook
title: Refunds
---
# Refunds
Steps to issue a refund. Back to [payments](payments.md).
EOF
cat > drafts/idea.md <<'EOF'
# Loyalty program idea
Rough notes, not ready yet.
EOF

payments.md and drafts/idea.md have no frontmatter; refunds.md already declares a type and title.

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder enrich . --dry-run
enrich .
(dry run — no files written)
3 file(s): 3 would enrich, 0 unchanged, 0 skipped
would enrich drafts/idea.md (add: generated, title, type)
would enrich payments.md (add: generated, title, type)
would enrich refunds.md (add: generated)

refunds.md only needs generated: its authored type and title are already present and will be left untouched. This is the additive rule: binder fills in missing fields and never overwrites a value you authored.

Step 3: enrich with status and verification

Section titled “Step 3: enrich with status and verification”

Stamp lifecycle and verification metadata as you enrich. --status-map assigns status by source directory (longest directory prefix wins; default= is the fallback), and --verified-by appends a verified actor stamp. Both are set only where the field is absent:

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder enrich . \
--status-map "drafts=draft,default=stable" \
--verified-by "human:alice"
enrich .
3 file(s): 3 enriched, 0 unchanged, 0 skipped
enriched drafts/idea.md (added: generated, status, title, type, verified)
enriched payments.md (added: generated, status, title, type, verified)
enriched refunds.md (added: generated, status, verified)
Trust (verified stamps):
actor: human:alice (source: flag)
stamped: 3 file(s)
- drafts/idea.md
- payments.md
- refunds.md

The Trust (verified stamps): block is the trust disclosure. Any run that writes a verified stamp (or declines to write one) names the actor it used and where that actor came from; flag here, because you passed --verified-by on the command line. It is the receipt for the one thing binder does that asserts something on your behalf, so it is printed whether you asked for prose or --json, and whether or not anything was written.

Look at the drafts file: it received status: draft from the drafts= prefix, title derived from its # H1, a Note type, and the verified and generated stamps:

Terminal window
cat drafts/idea.md
---
type: Note
title: Loyalty program idea
status: draft
verified:
- at: "2023-11-14T22:13:20Z"
by: human:alice
generated:
at: "2023-11-14T22:13:20Z"
by: binder/0.3.0
---
# Loyalty program idea
Rough notes, not ready yet.

payments.md and refunds.md got status: stable from the default= fallback. The generated.at and verified.at timestamps come from SOURCE_DATE_EPOCH, so the run is reproducible.

Step 4: confirm a repeat run changes nothing

Section titled “Step 4: confirm a repeat run changes nothing”

A second identical run finds every key present and writes nothing, so there is no git churn:

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder enrich . \
--status-map "drafts=draft,default=stable" \
--verified-by "human:alice"
enrich .
3 file(s): 0 enriched, 3 unchanged, 0 skipped
Trust (verified stamps):
actor: human:alice (source: flag)
stamped: 0 file(s)

The disclosure prints anyway, and stamped: 0 file(s) is exactly the point: you asked for a stamp and binder is telling you none was needed. Stamps de-duplicate by (by, at), and the clock is pinned, so the stamp your first run wrote is the stamp this run would have written.

Step 5: stop retyping the actor — binder config

Section titled “Step 5: stop retyping the actor — binder config”

You have now typed --verified-by "human:alice" twice. binder config set persists a default, but where you persist it decides whether binder will stamp from it at all. Start with the default location:

Terminal window
binder config set verified_by "human:alice"
Set verified_by = "human:alice" in .binder.yaml

There is no --local flag. Local is the default: config set writes ./.binder.yaml in the current directory, which is the repository-scoped file you commit alongside the corpus. -g/--global is the opt-out, writing $XDG_CONFIG_HOME/binder/config.yaml (~/.config/binder/config.yaml by default) instead. binder config set --local … fails with unknown flag: --local and exit 2.

binder config with no arguments lists every resolved value with the source it came from. That is the fastest way to explain a surprising run, because the precedence is flag > env > config file > built-in default:

Terminal window
binder config
binder config
config file: .binder.yaml
default_type: "Note" (source: default)
verified_by: "human:alice" (source: file)
gemini_model: "gemini-3.5-flash-lite" (source: default)
gemini_location: "global" (source: default)
gemini_project: "" (source: default)
gemini_backend: "auto" (source: default)

That ordering is which layer supplies a value. For verified_by there is a second question underneath it (which layers may act on one), and the next few commands are that distinction: verified_by is resolved here from the file, and is about to be refused as stamping authority.

Now add a new document and enrich without passing --verified-by at all:

Terminal window
cat > pricing.md <<'EOF'
# Pricing
How we price. See [payments](payments.md).
EOF
SOURCE_DATE_EPOCH=1700000000 binder enrich .
enrich .
4 file(s): 1 enriched, 3 unchanged, 0 skipped
enriched pricing.md (added: generated, title, type)
Trust (verified stamps):
note: ignored repo-local .binder.yaml verified_by "human:alice": a repo-local config does not authorize stamping (pass --verified-by to stamp)

pricing.md got its type, title and generated. It got no verified stamp. Slow down here, because this is the shape of the whole trust model: a .binder.yaml lives inside a repository, so it travels. Clone somebody’s corpus and their config file comes with it. A file that arrived in a clone you did not write cannot evidence a decision you made, so binder will not attest documents in your name on its say-so. It does not ignore the value silently either: the disclosure names it, and names what would honour it.

The place that does count is your own home directory, because nothing but you puts a file there:

Terminal window
binder config unset verified_by
binder config set --global verified_by "human:alice"

The first prints Unset verified_by in .binder.yaml (reverted to default) and, with no keys left in it, deletes .binder.yaml altogether. The second writes $XDG_CONFIG_HOME/binder/config.yaml (~/.config/binder/config.yaml by default) and prints the absolute path it wrote. Now run the identical enrich again:

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder enrich .
enrich .
4 file(s): 1 enriched, 3 unchanged, 0 skipped
enriched pricing.md (added: verified)
Trust (verified stamps):
actor: human:alice (source: config)
stamped: 1 file(s)
- pricing.md

Same command, same corpus, different answer — and source: config is how you tell a stamp you asked for on the command line from one your own default supplied. This does not weaken never-fabricate-trust: binder stamped verified only because you recorded that actor as your default. With no flag and no global verified_by, nothing is written.

One more thing worth knowing before you commit a .binder.yaml: binder loads exactly one config file, and ./.binder.yaml wins the search. A repo-local file therefore hides your global one entirely, which is why the unset above was needed before the global default could take effect.

config get prints one resolved value (handy in scripts):

Terminal window
binder config get verified_by
human:alice

config unset reverts a key to its default, and it needs the same -g you used to set it: the value now lives in the global file, so a bare binder config unset verified_by would answer Key verified_by is not set in .binder.yaml and change nothing. Leave the default in place for the next step; Step 6 ends by removing it.

The other keys are default_type, gemini_model, gemini_location, gemini_project, and gemini_backend; dotted spellings like gemini.project are accepted and written back as snake_case. An unknown key is a usage error (exit 2).

Your global default now stamps documents as you enrich them. What happens to a document that somebody else has already attested? Try it in a scratch directory so the corpus you built above stays as it is:

Terminal window
mkdir -p /tmp/kb-shared && cd /tmp/kb-shared
cat > handbook.md <<'EOF'
---
type: Playbook
title: Escalation handbook
verified:
- by: human:bob
at: 2026-01-05T00:00:00Z
---
# Escalation handbook
Bob reviewed this.
EOF
SOURCE_DATE_EPOCH=1700000000 binder enrich .
enrich .
1 file(s): 1 enriched, 0 unchanged, 0 skipped
enriched handbook.md (added: generated)
Trust (verified stamps):
actor: human:alice (source: config)
stamped: 0 file(s)
skipped: 1 file(s) (a different identity already attested; pass --verified-by to co-sign)
- handbook.md (already attested by human:bob)

Your default said attest my work; it did not say add my name beside Bob’s. So binder skipped the stamp and told you it did.

A skip is not a rejection. The run exits 0, the file was still enriched (it needed generated), and Bob’s attestation is exactly as he left it. If co-signing is what you actually mean, say it explicitly — the flag is exempt from this rule, because typing it is the decision:

Terminal window
SOURCE_DATE_EPOCH=1700000000 binder enrich . --verified-by "human:alice"
enrich .
1 file(s): 1 enriched, 0 unchanged, 0 skipped
enriched handbook.md (added: verified)
Trust (verified stamps):
actor: human:alice (source: flag)
stamped: 1 file(s)
- handbook.md

handbook.md now carries both attestations, Bob’s first and untouched:

verified:
- by: human:bob
at: 2026-01-05T00:00:00Z
- at: "2023-11-14T22:13:20Z"
by: human:alice

Take the default away again when you are done, and go back to the greenfield corpus for the rest of the tutorial:

Terminal window
binder config unset --global verified_by
cd /tmp/kb

The status vocabulary and the actor convention

Section titled “The status vocabulary and the actor convention”

Two conventions keep a greenfield bundle honest.

Keep status in the OKF §5.4 vocabulary: draft, stable, or deprecated. The --status-map you passed above is already conformant, so it ran silently. Had you written --status-map "drafts=wip,default=stable", enrich would have checked the value at input time — the check is always on — written it unchanged, and told you so:

status: status value "wip" (from --status-map key "drafts") is not one of draft|stable|deprecated (OKF §5.4); wrote it unchanged — pass --canonicalize-status to map it to "draft"

(convert reports the same messages under a Status vocabulary (OKF §5.4): heading; enrich folds them into its per-file list.)

binder still does not reject the value: bare enrich exits 0. But --strict turns it into a gate (exit 1) that fires before anything is written, and --canonicalize-status opts into rewriting the handful of known aliases (activestable, wip/in-progressdraft, archived/legacydeprecated) so the gate has nothing left to catch. Both flags exist on convert as well. See Status vocabulary.

Attest with a real actor. --verified-by requires the actor convention: human:<id>, process:<id>, team:<id>, or <producer>/<version> such as binder/0.3.0. There is no agent: form. An invalid actor is a usage error and exits 2:

Terminal window
binder enrich . --verified-by "agent:bot"
echo "exit=$?"
binder: invalid actor "agent:bot"; valid forms: human:<id>, process:<id>, team:<id>, or <producer>/<version> (e.g. binder/0.3.0)
exit=2

This is the design principle everything else rests on: if binder ever stamped trust you had not asserted, none of the trust tiers it derives could be believed. binder derives a trust tier from the verified signals in the frontmatter (human: verification yields human-reviewed, other verification yields machine-confirmed, none yields unverified); it never stores a credibility score. It stamps an honest generated: binder/<version> for content it produced, and it never auto-stamps verified: a verified stamp is written only from an explicit verifier you supply, never on binder’s own initiative. Steps 5 and 6 are that rule in three parts:

  • No flag, no stamp. A verified stamp needs --verified-by on the run, or a verified_by default in your own global config.
  • A repo-local .binder.yaml does not authorize one — it can arrive inside somebody else’s clone, and binder reports the value rather than acting on it.
  • binder does not co-sign. Against a document a different identity has already attested, a stamp from a default is skipped and disclosed as skipped; only an explicit --verified-by co-signs.

Every one of those decisions is disclosed, in prose and in --json, so a run that stamped nothing is distinguishable from a run that never tried. When an agent drives binder, the same rule binds the agent: do not stamp trust you cannot assert.

One case this part did not exercise: BINDER_VERIFIED_BY in the environment does not authorize a stamp either. It is refused for the same reason the repo-local file is, and refused the same way — the run writes no verified key and discloses the value it declined:

Trust (verified stamps):
note: ignored BINDER_VERIFIED_BY "process:nightly-ingest": an environment default does not authorize stamping (pass --verified-by to stamp)

The user guide has that case in full, and the full rules cover the JSON disclosure fields.

Everything so far used binder. Once binder has produced or enriched an OKF bundle, you can hand that bundle to a second, independent tool for a fresh opinion. okf is a vendor-neutral Go CLI for the Open Knowledge Format — a different implementation of the same OKF v0.2 spec binder targets. It reads a bundle, validates it, and prints structured JSON on stdout, which makes it a good post-enrichment check: two independent tools agreeing your bundle is conformant is worth more than either tool agreeing with itself.

Install it with Go: one static binary, no runtime dependencies. Pin the version so what you see below is what your binary prints: the JSON output in this section was generated with okf v0.3.0, so install exactly that version.

Terminal window
go install github.com/okfcli/okf/cmd/okf@v0.3.0

Point it at the greenfield bundle you just enriched. Because okf validate emits JSON on stdout, pipe it through jq for a summary:

Terminal window
okf validate /tmp/kb | jq '{valid, errors, warnings}'
{
"valid": true,
"errors": 0,
"warnings": 8
}

okf agrees the enriched bundle is conformant. The eight warnings are recommended-field advisories (each of the four concepts is missing description and tags), the same kind of non-blocking finding binder reports; they do not fail the check.

Now point okf at the brownfield bundle from Part 1, the one with the unresolved link:

Terminal window
okf validate /tmp/tut-bundle | jq '{valid, errors, warnings}'
{
"valid": false,
"errors": 1,
"warnings": 5
}

The single error is the broken topics/onboarding -> /topics/deploy.md link that binder flagged back in Part 1. A second, independent implementation reached the same conclusion from the same bundle.

okf also derives trust the way binder does, from the frontmatter alone. okf list reports a trust tier per concept:

Terminal window
okf list /tmp/kb | jq '[.concepts[] | {id, trust_tier}]'
[
{
"id": "drafts/idea",
"trust_tier": "human-reviewed"
},
{
"id": "payments",
"trust_tier": "human-reviewed"
},
{
"id": "pricing",
"trust_tier": "human-reviewed"
},
{
"id": "refunds",
"trust_tier": "human-reviewed"
}
]

Every concept is human-reviewed because you enriched it with --verified-by "human:alice". okf reads the verified stamp binder wrote and derives the same tier binder would — no credibility score, no fabricated trust, the same guarantee restated by a different tool. For the full command set, run okf --help or see the okf README.

The CLI you used above is the deterministic core. Two other surfaces ship today and rest on the same binder.report/v1 payloads; choose by the integration depth you want.

  • CLI. Deterministic at runtime, with no model in the loop by default: every command runs without a network or an API key, the sole exception being the opt-in binder infer --gemini semantic tier. Reach for it for batch ingestion, pipelines, and any gate that must run unattended. It is the foundation the other two build on. (Runtime only: building binder still needs network to fetch its pinned modules from the Go module proxy.)
  • Agent Skill / Plugin (okf-convert). A skill teaches an agent harness you already run (Claude Code, Cursor, Zed) how to drive the CLI for judgment-laden work: reading a dry-run triage and deciding remediate-versus-accept, choosing conversion flags, reading the trust-extraction review. Install it from binder’s self-hosted marketplace: /plugin marketplace add ghchinoy/binder, then /plugin install okf-convert. It assumes the binder binary is on your PATH. See Agent Skill / Plugin.
  • MCP server (binder mcp). Runs binder as a stdio MCP server. It registers seven tools: the additive verbs convert, validate, review, lint and graph, which return the same payloads as binder <cmd> --json, plus the two read-only graph tools list_graphs (schema introspection) and query_graph (traversal). It is a transport. It produces no report of its own, and the surface stays deliberately narrow: source-mutating verbs such as enrich are not exposed, and neither is infer, which is proposal-only and may call out to a model. Wire it into a host with claude mcp add binder -- binder mcp, or let the okf-convert plugin’s bundled .mcp.json register it on install. See MCP server.

The through-line: binder keeps the mechanical, reproducible work in a tool you can audit, and leaves the semantics to you or your agent. That boundary holds whichever surface you reach for.

  • The user guide documents every command, flag, and the full trust vocabulary.
  • CI usage shows a complete convert-validate-review pipeline with --json and the exit-code contract.
  • Relationship extraction covers wikilinks, frontmatter refs, hashtags, and file:// resolution in depth.