···33author: The Tangled Contributors
44date: 21 Sun, Dec 2025
55abstract: |
66- Tangled is a decentralized code hosting and collaboration
77- platform. Every component of Tangled is open-source and
88- self-hostable. [tangled.org](https://tangled.org) also
99- provides hosting and CI services that are free to use.
66+ Tangled is a decentralized code hosting and collaboration
77+ platform. Every component of Tangled is open-source and
88+ self-hostable. [tangled.org](https://tangled.org) also
99+ provides hosting and CI services that are free to use.
10101111- There are several models for decentralized code
1212- collaboration platforms, ranging from ActivityPub’s
1313- (Forgejo) federated model, to Radicle’s entirely P2P model.
1414- Our approach attempts to be the best of both worlds by
1515- adopting the AT Protocol—a protocol for building decentralized
1616- social applications with a central identity
1111+ There are several models for decentralized code
1212+ collaboration platforms, ranging from ActivityPub’s
1313+ (Forgejo) federated model, to Radicle’s entirely P2P model.
1414+ Our approach attempts to be the best of both worlds by
1515+ adopting the AT Protocol—a protocol for building decentralized
1616+ social applications with a central identity
17171818- Our approach to this is the idea of “knots”. Knots are
1919- lightweight, headless servers that enable users to host Git
2020- repositories with ease. Knots are designed for either single
2121- or multi-tenant use which is perfect for self-hosting on a
2222- Raspberry Pi at home, or larger “community” servers. By
2323- default, Tangled provides managed knots where you can host
2424- your repositories for free.
1818+ Our approach to this is the idea of “knots”. Knots are
1919+ lightweight, headless servers that enable users to host Git
2020+ repositories with ease. Knots are designed for either single
2121+ or multi-tenant use which is perfect for self-hosting on a
2222+ Raspberry Pi at home, or larger “community” servers. By
2323+ default, Tangled provides managed knots where you can host
2424+ your repositories for free.
25252626- The appview at tangled.org acts as a consolidated "view"
2727- into the whole network, allowing users to access, clone and
2828- contribute to repositories hosted across different knots
2929- seamlessly.
2626+ The appview at tangled.org acts as a consolidated "view"
2727+ into the whole network, allowing users to access, clone and
2828+ contribute to repositories hosted across different knots
2929+ seamlessly.
3030---
31313232# Quick start guide
···131131cd my-project
132132133133git init
134134-echo "# My Project" > README.md
134134+echo "# My Project" > README.md
135135```
136136137137Add some content and push!
···278278git push tangled main
279279```
280280281281+# Webhooks
282282+283283+Webhooks allow you to receive HTTP POST notifications when events occur in your repositories. This enables you to integrate Tangled with external services, trigger CI/CD pipelines, send notifications, or automate workflows.
284284+285285+## Overview
286286+287287+Webhooks send HTTP POST requests to URLs you configure whenever specific events happen. Currently, Tangled supports push events, with more event types coming soon.
288288+289289+## Configuring webhooks
290290+291291+To set up a webhook for your repository:
292292+293293+1. Navigate to your repository settings
294294+2. Click the "hooks" tab
295295+3. Click "add webhook"
296296+4. Configure your webhook:
297297+ - **Payload URL**: The endpoint that will receive the webhook POST requests
298298+ - **Secret**: An optional secret key for verifying webhook authenticity (auto-generated if left blank)
299299+ - **Events**: Select which events trigger the webhook (currently only push events)
300300+ - **Active**: Toggle whether the webhook is enabled
301301+302302+## Webhook payload
303303+304304+### Push
305305+306306+When a push event occurs, Tangled sends a POST request with a JSON payload of the format:
307307+308308+```json
309309+{
310310+ "after": "7b320e5cbee2734071e4310c1d9ae401d8f6cab5",
311311+ "before": "c04ddf64eddc90e4e2a9846ba3b43e67a0e2865e",
312312+ "pusher": {
313313+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
314314+ },
315315+ "ref": "refs/heads/main",
316316+ "repository": {
317317+ "clone_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
318318+ "created_at": "2025-09-15T08:57:23Z",
319319+ "description": "an example repository",
320320+ "fork": false,
321321+ "full_name": "did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
322322+ "html_url": "https://tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
323323+ "name": "some-repo",
324324+ "open_issues_count": 5,
325325+ "owner": {
326326+ "did": "did:plc:hwevmowznbiukdf6uk5dwrrq"
327327+ },
328328+ "ssh_url": "ssh://git@tangled.org/did:plc:hwevmowznbiukdf6uk5dwrrq/some-repo",
329329+ "stars_count": 1,
330330+ "updated_at": "2025-09-15T08:57:23Z"
331331+ }
332332+}
333333+```
334334+335335+## HTTP headers
336336+337337+Each webhook request includes the following headers:
338338+339339+- `Content-Type: application/json`
340340+- `User-Agent: Tangled-Hook/<short-sha>` — User agent with short SHA of the commit
341341+- `X-Tangled-Event: push` — The event type
342342+- `X-Tangled-Hook-ID: <webhook-id>` — The webhook ID
343343+- `X-Tangled-Delivery: <uuid>` — Unique delivery ID
344344+- `X-Tangled-Signature-256: sha256=<hmac>` — HMAC-SHA256 signature (if secret configured)
345345+346346+## Verifying webhook signatures
347347+348348+If you configured a secret, you should verify the webhook signature to ensure requests are authentic. For example, in Go:
349349+350350+```go
351351+package main
352352+353353+import (
354354+ "crypto/hmac"
355355+ "crypto/sha256"
356356+ "encoding/hex"
357357+ "io"
358358+ "net/http"
359359+ "strings"
360360+)
361361+362362+func verifySignature(payload []byte, signatureHeader, secret string) bool {
363363+ // Remove 'sha256=' prefix from signature header
364364+ signature := strings.TrimPrefix(signatureHeader, "sha256=")
365365+366366+ // Compute expected signature
367367+ mac := hmac.New(sha256.New, []byte(secret))
368368+ mac.Write(payload)
369369+ expected := hex.EncodeToString(mac.Sum(nil))
370370+371371+ // Use constant-time comparison to prevent timing attacks
372372+ return hmac.Equal([]byte(signature), []byte(expected))
373373+}
374374+375375+func webhookHandler(w http.ResponseWriter, r *http.Request) {
376376+ // Read the request body
377377+ payload, err := io.ReadAll(r.Body)
378378+ if err != nil {
379379+ http.Error(w, "Bad request", http.StatusBadRequest)
380380+ return
381381+ }
382382+383383+ // Get signature from header
384384+ signatureHeader := r.Header.Get("X-Tangled-Signature-256")
385385+386386+ // Verify signature
387387+ if signatureHeader != "" && verifySignature(payload, signatureHeader, yourSecret) {
388388+ // Webhook is authentic, process it
389389+ processWebhook(payload)
390390+ w.WriteHeader(http.StatusOK)
391391+ } else {
392392+ http.Error(w, "Invalid signature", http.StatusUnauthorized)
393393+ }
394394+}
395395+```
396396+397397+## Delivery retries
398398+399399+Webhooks are automatically retried on failure:
400400+401401+- **3 total attempts** (1 initial + 2 retries)
402402+- **Exponential backoff** starting at 1 second, max 10 seconds
403403+- **Retried on**:
404404+ - Network errors
405405+ - HTTP 5xx server errors
406406+- **Not retried on**:
407407+ - HTTP 4xx client errors (bad request, unauthorized, etc.)
408408+409409+### Timeouts
410410+411411+Webhook requests timeout after 30 seconds. If your endpoint needs more time:
412412+413413+1. Respond with 200 OK immediately
414414+2. Process the webhook asynchronously in the background
415415+416416+## Example integrations
417417+418418+### Discord notifications
419419+420420+```javascript
421421+app.post("/webhook", (req, res) => {
422422+ const payload = req.body;
423423+424424+ fetch("https://discord.com/api/webhooks/...", {
425425+ method: "POST",
426426+ headers: { "Content-Type": "application/json" },
427427+ body: JSON.stringify({
428428+ content: `New push to ${payload.repository.full_name}`,
429429+ embeds: [
430430+ {
431431+ title: `${payload.pusher.login} pushed to ${payload.ref}`,
432432+ url: payload.repository.html_url,
433433+ color: 0x00ff00,
434434+ },
435435+ ],
436436+ }),
437437+ });
438438+439439+ res.status(200).send("OK");
440440+});
441441+```
442442+281443# Knot self-hosting guide
282444283445So you want to run your own knot server? Great! Here are a few prerequisites:
···313475and operation tool. For the purpose of this guide, we're
314476only concerned with these subcommands:
315477316316- * `knot server`: the main knot server process, typically
317317- run as a supervised service
318318- * `knot guard`: handles role-based access control for git
319319- over SSH (you'll never have to run this yourself)
320320- * `knot keys`: fetches SSH keys associated with your knot;
321321- we'll use this to generate the SSH
322322- `AuthorizedKeysCommand`
478478+- `knot server`: the main knot server process, typically
479479+ run as a supervised service
480480+- `knot guard`: handles role-based access control for git
481481+ over SSH (you'll never have to run this yourself)
482482+- `knot keys`: fetches SSH keys associated with your knot;
483483+ we'll use this to generate the SSH
484484+ `AuthorizedKeysCommand`
323485324486```
325487cd core
···432594can move these paths if you'd like to store them in another folder. Be careful
433595when adjusting these paths:
434596435435-* Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
436436-any possible side effects. Remember to restart it once you're done.
437437-* Make backups before moving in case something goes wrong.
438438-* Make sure the `git` user can read and write from the new paths.
597597+- Stop your knot when moving data (e.g. `systemctl stop knotserver`) to prevent
598598+ any possible side effects. Remember to restart it once you're done.
599599+- Make backups before moving in case something goes wrong.
600600+- Make sure the `git` user can read and write from the new paths.
439601440602#### Database
441603···5196812. Check to see that your knot has synced the key by running
520682 `knot keys`
5216833. Check to see if git is supplying the correct private key
522522- when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...`
684684+ when pushing: `GIT_SSH_COMMAND="ssh -v" git push ...`
5236854. Check to see if `sshd` on the knot is rejecting the push
524686 for some reason: `journalctl -xeu ssh` (or `sshd`,
525687 depending on your machine). These logs are unavailable if
···5276895. Check to see if the knot itself is rejecting the push,
528690 depending on your setup, the logs might be in one of the
529691 following paths:
530530- * `/tmp/knotguard.log`
531531- * `/home/git/log`
532532- * `/home/git/guard.log`
692692+ - `/tmp/knotguard.log`
693693+ - `/home/git/log`
694694+ - `/home/git/guard.log`
533695534696# Spindles
535697···84710098481010### Prerequisites
8491011850850-* Go
851851-* Docker (the only supported backend currently)
10121012+- Go
10131013+- Docker (the only supported backend currently)
85210148531015### Configuration
85410168551017Spindle is configured using environment variables. The following environment variables are available:
8561018857857-* `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
858858-* `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
859859-* `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
860860-* `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
861861-* `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
862862-* `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
863863-* `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
864864-* `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
865865-* `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
10191019+- `SPINDLE_SERVER_LISTEN_ADDR`: The address the server listens on (default: `"0.0.0.0:6555"`).
10201020+- `SPINDLE_SERVER_DB_PATH`: The path to the SQLite database file (default: `"spindle.db"`).
10211021+- `SPINDLE_SERVER_HOSTNAME`: The hostname of the server (required).
10221022+- `SPINDLE_SERVER_JETSTREAM_ENDPOINT`: The endpoint of the Jetstream server (default: `"wss://jetstream1.us-west.bsky.network/subscribe"`).
10231023+- `SPINDLE_SERVER_DEV`: A boolean indicating whether the server is running in development mode (default: `false`).
10241024+- `SPINDLE_SERVER_OWNER`: The DID of the owner (required).
10251025+- `SPINDLE_PIPELINES_NIXERY`: The Nixery URL (default: `"nixery.tangled.sh"`).
10261026+- `SPINDLE_PIPELINES_WORKFLOW_TIMEOUT`: The default workflow timeout (default: `"5m"`).
10271027+- `SPINDLE_PIPELINES_LOG_DIR`: The directory to store workflow logs (default: `"/var/log/spindle"`).
86610288671029### Running spindle
8681030869869-1. **Set the environment variables.** For example:
10311031+1. **Set the environment variables.** For example:
87010328711033 ```shell
8721034 export SPINDLE_SERVER_HOSTNAME="your-hostname"
···90010629011063Spindle is a small CI runner service. Here's a high-level overview of how it operates:
9021064903903-* Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
904904-[`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
905905-* When a new repo record comes through (typically when you add a spindle to a
906906-repo from the settings), spindle then resolves the underlying knot and
907907-subscribes to repo events (see:
908908-[`sh.tangled.pipeline`](/lexicons/pipeline.json)).
909909-* The spindle engine then handles execution of the pipeline, with results and
910910-logs beamed on the spindle event stream over WebSocket
10651065+- Listens for [`sh.tangled.spindle.member`](/lexicons/spindle/member.json) and
10661066+ [`sh.tangled.repo`](/lexicons/repo.json) records on the Jetstream.
10671067+- When a new repo record comes through (typically when you add a spindle to a
10681068+ repo from the settings), spindle then resolves the underlying knot and
10691069+ subscribes to repo events (see:
10701070+ [`sh.tangled.pipeline`](/lexicons/pipeline.json)).
10711071+- The spindle engine then handles execution of the pipeline, with results and
10721072+ logs beamed on the spindle event stream over WebSocket
91110739121074### The engine
9131075···13561518<details>
13571519 <summary><strong>macOS users will have to set up a Nix Builder first</strong></summary>
1358152013591359- In order to build Tangled's dev VM on macOS, you will
13601360- first need to set up a Linux Nix builder. The recommended
13611361- way to do so is to run a [`darwin.linux-builder`
13621362- VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
13631363- and to register it in `nix.conf` as a builder for Linux
13641364- with the same architecture as your Mac (`linux-aarch64` if
13651365- you are using Apple Silicon).
15211521+In order to build Tangled's dev VM on macOS, you will
15221522+first need to set up a Linux Nix builder. The recommended
15231523+way to do so is to run a [`darwin.linux-builder`
15241524+VM](https://nixos.org/manual/nixpkgs/unstable/#sec-darwin-builder)
15251525+and to register it in `nix.conf` as a builder for Linux
15261526+with the same architecture as your Mac (`linux-aarch64` if
15271527+you are using Apple Silicon).
1366152813671367- > IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
13681368- > the Tangled repo so that it doesn't conflict with the other VM. For example,
13691369- > you can do
13701370- >
13711371- > ```shell
13721372- > cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
13731373- > ```
13741374- >
13751375- > to store the builder VM in a temporary dir.
13761376- >
13771377- > You should read and follow [all the other intructions][darwin builder vm] to
13781378- > avoid subtle problems.
15291529+> IMPORTANT: You must build `darwin.linux-builder` somewhere other than inside
15301530+> the Tangled repo so that it doesn't conflict with the other VM. For example,
15311531+> you can do
15321532+>
15331533+> ```shell
15341534+> cd $(mktemp -d buildervm.XXXXX) && nix run nixpkgs#darwin.linux-builder
15351535+> ```
15361536+>
15371537+> to store the builder VM in a temporary dir.
15381538+>
15391539+> You should read and follow [all the other intructions][darwin builder vm] to
15401540+> avoid subtle problems.
1379154113801380- Alternatively, you can use any other method to set up a
13811381- Linux machine with Nix installed that you can `sudo ssh`
13821382- into (in other words, root user on your Mac has to be able
13831383- to ssh into the Linux machine without entering a password)
13841384- and that has the same architecture as your Mac. See
13851385- [remote builder
13861386- instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
13871387- for how to register such a builder in `nix.conf`.
15421542+Alternatively, you can use any other method to set up a
15431543+Linux machine with Nix installed that you can `sudo ssh`
15441544+into (in other words, root user on your Mac has to be able
15451545+to ssh into the Linux machine without entering a password)
15461546+and that has the same architecture as your Mac. See
15471547+[remote builder
15481548+instructions](https://nix.dev/manual/nix/2.28/advanced-topics/distributed-builds.html#requirements)
15491549+for how to register such a builder in `nix.conf`.
1388155013891389- > WARNING: If you'd like to use
13901390- > [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
13911391- > [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
13921392- > ssh` works can be tricky. It seems to be [possible with
13931393- > Orbstack](https://github.com/orgs/orbstack/discussions/1669).
15511551+> WARNING: If you'd like to use
15521552+> [`nixos-lima`](https://github.com/nixos-lima/nixos-lima) or
15531553+> [Orbstack](https://orbstack.dev/), note that setting them up so that `sudo
15541554+ssh` works can be tricky. It seems to be [possible with
15551555+> Orbstack](https://github.com/orgs/orbstack/discussions/1669).
1394155613951557</details>
13961558···1463162514641626We follow a commit style similar to the Go project. Please keep commits:
1465162714661466-* **atomic**: each commit should represent one logical change
14671467-* **descriptive**: the commit message should clearly describe what the
14681468-change does and why it's needed
16281628+- **atomic**: each commit should represent one logical change
16291629+- **descriptive**: the commit message should clearly describe what the
16301630+ change does and why it's needed
1469163114701632### Message format
14711633···14911653knotserver/git/service: improve error checking in upload-pack
14921654```
1493165514941494-14951656### General notes
1496165714971658- PRs get merged "as-is" (fast-forward)—like applying a patch-series
14981498-using `git am`. At present, there is no squashing—so please author
14991499-your commits as they would appear on `master`, following the above
15001500-guidelines.
16591659+ using `git am`. At present, there is no squashing—so please author
16601660+ your commits as they would appear on `master`, following the above
16611661+ guidelines.
15011662- If there is a lot of nesting, for example "appview:
15021502-pages/templates/repo/fragments: ...", these can be truncated down to
15031503-just "appview: repo/fragments: ...". If the change affects a lot of
15041504-subdirectories, you may abbreviate to just the top-level names, e.g.
15051505-"appview: ..." or "knotserver: ...".
16631663+ pages/templates/repo/fragments: ...", these can be truncated down to
16641664+ just "appview: repo/fragments: ...". If the change affects a lot of
16651665+ subdirectories, you may abbreviate to just the top-level names, e.g.
16661666+ "appview: ..." or "knotserver: ...".
15061667- Keep commits lowercased with no trailing period.
15071668- Use the imperative mood in the summary line (e.g., "fix bug" not
15081508-"fixed bug" or "fixes bug").
16691669+ "fixed bug" or "fixes bug").
15091670- Try to keep the summary line under 72 characters, but we aren't too
15101510-fussed about this.
16711671+ fussed about this.
15111672- Follow the same formatting for PR titles if filled manually.
15121673- Don't include unrelated changes in the same commit.
15131674- Avoid noisy commit messages like "wip" or "final fix"—rewrite history
15141514-before submitting if necessary.
16751675+ before submitting if necessary.
1515167615161677## Code formatting
15171678···1601176216021763- You may need to ensure that your PDS is timesynced using
16031764 NTP:
16041604- * Enable the `ntpd` service
16051605- * Run `ntpd -qg` to synchronize your clock
17651765+ - Enable the `ntpd` service
17661766+ - Run `ntpd -qg` to synchronize your clock
16061767- You may need to increase the default request timeout:
16071768 `NODE_OPTIONS="--network-family-autoselection-attempt-timeout=500"`
16081769