timeline — your life, versioned. Every post is a commit. Every friend is a submodule. Git is the protocol, and the world is your repository.
tl is a command-line application that turns a plain git repository into a
micro-blogging platform. There is no server to deploy, no database to tune, and
no proprietary format to lock you in — just git, the same tool that has been
versioning human knowledge for decades.
The core idea is beautifully simple:
- Every post is a commit. The commit message is your post content. Attach a document or image and it becomes the commit's content.
- Every friend is a submodule. Following someone clones their timeline
repository into a
FOLLOW/folder inside your own timeline. - Every identity is a hash. Users and folders are identified by the SHA3-256 digest of their repository URL, so you never collide with someone else's username.
- Many repos, one you. One user can hold several repositories, each named
with a friendly alias for its SHA3 id. Commands pick a repository with
--repo <NAME>and default to the first one created. - Aliases are only for you. You can attach friendly aliases to the people you follow — they live in your config file and are never pushed to any timeline.
Everything lives under the tl/ directory, following the XDG Base Directory
Specification:
$XDG_DATA_HOME/tl/
├── cache/ # data that must never be committed
│ └── messages/ # plaintext of sent direct messages
├── keyring/ # keys managed by tl
│ ├── gpg/ # public OpenPGP keys imported from followed timelines
│ ├── pqp/ # public post-quantum keys (keyring-path for `pqp`)
│ └── private/ # private keys imported by YOU (never in a repo)
│ ├── gpg/ # OpenPGP private keyring
│ └── pqp/ # post-quantum private keyring
└── repos/
└── <name>/ # one folder per repository
└── timeline/ # the repository timeline (a git repository)
├── FOLLOW/ # submodules of the users you follow
│ └── <sha3-of-url>/ # one folder per followed user
├── ATTACH/<post>/ # files attached to a post
├── REPOST/<commit>/ # encapsulated original of a repost
│ └── original # the raw shared commit object
├── PUBKEY # your public key (from `tl crypto`)
├── PQPKEY # post-quantum copy of the public key
└── PGPKEY # OpenPGP copy of the public key
$XDG_CONFIG_HOME/tl/
└── tl.toml # repositories, followed users, and aliases
Direct messages live on git branches, never in the mainline commits: your
timeline keeps one refs/heads/<sha3-of-user> branch per followed user (the
messages you sent them), and each followed timeline keeps a
refs/heads/<your-sha3> branch with the messages they addressed to you.
$ tl create --repo main --pull-url https://example.com/alice/timeline.git \
--push-url git@example.com:alice/timeline.git
timeline `main` created at `/home/alice/.local/share/tl/repos/main/timeline`The --repo flag names the repository (default: default). The first one you
create becomes the default, used by every other command unless --repo is
given. Pull and push URLs are required on the command line.
create also sets a repository-local git identity derived from your user id
(the SHA3 of the pull URL), so posting works even if you have no global git
configuration.
$ tl follow https://example.com/bob/timeline.git --alias bob
now following `bob` (https://example.com/bob/timeline.git)A git submodule is added at FOLLOW/<sha3-of-url> and the follow is recorded
as a commit in your timeline, so you can pull updates with a single command.
In tl list the follow is shown as the followed user's alias (or short SHA3
id) together with their URL — not the literal word follow.
$ tl follow https://example.com/carol.git --repo work # follow in another repoFollows are public: every timeline records who it follows in its .gitmodules.
tl discover walks that graph — the users you follow (distance 1), the users
they follow (distance 2), and so on — and lists every repository it finds
with its URL, the last time it was updated, and its distance from you:
$ tl discover
URL LAST UPDATED DIST
https://example.com/bob.git 2026-08-05 18:20:11 1
https://example.com/carol.git 2026-07-30 09:04:42 2The whole tree is traversed by default. Bound it with --depth <N> (1 is a
direct follow, 2 a follow of a follow, …) and cap the output with --limit <N>.
Repositories you already follow are read from their checked-out submodule;
repositories deeper in the tree are fetched with a shallow clone into a
temporary directory that is removed afterwards. Each repository is visited
once, so follow cycles never loop.
Give your repository an asymmetric keypair — the public key is what friends
use to send you encrypted messages. Pick a post-quantum key (ML-DSA / ML-KEM)
with --pqp, or a classic OpenPGP key with --gpg, and generate it:
$ tl crypto --pqp --generate
generated `pqp` keypair for repository `default`; public key published in `PUBKEY`The private key is stored in the tl-owned keyring under the data directory
(keyring/private/pqp or keyring/private/gpg); the public key is committed
as PUBKEY (plus PQPKEY/PGPKEY) at the root of your timeline, so anyone
who follows you can grab it with tl sync and encrypt messages to you. The
commit shows up in tl list with its own kind:
$ tl list
1. 2026-08-05 10:00:00 alice 🔑 a1b2c3d4e5f6a7b8Without --generate the command reports whether your keys exist and what they
contain:
$ tl crypto --pqp
repo: default
format: pqp
program: pqp
private key: present
key id: a1b2c3d4e5f6a7b8
user id: 1267de09c2d2285216ba2ab9621b7bcbbe97924cff29d496ec6ec3e4271e8a9c
created: 2026-08-05T10:00:00+00:00
algorithm: ML-KEM-768 + ML-DSA-65
fingerprint: f1f2f3f4f5f6f7f8
public key: publishedExactly one of --pqp or --gpg is required. --repo selects the repository
as usual.
$ tl post "hello world"
posted: hello worldAttach files to a post and they are committed together with the message:
$ tl post "check this out" --attach photo.jpg --attach notes.txt
posted: check this outAttachments are copied into ATTACH/<post>/ inside your timeline, so the post
carries its own content — a commit with the message and the files.
If the message contains a mention of a user you follow (matched by the regex
from --regex-mention, default @\w+ — the same default as tl list --regex-mention) and the typed value matches one of the followed user's
aliases, the mention is rewritten to the user's sha3 id in the commit subject,
so the stored post always references the canonical id:
$ tl follow https://example.com/bob/timeline.git --alias bob
$ tl post "hi @bob"
posted: hi @1267de09c2d2285216ba2ab9621b7bcbbe97924cff29d496ec6ec3e4271e8a9cUnknown mentions (or mentions of users you do not follow) are left untouched.
The regex can be customized via --regex-mention or the [post] regex_mention
config value.
Reply to any message shown by tl list, using its sequential id:
$ tl reply 3 "thanks, that clears it up"
replied to 7e047ff8...:35880afb...: thanks, that clears it upThe reply is committed as reply <parent-id> <text> — where <parent-id> is
the unique id of the message being answered (sha3(repo_url):<commit>) — and
tl list threads it directly below its parent. Replies support --attach too,
and can be signed with --sign / --signer / --sign-tool exactly like
posts (see 🛡️ Sign and verify posts).
Share any message shown by tl list on your own timeline:
$ tl repost 4
reposted 7e047ff8...:35880afb...: thanks, that clears it upThe repost is committed as repost <id> <text>, where <id> is the unique id
of the shared message. The original message is encapsulated verbatim: its
raw commit object — including its gpgsig signature header, if any — is copied
into REPOST/<commit>/original inside your timeline, so the shared content and
its signature stay verifiable forever. tl verify on a repost checks the
encapsulated original, not the (possibly unsigned) repost commit. Reposts can
also be signed themselves with --sign / --signer / --sign-tool (see 🛡️
Sign and verify posts).
Send a private, end-to-end encrypted message to anyone you follow — no plaintext ever touches the mainline of a timeline:
$ tl message bob "did you see the announcement?"
encrypted message sent to `bob` on branch `3f9a2c07`tl encrypts your text with the recipient's public key — published as a
PQPKEY (post-quantum) or PGPKEY (OpenPGP) file in the root of their
timeline — and commits the ciphertext to a branch named with the recipient's
SHA3 id inside your own timeline:
$ tl git log --format=%s refs/heads/3f9a2c07...
message c52f5b08... Q0lQSEVSRUQ=The branch is delivered by tl sync. Recipients are named by alias, SHA3 id,
or pull URL. You cannot message someone you don't follow, and sending fails if
the recipient publishes no public key. Secret keys never live in a repository:
import your private key into the keyring/private/ folder by hand (see the
storage layout above) to be able to read messages addressed to you.
Attach files with --attach (repeatable) — each one is encrypted with the
recipient's public key, just like the message text, and stored in the message
commit's tree under ATTACH/<name>:
$ tl message bob "the docs" --attach proposal.pdf --attach notes.txt
encrypted message sent to `bob` on branch `3f9a2c07`No plaintext file is ever committed: the branch only ever holds encrypted blobs.
The ciphertext is all that is ever committed. To show you your own words
later (you have no private key for the recipient's message), tl caches the
plaintext in cache/messages/ inside the tl data directory — outside any
repository — so tl list always shows your sent messages as plaintext and
never dumps the ciphertext.
⚠️ Warning: the ciphertext is committed to the timeline, which is public — anyone who clones your timeline can read the encrypted message. The confidentiality of a message therefore rests entirely on the strength of the encryption, so choose a strong recipient key. Both post-quantum PQP (ML-DSA / ML-KEM) and classic OpenPGP (viagpg) encryption are available; post-quantum is preferred.
Sign any post with the keypair of the active repository (see 🔐 Manage your
keys). Signatures are embedded in the commit as a git gpgsig header — the
same place git stores PGP/SSH signatures — so signed commits remain fully
inspectable with git cat-file and friends.
$ tl post --sign "hello, quantum world"
posted (signed): hello, quantum world
$ tl verify 1
verified: post `1` has a valid signatureThe signing key is discovered automatically from the repository's private keyring:
- a single key is used without any extra flags;
- several keys require
--signer <KEY>(or the[sign] keyconfig value), which must be one of the available key ids; - the tool is
pqpwhen only post-quantum keys exist andgpgwhen only OpenPGP keys exist; with keys of both types--sign-tool pqp|gpg(or the[sign] toolconfig value) is required.
$ tl post --sign --signer a1b2c3d4e5f6a7b8 "pick a specific key"
$ tl post --sign --sign-tool gpg --signer F0F1... "openpgp signed"The pqp binary path is set with [sign] program (default pqp) and the
gpg binary path with [sign] gpg_program (default gpg):
[sign]
program = "pqp"
gpg_program = "gpg"
tool = "pqp"tl verify <ID> reconstructs the exact commit payload the signature covers
and checks it with pqp verify (or gpg --verify for OpenPGP signatures), so
tampering is detected. If the author's public key is not available locally,
tl looks for a PQPKEY (or PGPKEY for OpenPGP signatures) file in the
root of the author's timeline, imports it into a dedicated keyring under the
tl data directory, and verifies against it — your own pqp/gpg keyrings are
never touched.
Run any git command inside the active repository's timeline:
$ tl git log --oneline
2f34bb8 hello gitThe output and exit code of git are passed through unchanged, so tl git push and friends work exactly like running git by hand — handy for inspecting
or repairing the underlying repository.
$ tl reset
timeline `default` resetRuns git reset --hard to discard any dirty files and reset the working tree
and index to the last commit. Handy after a follow leaves the timeline in an
inconsistent state.
Find any post whose text matches a pattern, across your own timeline, every
user you follow, and the direct-message branches. The pattern is a glob by
default (* matches any run of characters, ? a single character), matching
any substring:
$ tl search "hello*"
3. 2026-08-01 15:25:30 @alice 💬 hello world 🌍
17. 2026-08-05 13:13:28 @test1 ✉ hola desde cachePass --regex to use a regular expression instead:
$ tl search --regex "h.llo w.rld"Results are printed in the same table as tl list, so you can feed a result's
id to tl show, tl reply or tl repost. Encrypted messages are searched
by their plaintext — decrypted or read back from the cache/messages/
cache — never by the ciphertext committed to the branch. A message whose
plaintext is not available can't match.
$ tl sync
timeline is up to datesync pulls new commits from your own pull URL into your local timeline, runs
git submodule update --init --remote to fetch the newest messages from every
user you follow, and finally exchanges direct messages: it fetches the
branch named with your own SHA3 id from each followed timeline (picking up the
messages addressed to you) and pushes the branch named with each followed
user's SHA3 id (delivering your replies to them). Run it anytime you want to
catch up.
$ tl info
repo: main
path: /home/alice/.local/share/tl/repos/main/timeline
pull url: https://example.com/alice/timeline.git
push url: git@example.com:alice/timeline.git
id: 1267de09c2d2285216ba2ab9621b7bcbbe97924cff29d496ec6ec3e4271e8a9c
messages: 12
follows: 3Shows everything about a repository's timeline: its name and location, pull and push URLs, your SHA3 user id, message count, and how many users you follow.
Drill into any message shown by tl list, using its sequential id:
$ tl show 17
id: 17
kind: message
source: test1
date: 2026-08-05 13:13:28
author: b50347c8a99bd8ea57618a8cc94e783a2a5b0bddcf64798fb03332b8d2f86db2
account: b50347c8a99bd8ea57618a8cc94e783a2a5b0bddcf64798fb03332b8d2f86db2
repo: file:///tmp/test1
commit: 78e26a748e5ac3de430016f45a81f3ca64547bc5
message id: b50347c8...:78e26a74...
to: 575b65bf2b10cc25b1339661252bd16aa0c3595cd7fcdb55aece66992ff2e6f8
payload: TUwtS0VNK1hDaGE...
plaintext: hola desde cache
plaintext source: cache
signed: no
verified: -
message:
hola desde cacheshow prints the message content plus its metadata — kind, source timeline,
date, author, account, repository, commit hash, and unique message id — plus
kind-specific details (the followed URL of a follow, the parent of a
reply/repost, the recipient of a message, the published key of a key).
A direct message also shows its encrypted payload, its plaintext, and
where the plaintext came from (cache for sent messages, decrypt for
received ones). It also runs the same signature check as tl verify, reports
it in signed/verified, and prints the raw signature payload of signed
messages.
$ tl list
1. 2026-08-01 15:10:00 alice ✚ bob (https://example.com/bob/timeline.git)
2. 2026-08-01 15:10:00 alice 💬 first post!
3. 2026-08-01 15:25:30 alice 💬 hello world 🌍
4. 2026-08-01 15:20:00 bob ⤷ nice one
5. 2026-08-02 09:00:00 alice 🔁 bob's first postEvery column is padded to the width of its widest value, so the fields line up
vertically. The kind is shown as a single Unicode character: 💬 for posts,
✚ for follows, ⤷ for replies, 🔁 for reposts, ✉ for direct messages
and 🔑 for published keys. A follow shows the followed user's alias (or,
without one, their short SHA3 id) together with their repository URL — never
the literal word follow. Override the kind characters with
--kind-post-char, --kind-follow-char, --kind-reply-char,
--kind-repost-char, --kind-message-char and --kind-key-char (or the
matching [list] config values), or show the kind name with
--fields id,date,user,kind,message.
Signed posts are flagged and distinguish a valid signature from an
invalid or unverifiable one: a verified signature shows ✹ (configurable
with --kind-signed-char, color with --color-signed-char) followed by the
kind character, e.g. ✹💬, and the kind name becomes post+signed,
follow+signed, reply+signed, repost+signed, message+signed or
key+signed. A signature that is invalid or cannot be verified shows ✗
(configurable with --kind-invalid-signed-char) followed by the kind
character, e.g. ✗💬, and the kind name becomes post+signed+invalid (and so
on). Validity is checked like tl verify, importing the author's key when
needed. Run tl verify <ID> for details.
New posts are separated from the ones you've already seen: tl list
remembers what it has printed, per repository, in a seen.txt state file under
the data directory (<data>/state/seen-<repo>.txt, one message id per line) —
so tl list --repo <name> keeps each repository's state separate. Posts that
were never printed before show up below a horizontal line; in the default
oldest-first order the line sits before the new posts, and with --reverse
after them. The very first run marks everything as seen and draws no line.
Disable it with --no-new-posts-line or [list] new_posts_line = false.
$ tl list
1. 2026-08-05 09:00:00 alice 💬 some older post
──────────────────────────────
2. 2026-08-05 18:54:11 alice 💬 brand new postEvery message carries a type, inferred from the commit that carries it:
a follow <url> commit is a follow, a reply <id> <text> commit is a
reply, a repost <id> <text> commit is a repost, a
message <id> <payload> commit is a message, a key <key-id> commit is a
key, everything else is a post.
Direct messages show up in the list too: the messages you sent live on the
recipient's branch of your own timeline, and the messages you received live on
the branch named with your id inside the sender's timeline. Received messages
are decrypted for display when your private key is available in
keyring/private/; sent messages show your own words read back from the
cache/messages/ plaintext cache. A message that can't be shown (an old sent
message whose plaintext was never cached, or a received message you have no
key for) is rendered as (encrypted message) — the ciphertext is never
displayed.
Every post is prefixed with a sequential id (1-based, oldest first over
the whole timeline by default). Ids stay stable no matter the --limit or
--reverse flags, so you can use them later to reply to or expand a specific
post. Pass --reverse to print the newest posts first.
Replies show only their text: the reply <sha3>:<commit> link that identifies
the parent is hidden from the output and used only to build the thread.
Replies are threaded: a reply is shown directly below the message it
answers, indented by two spaces per nesting level (tune it with
--reply-indent <N>). A reply whose original message is not present in any
followed or local timeline is hidden by default; pass --show-orphans to see
it.
By default post dates are rendered as YYYY-mm-dd HH:MM:ss. Pass a
strftime-style format to change it:
$ tl list --date-format "%Y/%m/%d"Options:
| Option | Description |
|---|---|
--limit <N> |
Show at most N posts. |
--no-follow |
Exclude the followed timelines and the local "follow" commits so only your own posts and replies are listed. |
--reverse |
Print newest posts first (the default is oldest first). |
--date-format <FMT> |
strftime-style format for post dates. Overrides the config value. |
--show-orphans |
Keep replies whose original message is missing. |
--format <FMT> |
Output format: table (default) or json. |
--fields <FIELDS> |
Comma-separated columns for the table output. |
--kind-post-char <C> |
Character used to render posts in the table. |
--kind-follow-char <C> |
Character used to render follows in the table. |
--kind-reply-char <C> |
Character used to render replies in the table. |
--kind-repost-char <C> |
Character used to render reposts in the table. |
--kind-message-char <C> |
Character used to render direct messages in the table. |
--kind-key-char <C> |
Character used to render published-key commits in the table. |
--kind-signed-char <C> |
Character used to render signed messages in the table. |
--kind-invalid-signed-char <C> |
Character used to render signed messages whose signature is invalid or could not be verified. |
--user-char <C> |
Character prefixed to the user name/id in the user column (default @). |
--color-<FIELD> <SPEC> |
Color of a table column, e.g. --color-user red,bold. |
--color-signed-char <SPEC> |
Color of the signed kind character. |
--color-message-url <SPEC> |
Color used to highlight URLs in the message column (default bright_blue,underline). |
--regex-message-url <RE> |
Regex used to match URLs in the message column (default (https?|file)://[^\s]*[^\s.,;!?()]). |
--color-mention <SPEC> |
Color used to highlight user mentions in the message column (default bright_magenta). |
--regex-mention <RE> |
Regex used to match user mentions in the message column (default @\w+); matches are rewritten to the followed user's alias when known. |
--reply-indent <N> |
Spaces to indent each reply nesting level (default 2). |
--no-new-posts-line |
Do not draw the line separating posts already printed by tl list from new ones, and do not track what has been printed. Overrides the config value. |
Table colors are only emitted when stdout is a terminal. Override with
--force-color (always) or --no-color (never):
$ tl list --color-user bright_blue --color-kind_char "196,bold"
$ tl list --color-message-url "yellow,underline" --regex-message-url "https?://[^\s]+"The color spec is foreground,background,modifier; colors may be named or a
256-color index, and modifiers include bold, dim, italic, underline,
blink, reverse, hidden and strikethrough. Per-field colors can be
stored in the config under [list] colors.
Ask for the timeline as JSON for scripting:
$ tl list --format json
[{"id":1,"source":"alice","kind":"post","date":"2026-08-01 15:10:00","message":"first post!","message_id":"1267de09...:2f34bb8"}]Each JSON post carries id, source, kind, date, message,
message_id, and (for replies) parent_id.
Pick the columns you want in the table:
$ tl list --fields id,kind,message
1. follow bob (https://example.com/bob/timeline.git)
2. post first post!
3. post hello world 🌍
4. reply nice oneThe reply row is indented (depth × --reply-indent) under its parent.
Available fields: id, date, user, kind_char, message (the default
set), plus kind (the kind name), account (SHA3 user id), commit (commit
hash), repo (pull URL), message_id, and parent (the id of the post a
reply answers).
| Option | Description |
|---|---|
--config <PATH> |
Override the configuration file path. |
--data <PATH> |
Override the data root directory. |
--repo <NAME> |
Repository to operate on (defaults to the first created). |
--no-color |
Never emit ANSI colors, even on a terminal. |
--force-color |
Always emit ANSI colors, even when piped. |
--help |
Print help information. |
--version |
Print version information. |
A user id is computed as:
user_id(url) = hex( SHA3-256( url ) )
It is deterministic and collision-resistant, so the same URL always resolves to the same 64-character lowercase hex id — on any machine, forever.
The config file ($XDG_CONFIG_HOME/tl/tl.toml) holds your repositories, each
with its own followed users and aliases:
default_repo = "main"
[repo.main]
pull_url = "https://example.com/alice/timeline.git"
push_url = "git@example.com:alice/timeline.git"
[repo.main.following]
"<sha3-of-url>" = "https://example.com/bob/timeline.git"
[repo.main.aliases]
"<sha3-of-url>" = "bob"
[repo.work]
pull_url = "https://example.com/alice/work.git"
push_url = "git@example.com:alice/work.git"
[list]
date_format = "%Y-%m-%d %H:%M:%S"
kind_repost_char = "🔁"
kind_message_char = "✉"
kind_key_char = "🔑"
[repost]
sign = falseSee config/tl.toml.sample for a commented example.
Every command-line option is also available as a config-file key, with the precedence CLI flag > config value > built-in default. The full set of sections and keys:
| Section | Keys |
|---|---|
| top-level | default_repo, data_dir |
[repo.<name>] |
pull_url, push_url, following (sha3 id → URL), aliases (sha3 id → display alias) |
[create] |
pull_url, push_url |
[list] |
limit, no_follow, reverse, format, fields, date_format, show_orphans, kind_post_char, kind_follow_char, kind_reply_char, kind_repost_char, kind_message_char, kind_key_char, kind_signed_char, kind_invalid_signed_char, user_char, color, colors, color_signed_char, color_message_url, regex_message_url, color_mention, regex_mention, reply_indent |
[post] |
sign, regex_mention |
[reply] |
sign |
[repost] |
sign |
[search] |
regex |
[sign] |
program, key, gpg_program, tool |
data_dir (the full data root, equivalent to --data) is read first so the
rest of the configuration can resolve relative to it.
Requires Rust 2021 edition (1.70+) and git on the PATH.
$ make # build the release binary
$ make debug # build the debug binary (target/debug/tl)
$ make test # run the full test suite
$ make lint # clippy with -D warnings + fmt check
$ make doc # build the API documentation
$ make install # install to /usr/localOr with cargo directly:
$ cargo build --release
$ cargo test- Man page:
man/tl.1(make manto regenerate) - Shell completions:
completions/tl.{bash,zsh,fish}(make completions) - API docs:
cargo doc --no-deps --open
The test suite never touches a real network. Unit tests run against an
in-memory git mock, and integration tests exercise the real git binary
against local repositories in temporary directories.
MIT — see COPYING for the full license text.