Tethered Docs. Real Code. Zero Hallucinations.
Website · Guide · Spec · Discussions
Docs that point at real code, and fail CI when they stop. Built for
AGENTS.mdand the other docs coding agents read as instructions.
Your AGENTS.md says "follow the pattern in fetchData." Three sprints
later someone renames fetchData and nothing fails. The doc still reads
fine, and everyone who follows the pointer spends time hunting for code
that is gone.
Broken URLs return 404. Broken code references do not. A link checker verifies the file, and nothing verifies the symbol inside it.
<!-- The file exists, so every link checker passes this, -->
<!-- but fetchData was renamed two weeks ago. -->
Follow the fetch pattern in [fetchData](src/api/client.ts#L42).The #L42 fragment is worse than a bare file link, because after the file
shifts it points at whatever code moved into line 42.
symtether fixes this. The reference names the symbol, and the tool checks it against the code itself.
Follow the fetch pattern in [ApiClient.fetchData](src/api/client.ts#sym:ApiClient.fetchData).This is still a plain markdown link, so it renders and clicks on GitHub.
Now symtether check can resolve it against the AST and fail CI when
the symbol moves or disappears, and symtether fix repairs the common
cases automatically. symtether is a one-page open spec for the #sym:
reference syntax, plus the reference toolkit that enforces it.
$ npx symtether check
docs/agents/fetching.md
✗ src/api/client.ts#sym:ApiClient.fetchData BROKEN (line 14)
file OK; symbol not found
closest in file: ApiClient.fetchAgentData (method)
→ symtether fix docs/agents/fetching.md
$ npx symtether fix --write
$ npx symtether check && echo green
greenA rename that used to pass unnoticed now fails CI, and the fix is one
command away. There is no config file, and the markdown links are the
only source of truth. If you turn on staleness detection, symtether
also writes an optional symtether.sum file. That file holds derived
checksums, and you can delete and regenerate it at any time. It is
never a source of truth. Exclusions come from your .gitignore, and
node_modules is always skipped
(GLOB_OPTIONS).
Coding agents read AGENTS.md, CLAUDE.md, and skill files as
instructions. The most useful instruction in these files is a pointer to
existing code, e.g., "follow the pattern in fetchData". An agent pointed
at a deleted symbol searches for it, guesses, and then imitates whatever it
finds instead. Agents also cause the breakage, because every refactor an
agent lands can break the pointers the next session depends on.
The #sym: convention helps even before the tool is installed. An agent
reading src/client.ts#sym:ApiClient.fetchData has the file path and an
exact string to grep. With a bare file link the agent has to read hundreds
of lines hoping to spot the pattern, and with a line link the agent reads
the wrong lines after the file shifts. symtether makes the convention
enforceable. check in CI catches what agents and humans break, fix
repairs it, and init installs a short managed block that tells agents
to keep refs working themselves.
npx symtether check [globs…] # validate refs; exit 1 on broken
npx symtether check --json # stable machine output (schemas/check-output.schema.json)
npx symtether fix [globs…] # propose repairs (dry-run)
npx symtether fix --write # apply them
npx symtether fix --canonicalize # also rewrite compat-form refs to #sym:
npx symtether init # install the agent block into AGENTS.md
npx symtether init --ci # + a GitHub Actions workflow
npx symtether update [targets…] # stamp review: (re)generate symtether.sum
npx symtether update --check # CI: fail if symtether.sum is out of date
npx symtether check --strict # also fail when stamped targets changed
npx symtether check --strict=warn # …or just report stalenessExit codes:
0. All refs pass.1. Broken refs, stale refs under--strict, or an outdated sum file underupdate --check.2. Usage or runtime error.
You can also use symtether as a library. The CLI calls the same four functions the library exports: check, fix, init, and update.
import { check } from 'symtether';
const report = await check({ cwd: '/path/to/repo' });You can run symtether in CI without copying a workflow file. Reference
this repo as a reusable GitHub Action. It runs the published CLI through
npx, so there is nothing to build or maintain, and the tool version
matches the release you reference.
# .github/workflows/symtether.yml in YOUR repo
name: symtether
on: [push, pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: jutaz/symtether@v1 # or @v1.2.3 to pin a release
with:
command: check
strict: true # also fail on stale stamped refs
exclude: |
test/fixtures/**The action builds the CLI flags from these inputs:
command. The subcommand to run:check(default),fix,init, orupdate.strict. Staleness gate forcheck. Use''(off, default),trueto fail on stale refs (--strict), orwarnto report only (--strict=warn).globs. Files to check, one per line. When empty, symtether finds files from the repo root and honors.gitignore(GLOB_OPTIONS).exclude. Globs to exclude, one per line (e.g.test/fixtures/**). Each line becomes a--excludeflag.json. Set totrueto emit stable JSON (schemas/check-output.schema.json) for another action to read. The default isfalse.working-directory. The directory to run in, relative to the repo root. The default is the repo root.args. Raw extra flags for anything not covered above (e.g.--write).
Version pinning. The action pins the tool version to the git ref you
reference. It reads its own package.json at that ref and runs
npx symtether@<that version>. So @v1.2.3 runs symtether 1.2.3, and
@v1 runs whatever the moving v1 tag points at. If your repo depends
on symtether itself, npx prefers your local copy, and the pinned
version no longer applies.
If you would rather own the workflow file, run npx symtether init --ci.
It writes .github/workflows/symtether.yml into your repo. That file uses
the same action.
Full spec: SPEC.md. The short version:
[text](path/to/file.ts#sym:Class.method)
[text](path/to/file.ts#sym:fn:parseConfig) ← optional kind: fn | class | type | const
[text](/src/from-repo-root.ts#sym:Widget)The dotpath is a suffix match against the definition's nesting chain, so the natural short form works across languages. Exactly one match passes. Zero matches is broken. Two or more matches is ambiguous, and the error asks you to qualify the ref.
Every ref resolves at one of three tiers, and the tier is part of the
output. Anything that could not be fully verified shows up as lexical or
file-only rather than passing quietly (see
Resolver):
| Tier | When | Meaning |
|---|---|---|
ast |
TypeScript, TSX, JavaScript, Python, Go, Rust, Java, Kotlin, Swift, Ruby, PHP, C, C++, C#, Scala, Elixir, Lua, Bash | Symbol verified against the parsed AST |
lexical |
any other text file | Word-boundary match for the symbol name |
file-only |
fragment not checkable | Path existence only, reported as a warning |
Adding a tier-1 language is mostly a grammar import plus fixtures (see the registry in loadLanguage). Open an issue if yours is missing.
By default check fails only on broken refs. If you also want to find
out when the implementation behind a ref changes, use the sum file.
The flow is:
npx symtether updatewritessymtether.sum, which holds a normalized content hash (hashDefinition) for every resolvable ref. Reformatting does not change a hash. Renaming does not either, because the hash excludes the symbol's own name. That is what letsfixdetect renames by content.npx symtether check --strictmarks refs stale when their target's hash no longer matches, and lists every doc referencing the changed target.--strict=warnreports without failing.- Re-read the prose, fix it or confirm it, then re-stamp with
npx symtether update <target>.
The sum file holds derived checksums, not decisions. go.sum uses the
same idea (sumfile.ts). If you
delete the sum file, check passes or fails exactly as before, and
update writes the sum file back. A repo that never runs update
gives up two things and only two things. It gives up staleness
detection, and it gives up the ability of fix to detect renames by
content.
There is one trade-off. Entries are stored per target, and sumKey ignores the written kind, so re-stamping a target clears staleness for every doc that references it. That is why stale output lists every referencing doc for review.
- symtether guarantees the pointer resolves. It does not guarantee the
prose around the pointer is still true.
--strictflags refs whose implementation changed, but you or your agents judge whether the prose still holds. - Resolution checks that a definition exists in the linked file. There is no import following or re-export chasing, so a symbol re-exported but not defined in the linked file counts as broken. Link to the defining file instead.
Other tools work on the same problem in different ways.
| Tool | Mechanism | Difference |
|---|---|---|
| Fiberplane Drift | Stateful binder. drift link writes bindings and AST fingerprints into drift.lock |
The lockfile is the source of truth, and every intentional change needs re-stamping |
| docref | Early exploration of markdown path#Symbol links plus tree-sitter and .docref.lock |
Lockfile-first, cargo-only, and never released. It prototyped the direction and deserves the credit |
| Roam-Code | A codebase intelligence platform with a SQLite symbol index | Requires indexing, and doc checking is one feature among hundreds |
| AgentLinter | Lints AGENTS.md structure, token budget, and file-level references | Overlaps with symtether's file-only tier, and symtether adds AST symbol resolution. A repo can run both linters |
| lychee, markdown-link-check | HTTP and filesystem link checkers | They verify that URLs return 200 and that files exist. Neither reads the code, so #L42 and #sym: fragments pass as long as the file does. They are complementary to symtether |
symtether differs in three ways:
- ordinary clickable markdown links are the only source of truth,
- the optional
symtether.sumfile holds derived checksums that you can delete and regenerate at any time, and no other index is maintained, - checking fails only when a ref is broken, and staleness detection stays opt-in.
You get the same guarantees as Drift without the lockfile step.
MIT © Justas Brazauskas
