knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
A repository is a handful of records in your own repo: no git server, no knot, nothing installed on the PDS. It moves with your account, rides in your CAR backups, and anyone can clone it from your PDS directly. Private repositories live in a permissioned space; collaborators push through a gateway you delegate to; forks, pulls, issues and reviews are records in each participant's own account. Agents work through MCP, people read through a small web interface, and both are served by one binary that is cheap to run.
cargo install --path crates/knotless # git-remote-knotless + knotless
knotless login alice.bsky.social # OAuth, in your browser
git push knotless://alice.bsky.social/my-project main
git clone knotless://alice.bsky.social/my-project
Why it is fast
Every operation is planned around round trips to the PDS, because on a real network those are what an agent waits for.
| Operation | Round trips (warm) |
|---|---|
git push | 2: read the refs and newest packs (three reads in parallel), then one applyWrites |
git fetch | 1 for pushes under 192 KiB, whose packs arrive inside the listing; plus parallel blob reads for larger ones |
knotless commit (no checkout) | 2, plus 3 per lost race |
| Ref compare-and-swap | 0 extra: a claim record in the same applyWrites |
The identity, session and repository key are cached on disk after the first call, and every request in a process shares one connection.
Measured with cargo run --release --example bench -- --rtt 50: the npm
reference PDS (@atproto/pds 0.5.43, SQLite) on the same machine, behind a
proxy adding 50 ms of round-trip time, on an Apple-silicon laptop.
| p50 | p95 | |
|---|---|---|
git push, one small commit | 223 ms | 276 ms |
git fetch, one new commit | 177 ms | 211 ms |
knotless commit, no checkout | 366 ms | 596 ms |
git clone, 21 packs | 240 ms | 253 ms |
git clone, compacted | 158 ms | 172 ms |
git clone --depth 1 via knotless-serve | 208 ms | 299 ms |
One file via the knotless-serve API, warm | 45 ms | 57 ms |
| 16 agents pushing 16 branches at once | 443 ms total | |
| 16 agents committing to one branch at once | 10.9 s total, 97 pushes |
A branch is one compare-and-swap, so direct writers to one branch land one per round, and the reference PDS serializes an account's writes. Give each agent its own branch and merge, or send commits through a gateway, which queues them per branch and lands each queue as one push.
What the web pages show is read from a mirror's objects in process, in
about a millisecond warm; a highlighted 40 KB source file takes a few more.
The pages themselves take a few milliseconds more to draw. A page's social
card is drawn in about 5 ms the first time, about 30 KB, and served from
memory after that (just bench-cards).
How it works
Four record types hold a repository (schemas in
lexicons/):
dev.knotless.repo, keyed by name: the repository'skey(a TID), description, default branch, collaborators, the gateway they push through, and the repository it was forked from.dev.knotless.ref, keyed<key>.<hash of ref name>: one per ref, with its target and a generation counter.dev.knotless.claim, keyed<key>.<hash>.<generation>: the lock. A push creates the claim for the generation it read, in the sameapplyWritesas the ref update. Two pushes from the same generation both create the same key, and the PDS accepts only the first, so every ref has its own compare-and-swap, with noswapCommitcontention against the rest of the account. The claims double as the reflog.dev.knotless.pack, keyed<key>.<tid>: an immutable, self-contained git pack, inline as bytes when small and split over blobs when not.
Because the keys share a prefix, a listRecords from the cursor <key>/
walks one repository's records newest first and stops at the first key
outside it. A fetch reads the newest page of packs alongside the refs and
downloads only the packs whose tips it lacks.
Compaction folds a repository's packs into one once it holds more than 32, so a clone stays a few reads however long the history. It runs detached after a push, and keeps packs younger than ten minutes so a reader halfway through a walk still finds them.
Four more hold the conversation around it, each in its author's account and
naming the repository by its at:// URI: dev.knotless.pull,
dev.knotless.issue, dev.knotless.comment (a review, when it
carries a verdict and the head it saw) and dev.knotless.state (open,
closed or merged). An item's state is the latest one set by someone allowed
to: its author, the owner, or a collaborator, and only the latter two merge.
docs/design.md has the details: the failure cases, the
limits of a stock PDS, and the prior art.
Private repositories
knotless create knotless://alice.test/secret --private
git push knotless://alice.test/secret main
knotless share knotless://alice.test/secret bob.test # bob may clone
knotless members knotless://alice.test/secret
A private repository is a permissioned space,
at://<did>/space/dev.knotless.space/<name>, holding the same records. Its
members read with a space credential, which needs an OAuth login rather
than an app password, and which is cached until shortly before it expires. Each
member writes their issues and comments into their own repo in the space,
so the conversation stays as private as the code. Spaces are in the
reference PDS's permissioned-data alpha; on a PDS without them, every
repository is public.
A public knotless-serve never shows a private repository. To browse one,
run your own as whoever is logged in; it listens on this machine only, and
your own profile lists your private repositories:
knotless-serve --private # then http://127.0.0.1:2590/alice.test/secret
Collaborating
Only an account can write to its own repo, so collaborators push through a
gateway: a knotless-serve the owner has delegated to.
knotless delegate alice.test did:web:git.example.com # once, as the owner
knotless share knotless://alice.test/proj bob.test --write
# then, as bob, plain git:
git push knotless://alice.test/proj feature
A collaborator's helper signs each request with a service token from their own PDS, and the gateway checks it against the repository's collaborators before writing as the owner. Commits sent to one branch at once are queued and landed together as one push, rebuilt on the new tip if the branch moved underneath.
Anyone can fork and offer changes back:
knotless fork knotless://alice.test/proj
git push knotless://bob.test/proj topic
knotless pull knotless://alice.test/proj main knotless://bob.test/proj topic "Fix the parser" -m "Details."
knotless comment knotless://alice.test/proj <pull at-uri> "Looks right" --approve
knotless merge knotless://alice.test/proj <pull at-uri> # fast-forward, or a merge commit
Pulls and issues on public repositories are listed by an appview
(KNOTLESS_APPVIEW), which indexes them from Jetstream or the firehose; on private ones,
from the members' repos in the space. A review reads the pull from its
author's repo directly, so it never waits for an index.
Moderation is atproto's own. An owner hides what is said about their repository and blocks accounts from it or from all of theirs; anyone reports to the appview's moderation service, whose operator takes accounts and records down, and other appviews can follow its takedowns as labels:
knotless hide knotless://alice.test/proj <issue at-uri>
knotless block troll.test knotless://alice.test/proj
knotless report <issue at-uri> spam -m "the same link in every issue"
KNOTLESS_ADMIN_PASSWORD=… knotless admin reports
KNOTLESS_ADMIN_PASSWORD=… knotless admin takedown troll.test
For agents
knotless-mcp is an MCP server over stdio. Logged in as the account it acts
for, an agent reads and changes repositories with no clone and no checkout.
To connect it:
cargo install --path crates/knotless-mcp
knotless login you.bsky.social # the account the agent acts as
claude mcp add knotless -- knotless-mcp # Claude Code
Other clients, such as Claude Desktop (claude_desktop_config.json) and
Cursor (.cursor/mcp.json), take the same command in their MCP settings:
{ "mcpServers": { "knotless": { "command": "knotless-mcp" } } }
It acts as the account knotless login last stored. To pin another, set
KNOTLESS_IDENTIFIER in the server's env; set KNOTLESS_APPVIEW to a
knotless-serve index for pulls and issues on public repositories.
| Tools | |
|---|---|
list_repos, refs, list_tree, read_file, search, log, diff | read, by handle/name |
commit | change files on a branch, optionally from a base and through the gateway |
create_branch, delete_branch, rename_branch | make, remove or rename a branch; a rename takes the open pulls on it along |
create_repo, rename_repo, share, members, delegate, transfer_repo, accept_transfer | manage repositories and access; delegate hands a gateway (the appview, by default) your writes, so collaborators can push |
fork, open_pull, open_issue, list_items, show_item, pull_diff, comment, set_state, merge, retarget_pull | collaborate; show_item on a pull says what it brings in and whether it merges cleanly |
hide_item, block_account, report | moderate your repositories, report to the appview |
takedown, moderation_status, list_reports | as the appview's operator (KNOTLESS_ADMIN_PASSWORD) |
The same is on the command line:
# Change files on a branch without a checkout. If the branch moved, the same
# changes are replayed onto the new tip and pushed again.
knotless commit knotless://alice.test/proj -b main -m "fix typo" \
--put README.md ./README.md --put-exec run.sh - --delete old.txt --json < run.sh
knotless refs knotless://alice.test/proj # one round trip
The server
knotless-serve is one binary with four jobs, each optional:
knotless-serve --listen 0.0.0.0:2590 \
--jetstream wss://jetstream.us-east.bsky.network \ # keep mirrors warm; feed the index
--index /var/lib/knotless/index.db \ # appview: repositories, pulls, issues
--backfill https://bsky.network \ # index everything already there
--public-url https://git.example.com \ # be a gateway, as did:web:git.example.com
--labeler did:plc:… # honour a labeler's takedowns
--web http://127.0.0.1:2591 # the web pages, from web/
Events come from a Jetstream v2 host or, with --firehose wss://…, from a
relay or PDS directly. Jetstream is the cheaper of the two: it sends only
dev.knotless.* commits, plus identity and account changes, as small JSON
frames carrying each record, so the server neither downloads and decodes
every commit on the network nor fetches each record from its author's PDS.
Its frames are unsigned, so the index trusts the Jetstream operator for
what a record says; mirrors still sync from the PDS, and --backfill
rebuilds the index.
-
A read accelerator, a cache and never a source of truth. It speaks git smart HTTP (protocol v0 and v2, partial and shallow clones), read only, and an object API:
git clone --filter=blob:none http://localhost:2590/alice.test/proj curl 'localhost:2590/api/alice.test/proj/blob?rev=main&path=src/lib.rs' curl 'localhost:2590/api/alice.test/proj/tree?rev=main&path=src' curl 'localhost:2590/api/alice.test/proj/log?rev=main&path=src&limit=20' curl 'localhost:2590/api/alice.test/proj/diff?base=main&head=feature' curl 'localhost:2590/api/alice.test/proj/object/<sha>' # immutable, cacheable -
A web interface, read only: what knotless is and how to connect, repositories and search, each account's profile and public repositories, trees, files, history, commits, pulls and issues, laid out for a phone as well as a desktop. A repository's page is at its clone URL. Commits show each file's diff with old and new line numbers, syntax highlighting, and the changed part of each edited line. Code is sent as plain text and highlighted with tree-sitter ranges applied by the CSS Custom Highlight API; links load on hover; pages move with view transitions; anything named by a commit hash is cached as immutable. The front page, repositories, pulls and issues unfurl into a social card, drawn in process. Writing is for git, the CLI and agents.
The pages are a SvelteKit app in
web/, run with Node, that--webnames: the server hands it every request it does not answer itself, and it draws each page from the server's JSON under/_data(its TypeScript types are written bycargo test) and, for the front page,dev.knotless.getFeed. Without--web, the server has no pages.just web # build web/build just pages # serve it on :2591 just serve --index index.db --web http://127.0.0.1:2591 just web-dev # or redraw as the source changes -
An appview:
dev.knotless.listRepos,dev.knotless.getForgeanddev.knotless.getFeed, from a SQLite index of what Jetstream or the firehose reports. -
A gateway:
dev.knotless.pushRefs,dev.knotless.createCommitanddev.knotless.putDelegation(the gateway is an OAuth client the owner approves in a browser), for collaborators. -
A moderation service, as an appview with a public URL:
com.atproto.moderation.createReportfor anyone,com.atproto.admin.updateSubjectStatusanddev.knotless.listReportsfor its operator (KNOTLESS_ADMIN_PASSWORD), and its takedowns as labels oncom.atproto.label.subscribeLabels.
State is a directory of bare mirrors and one SQLite file, so all four fit on one small VPS.
Commands
knotless login <handle> [--password-stdin] / logout <handle> | store or drop a session |
knotless create <url> [--private] [--description] [--default-branch] | create an empty repository |
knotless ls <handle> | list an account's repositories |
knotless refs <url> | print refs |
knotless commit <url> -b <branch> -m <msg> ... | commit without a checkout |
knotless branch <url> <name> [<from>] / --delete | make or remove a branch without a checkout |
knotless delegate <handle> <gateway did> | let a gateway write for you |
knotless share <url> <handle> [--write] / unshare / members | access |
knotless fork <url> [<name>] | copy a repository into your account |
knotless pull <url> <base> <head url> <branch> <title> [-m <body>] | open a pull |
knotless issue <url> <title> [-m <body>] | open an issue |
knotless list <url> [--pulls | --issues] / show <url> <at-uri> | read them |
knotless comment <url> <at-uri> <text> [--approve | --changes] | comment or review |
knotless close / reopen / merge <url> <at-uri> | change state |
knotless retarget <url> <at-uri> [--base <b>] [--head <b>] | point a pull at other branches |
knotless hide / unhide <url> <at-uri> | as owner, hide a pull, issue or comment |
knotless block / unblock <handle> [<url>] | keep an account out of your repositories, or out of one |
knotless report <handle, url or at-uri> <reason> [-m <text>] | report to the moderation service |
knotless admin takedown <subject> [--undo] / status / reports | as the appview's operator |
knotless rename <url> <name> | rename; the old name redirects |
knotless transfer <url> <handle or url> / --cancel, accept <url> | move to another account |
knotless delete <url> --yes | delete a repository and its history |
knotless compact <url> | fold packs into one |
knotless mirror <url> | sync the cache mirror and print its path |
Environment: KNOTLESS_PASSWORD (an app password, instead of OAuth),
KNOTLESS_IDENTIFIER, KNOTLESS_BROWSER (the command an OAuth login opens
its page with), KNOTLESS_SERVICE
(skip DID resolution), KNOTLESS_PLC_URL, KNOTLESS_HANDLE_RESOLVER,
KNOTLESS_APPVIEW, KNOTLESS_MODERATOR (the appview's),
KNOTLESS_ADMIN_PASSWORD, KNOTLESS_DID_WEB_SCHEME (https),
KNOTLESS_INLINE_MAX (192 KiB), KNOTLESS_CHUNK_SIZE (4 MB),
KNOTLESS_COMPACT (background, inline, off),
KNOTLESS_COMPACT_THRESHOLD (32), KNOTLESS_COMPACT_GRACE_SECS (600),
KNOTLESS_CLAIM_GRACE_SECS (86400),
KNOTLESS_IDENTITY_TTL_SECS (3600), KNOTLESS_TIMEOUT_SECS (60),
KNOTLESS_CONFIG_DIR, KNOTLESS_CACHE_DIR.
Development
Tests run against the reference PDS from npm, started by
harness/pds.mjs, and again against the permissioned-data
alpha build of it for private repositories. Every line and function is
covered across the two runs, and CI fails below that.
just setup # npm install in harness/ and web/, llvm-tools for coverage
just test # every test
just test-spaces # against the alpha: KNOTLESS_DEV_ENV names its dev-env
just cov # both runs side by side, failing under 100% lines or functions
just lint # rustfmt, clippy and svelte-check
just bench 50 # the table above, at 50 ms RTT
just bench-cards # what a social card costs to draw, with the PNGs
While working, run only what a change needs:
just pds # leave a reference PDS up for test binaries to share
just changed # files changed since tests last passed, and their tests
just t-changed # run those, and mark them passed
just t -p knotless-serve --test web # or any cargo test arguments
Each test binary otherwise starts a PDS of its own, which takes most of a short run. On a shared one, tests make accounts as they need them.
The screenshots (harness/shots) take every page in
light and dark, at desktop and phone widths, with Playwright's Chromium.
The pages are drawn by the app in web/ from what knotless-serve answered
for them, recorded in harness/shots/fixtures.json, so a run needs no PDS
or server. The social cards the pages name, drawn by knotless-serve and the
same in any browser, are taken once, from a live world (just shots-live):
just shots # retake the pages whose HTML changed, as the baselines
just shots profile --project=desktop-light # or only some
just shots-check # take every page, failing on any pixel that differs
The recording comes from a world: a reference PDS filled with the same repositories, pulls and issues each time, with fixed commit dates and record times. Record again after a change to what the server answers:
just shots-up # seed once, and keep it up for the runs below
just shots-record # record each page's answers from it
just shots-live # every page, through knotless-serve over the world
Each baseline keeps a digest of the HTML it was taken from, and a page
whose HTML still matches is skipped as unchanged before a browser opens
it. Without shots-up, a recording or live run seeds a world of its own.
Baselines are kept per platform, under harness/shots/baseline/<platform>;
DIDs, which the PLC directory makes new for each world, are masked.
Scenarios (harness/scenarios) put agents to a day's
git work through the MCP server. Each track is a few steps by different
people, such as a contributor's fix going through review. Each step is a
fresh Claude session that has only the knotless tools, logged in as that
person, on a fresh PDS and appview. Each agent reports what tripped it up,
and the run collects those reports with every failed call:
just scenarios # every track; uses the `claude` CLI and costs model usage
just scenarios contribution # or only some
Transcripts and report.md land in target/scenarios/<time>/.
SCENARIO_MODEL picks the model.
Not yet
- Verifying refs against signed repository commits.
- The
dev.knotlessNSIDs assume control ofknotless.dev.