name: "tangled-https" description: "Create Tangled repositories and push commits to them entirely over HTTPS, with no SSH — using atproto service-auth JWTs against the knot's XRPC API and git-over-HTTP. Use whenever an agent needs to make, seed, or commit to a Tangled repo from an environment where outbound port 22 is blocked, or when git push fails for that reason." #
Tangled over HTTPS only #
Verified end-to-end 2026-08-03 from a sandbox with outbound port 22 blocked (no SSH to anywhere — knots, tangled.org, or github.com). This repository was created and populated using only the steps below.
The key fact #
knot1.tangled.sh runs knot2 (the Rust knot), not the Go knotserver.
This matters enormously:
- Go
knotserver/git.gohasRejectPush→ 403 "Pushes are only supported over SSH." Reading that file alone gives the wrong answer for knots that have moved on. - knot2 (
knot2/crates/knot-xrpc/src/receive.rs) servesgit-receive-packover HTTP, gated byauthenticate_push, which accepts a service JWT with lxmsh.tangled.repo.pushas eitherBearerorBasic— the Basic path exists so ordinary git HTTPS auth works.
Check GET /xrpc/sh.tangled.knot.version first. knot1 was v1.15.0,
capabilities ["knot-acl"]. A knot still running the Go implementation will
reject HTTP pushes and require SSH.
knot2 also uses different query param names than the Go lexicons in
tangled.org/core/lexicons/ (e.g. describeRepo?repoDid=…, and its
MergeInput has no repo field — passing one yields a misleading
RepoNotFound). When in doubt read knot2/crates/knot-xrpc/src/*.rs rather
than the Go handlers or the shared lexicon JSON.
Auth pattern (all three calls) #
Mint a fresh inter-service JWT per lxm from the acting account's PDS:
GET <pds>/xrpc/com.atproto.server.getServiceAuth
?aud=did:web:<knot hostname>&lxm=<NSID>
Authorization: Bearer <accessJwt from com.atproto.server.createSession>
Audience is always did:web:<knot hostname>. An app password is enough to
open the session; nothing here needs the account's signing key directly.
Recipe #
- Create the repo.
POST https://<knot>/xrpc/sh.tangled.repo.createwith lxmsh.tangled.repo.create, body{rkey, name, defaultBranch}.rkeyis a TID you generate. Optionalsource= a clone URL the knot fetches server-side (server-side fork/import, no local bandwidth). Returns{repoDid, key}— the knot mints adid:plcfor the repo itself. - Publish the record.
com.atproto.repo.putRecordinto the owner's repo, collectionsh.tangled.repo, same rkey, record{name, knot, repoDid, description, createdAt},validate: false. The appview ingests this via jetstream; without it the repo has no page. - Push. Get a token with lxm
sh.tangled.repo.push, then:
Success looks likegit -c http.extraHeader="Authorization: Bearer $TOK" \ push https://<knot>/<repoDid> mainremote: <knot> received 1 ref.
Permission note: repo:create requires server:member on that knot
(rbac/rbac.go).
Alternative to pushing #
sh.tangled.repo.merge applies a raw patch string to a branch server-side
({did, name, patch, branch, authorName, authorEmail, commitMessage, commitBody}; patchutil.IsFormatPatch means git format-patch output is
fine). Useful when there's no local clone. Push is simpler when there is one.
Also available with zero repo ownership: tg pr create bundles a patchset into
an sh.tangled.repo.pull record in the contributor's own repo — the fork-free
contribution path, which likewise needs no push.
Sanity checks #
git ls-remote https://<knot>/<repoDid>(anonymous; HTTPS read always works)GET /xrpc/sh.tangled.repo.describeRepo?repoDid=…→ owner + rkey- The appview page at
https://tangled.org/<handle>/<name>