A markdown-first knowledge outliner for the terminal, with a desktop app. Journals, nested blocks,
[[page links]] and backlinks — in plain files you can cat, rg, nvim and
git without tlog running at all.
~/notes/
├── journals/
│ ├── 2026-09-16.md
│ └── 2026-09-17.md
└── pages/
├── Project Foo.md
└── Rust.md
No Electron, no database, no daemon, no background process. The notes directory
is a git repository and tlog commits to it as you work, so every structural edit
is one git checkout away from being undone. Add a remote and
tlog push -auto on, and it pushes too.
Three ways in, one core: a terminal outliner, a CLI, and a desktop app that renders in the system WebView and weighs about 8 MB.
just install # the CLI and the outliner, into ~/.local/bin
just app-setup # once: the Wails CLI, also into ~/.local/bin
just app # the desktop app — macOS: app/build/bin/tlog.appOn Arch the desktop app also wants gtk3 and webkit2gtk-4.1.
tlog # today's journal, in the outliner
tlog today # print the path of today's journal
tlog open "Project Foo" # print a page's path, creating it if needed
tlog add "buy milk" # append a block to today's journal
tlog add -p "Project Foo" "implement the parser"
echo "note" | tlog add # append from a pipe
tlog attach report.pdf # onto the shelf, and linked in today's journal
tlog files # what is on the shelf, newest first
tlog due # what is dated and still open, soonest first
tlog import # one-time import from a Logseq graph
tlog push # send the notes to their remote
tlog help # all of it, and `tlog version`
nvim "$(tlog today)" # the point: it is just a file
rg '#research' ~/notesThe notes directory is $TLOG_DIR, or ~/notes. Any command takes -dir.
↑ ↓ / j k |
move between blocks |
← → / h l |
collapse · expand, or step out and in |
enter |
new block below, and start typing |
i / e |
edit this block |
alt+enter |
newline inside a block (also ctrl+j) |
tab / shift+tab |
indent · outdent |
alt+↑ alt+↓ |
move the block and its children |
space |
toggle the task checkbox |
dd |
delete the block and its children |
[[ |
page autocomplete, while typing |
/ |
commands: /todo, /deadline fr, /table, /code … |
gf / gb |
follow the link · what links here, as a list |
enter |
on a reference below the outline: jump to it |
ctrl+p / / |
open a page · search every block |
t / [ ] |
today · previous, next day |
backspace |
back to where you came from |
R |
reload from disk (asks again if you have unsaved typing) |
A |
agenda: everything with a deadline |
M |
messages: everything tlog has said |
, |
settings |
? / q |
help · quit |
Arrow keys work everywhere; the vim keys are an alternative, not the foundation.
shift+enter is not distinguishable from enter in most terminals, so the
newline-inside-a-block key is alt+enter or ctrl+j. In kitty you can map
shift+enter to \x1b\r to get it as well.
Small on purpose. This is not CommonMark and does not try to be.
---
title: optional page metadata as YAML frontmatter
---
- a block
- a child block
- a block can span several lines,
including a fenced code block:
```go
x := 1
```
- [ ] an open task, [x] a done one
- a block with properties
status:: doing
due:: 2026-09-20
- a link to [[Some Page]], to a block [[Some Page#^k3f9q2]], and a #tag
- met [[Ada Lovelace #person]] about [[Analytical Engine #project]]
- a block that something points at ^k3f9q2
- > a quotation, which is just a block whose text starts with >
- a block can hold ordinary markdown:
1. a numbered list
2. a second item
- tables are easier to type as csv than as pipes:
```csv
Kürzel, Studiengang, ECTS
BZ, Bioanalytik und Zellbiologie, 180
Everything here except `key:: value` is standard markdown, because the point is
that an agent reading your notes needs no knowledge of anyone's dialect. Block
properties survive untranslated only because markdown offers no equivalent.
## The desktop app
Keys, with `⌘/` for the list:
| | |
|---|---|
| `⌘P` | go to a journal, page or tag |
| `⌘K` `⌘F` | search inside blocks |
| `⌘T` | today |
| `⌘⇧A` | agenda |
| `⌘[` `⌘]` | back, forward |
| `⌥⌘←` `⌥⌘→` | the day before, the day after |
| `⌘,` | settings |
| `⌘⇧M` | everything tlog has said |
| `⌘⇧⏎` | a new block above this one |
| `⌘⌫` | delete the block and what is under it |
| `Esc` | close whatever is open |
Click a bullet to fold what is under it. A bullet does nothing else: a block
becomes a task when you say `/todo` or give it a `/deadline`, never because you
clicked near it. A checkbox, once there, ticks off on a click.
Errors stay in the corner until dismissed and are kept in Messages afterwards —
a push that failed while you were looking elsewhere is still there.
The same notes in a window, for when an outline is easier to read than to type:
sidebar of journals, pages and tags; the outline, editable in place; and the
tagged pages and linked references below it. `⌘F` searches, `⌘T` jumps to today.
Markdown is rendered while you read and raw while you write: the block under the
caret shows the characters that are actually in the file, everything else is
drawn. Rendered are links (wiki, markdown, bare URLs and the `file://` ones
`att` writes), **bold**, *italics*, `inline code`, ~~strikethrough~~, fenced
code as a highlighted monospace block, `>` quotations, numbered and bulleted
lists, tables, headings, rules and images. Clicking a page name follows it, a tag opens the tag
page, and a web or file link opens in the system.
Enter makes a new block, alt+Enter a newline, Tab and shift+Tab indent, alt+↑↓
move a block with its children, and `[[` completes — the same keys as the TUI.
It renders in the system WebView — WKWebView on macOS, WebKitGTK on Linux — so
the whole bundle is about **8 MB** with nothing embedded. There is no Electron
and no Chromium. It is a third adapter over the same core, which is why it can
be that small: `app/api.go` is 100 lines of bridging and holds no logic.
Because writes are compare-and-swap, the app, the outliner and nvim can all have
the same file open at once. A lost race is refused and reported, never merged.
On Hyprland with an NVIDIA card, WebKitGTK's DMA-BUF renderer can produce a
blank window; Wails detects the driver and disables it. Build natively rather
than shipping an AppImage — a bundled, stale `libwayland-client` is the usual
cause of an empty window on Wayland.
## Other names for a page
`aliases: Ada, Countess Lovelace` in a page's frontmatter — or `/alias Ada` on
it — makes `[[Ada]]` mean Ada Lovelace. Both names complete, both resolve, and
the mention lands in her backlinks whichever one was written, because a page has
one set of references however it was named. A page on disk always beats an
alias for its own name, so a line of frontmatter can never make a file
unreachable.
## Referring to one block
`((` opens a chooser of blocks — the same search the palette runs, so whatever
you can find you can point at. Picking one gives that block a durable name and
types a link to it:
see ((lecture → see [[Timetable#^k3f9q2]]
The `^k3f9q2` is written into the other file at that moment and not before. A
corpus nobody has referred to has no anchors in it at all, which is why reading
never writes. The reference is an ordinary link, so it appears in that page's
backlinks like any other mention, and following it goes to the block.
**A reference draws as the block it points at**, not as the page name:
what you type see ((lecture → picks a block what is stored see [[Timetable#^k3f9q2]] what you read see the lecture is at nine
The block under the caret still shows the reference itself, like every other
piece of syntax — what you type is what is stored.
**A block that is nothing but a reference is an embed:** it draws the block it
points at *and that block's children*, in place. No syntax to learn; the rule is
simply that a block which says nothing else is showing you what it points at.
Those rows belong to the other file, so they are shown and not edited — click
one to go there. A reference with prose around it stays inline, because
inlining a subtree into the middle of a sentence would be nonsense.
## Tasks and deadlines
`/` in a block opens a command menu, named after Logseq's so the muscle memory
carries over. The command is typed and then disappears: what stays in the file
is its effect, never a slash.
| | |
|---|---|
| `/todo` `/done` | a checkbox, open or ticked |
| `/deadline fr` | a checkbox and `Deadline:: 2026-09-25` on the block |
| `/date morgen` | `[[2026-09-25]]` inline, a link to that day's journal |
| `/quote` `/code` `/table` | a quotation, a code fence, a `csv` table |
| `/file report` | a link to an attachment, chosen from the shelf |
| `/page` `/tag` | a `[[link]]` or a `#tag`, where completion takes over |
| `/prop status offen` | `status:: offen` on the block; no value removes it |
| `/alias Ada` | another name this page answers to |
The date argument is typed, not picked. A calendar appears beside the menu
showing what the shorthand resolved to — confirmation, so a mistyped date is
caught before it is written, rather than the way you are meant to enter one.
/deadline fr friday — and mo tu we th sa su /deadline tomorrow today · tomorrow · yesterday /deadline +3d +3d +2w +1m +1y /deadline eow end of week · eom · eoy /deadline 26.9. the German form, since that is what you would type /deadline 2026-10-11 ISO
German works too, everywhere a date is read: `morgen`, `übermorgen`,
`nächsten freitag`, `+1j`, `3t`, and `/fällig` for the command itself. What
tlog *says* is English; what you type in your own notes is your business.
German and English both work, and whatever you type is stored as ISO — so the
files sort, grep and still mean the same thing in a year. Nonsense is refused
rather than guessed at: a silently wrong deadline is worse than being told the
word was not understood.
A dated block shows its date beside it, red when overdue, yellow when today.
Finished work is never overdue, however long ago it was due.
```sh
tlog due # everything open and dated, soonest first
tlog due -all # including what is done
Agenda is that same list, from the same call: A in the outliner, ⌘⇧A in
the app. Enter or a click goes to the block, and space ticks it off without
leaving — a in the outliner also shows what is already done.
~/.config/tlog/config.toml, written the first time something changes. , in
the outliner opens the settings, ⌘, in the app, and tlog config prints them
with where each one came from.
tlog config # everything, and its current value
tlog config attachments.sanitize true # change oneA flag beats the environment, which beats the file, which beats the default. The file is rendered whole each time it is saved, with the explanation of every setting in it — reading it is how you find out what there is. Hand-written comments are replaced; values are not.
notes |
where the notes live — $TLOG_DIR overrides |
startup |
open today's journal, or whatever was written last |
attachments.dir |
the shelf shared with att — $ATT_DIR overrides |
attachments.sanitize |
tidy filenames on the way in |
attachments.lowercase |
…and lowercase them |
git.autocommit |
commit as you write |
git.debounce |
how long writing must be idle before a commit |
git.autopush |
push after every commit — kept in the notes repo |
git.remote |
where pushing goes — kept in the notes repo |
watch.enabled |
pick up edits made in another editor |
watch.interval |
how often the notes directory is checked |
format.blank_lines |
a blank line between top-level blocks |
deadline.property |
Deadline or due, whichever you prefer |
dates.end_of_week |
what eow means: friday or sunday |
Two of those are not in tlog's config file, and the menus say so. Pushing
and the remote belong to the notes directory and live in its own git config, as
tlog.autopush and remote.origin — so a copy of the notes carries the answers
with it and no second file can disagree about where your notes go. They are
shown and changed here like everything else:
tlog config git.remote git@gitlab.example:you/notes.git
tlog config git.autopush trueClearing the remote turns pushing off with it, rather than leaving a switch that cannot do anything.
Most of tlog is deliberately not configurable. ISO dates, files as the source of truth, compare-and-swap writes and the small dialect are decisions rather than preferences, and a setting for each would only be a way to break them.
Two of these have consequences the menus tell you about: changing where the
notes live takes effect at the next start, and changing blank_lines reformats
each file the next time that file is written — the content is untouched, but
the diff will be large.
On attachments.sanitize: it renames meeting notes.pdf to
meeting-notes.pdf on the way in. Off by default, because att's own rule is
that files keep their names and att writes to the same directory — turning it
on means the two tools name things differently. Umlauts are kept either way:
they are valid in filenames, they survive percent-encoding in a link, and
mangling a German word to look English helps nobody.
Files live in ~/.att, shared with att
rather than in a second place of tlog's own: the same flat store with original
names, the same collision rule, and byte-for-byte the same Markdown link. So
att drop and tlog attach fill one shelf, and a link written by either is
understood by both.
tlog attach report.pdf slides.pptx # onto the shelf, linked in today's journal
tlog attach -p "Projekt" report.pdf # ...or on a page
tlog attach -link-only report.pdf # shelved, link printed, no note written
tlog files # the shelf, newest first
tlog files budget # narrowed, fuzzily if nothing contains itIn a note, /file offers the shelf. In the desktop app, drop files on the
window — they land on the shelf and their links land in the page you are
reading. Clicking one opens it in the Finder.
The original file is never moved: attaching it to a note should not take it
away from whatever else refers to it. Images become ![embeds], everything
else a plain link, and nothing on the shelf is ever overwritten — a clash gets
a numeric suffix, report.pdf then report-2.pdf.
att is not required. It owns the workflow tlog does not have — a drop folder and a watcher — and if it is installed the two simply share a directory.
tlog push sends the notes to their remote. tlog push -auto on does it after
every commit instead, and the setting is stored in the notes repository itself
(tlog.autopush), not in tlog: whether these notes leave the machine is a
property of this directory, so a freshly created one never pushes by surprise.
Three rules it does not bend, because writing a note and publishing it are different acts and only one of them is easy to take back:
- A failed push is never a failed save. The commit is what protects the notes; the push is a convenience on top. If it fails you are told "committed, but not pushed", and the commit is still there.
- It never forces and never merges. A rejected push means the remote has
commits this copy does not — probably another machine. You get told to run
git pull --rebaseand look at what comes back. Resolving that is a decision. - It is bounded. A push gets 20 seconds; being offline costs a moment rather than hanging a command that is supposed to feel instant.
A push that failed in the background is shown in the outliner's status line and in the app, because a failure nobody sees is the same as no backup at all.
Every page shows what the rest of your notes say about it. Below its own
outline, a page lists the blocks elsewhere that mention it — with their children,
because a mention is usually a bare name with the substance underneath it. Move
onto one and press enter to jump there. Those rows belong to other files and
are not editable in place.
A tag is just a page. #person and [[person]] name the same thing, so
there is no second namespace to keep in step. The person page shows everything
tagged #person and everything that mentions it, and can hold notes of its own.
Type a page at the moment you mention it:
- met [[Ada Lovelace #person]] about [[Analytical Engine #project]]That says Ada Lovelace is a person and Analytical Engine is a project. Now the
person page lists every person you have ever named, without you having gone to
a single one of their pages to declare it. Typing # after the page name inside
the brackets completes existing tags.
The same thing can be said on the page itself, if you prefer:
---
tags: person, colleague
---Both are read; neither is required. Tags inside brackets describe the page being
linked to, a bare #tag in prose is a mention of the tag itself, and only the
latter shows up as a linked reference — otherwise a tag's page would fill with
one identical line per page ever typed.
A pipe table has to be re-aligned by hand every time a cell changes. A csv
fence does not — type rows, and both the outliner and the app draw a table:
Kürzel, Studiengang, ECTS
BZ, Bioanalytik und Zellbiologie, 180
CH, Chemie, 180The separator is detected, so a semicolon-separated export from a German-locale
spreadsheet works unchanged; tsv and psv are honoured too. Quoted fields keep
their commas and a half-typed row is padded rather than rejected. Pipe tables
still render, so nothing you already wrote changes.
Fenced code is highlighted for go, js/ts, python, sh, sql, json, yaml and toml. An unknown language stays plain monospace.
Everything the outliner can do is reachable from the command line, because both are adapters over the same core. Reads hand back an address; writes take one.
tlog search lecture -json # hits, each with an address
tlog view Timetable -addr # a page, with an address per block
tlog index -json # every journal, page and tag
tlog due -json # the agenda, addressable
tlog block <addr> <operation> # change one blockAn address is pages/Note.md:42@9f3a2b1c8d4e5f60 — the file, where the block
starts, and the hash of the file it was read from. That last part is the
point: a write computed against a file that has since changed is refused, not
merged and not applied to whatever now sits at that offset. Every write prints
the address the block ended up at, because rewriting a file moves every offset
after it, so one call chains into the next:
addr=$(tlog search "plan the lecture" -json | jq -r '.[0].addr')
addr=$(tlog block "$addr" task) # [ ] plan the lecture
addr=$(tlog block "$addr" prop Deadline 2026-10-01) # now on the agenda
tlog block "$addr" after -child "book the room"The operations are text, after [-child], before, split, merge,
indent, outdent, up, down, task, prop, ref, delete — see
tlog block help.
The exit code says which kind of no it was, so a caller knows whether reading again would help:
0 |
done |
1 |
something went wrong |
2 |
that is not a command, an operation or an address |
3 |
the move does not exist — outdenting a top-level block. Retrying will not help. |
4 |
the file moved since you read it. Read again and retry. |
Nothing here is a special agent mode: tlog block is the same app.Service
call the outliner makes when you press Tab.
The core is the product. The outliner, the CLI and the desktop app are adapters
over internal/app and hold no logic of their own — which is why adding the
third one needed no new concepts.
Markdown is the truth, and tlog owns the formatting of files it writes. The first write normalises a file to canonical form; from then on, re-rendering the whole file changes only what actually changed, so diffs stay small without a source-preserving patcher.
A block is addressed by position plus a file checksum, not by an id. Search
hands out both; a mutation passes the checksum back and is refused if the file
moved on. That is what makes concurrent editing safe with no daemon and no
locks: nvim, the outliner and a CLI call can all write, and a lost race
produces a visible refusal rather than a silent overwrite.
Ids are lazy. A block gets a written ^anchor only when something needs to
name it permanently. Reading never writes.
The index is in memory and thrown away. At the sizes this targets, parsing the whole directory costs single-digit milliseconds, which is cheaper than keeping a cache honest.
DECISIONS.md has the reasoning behind all of that, including what was
deliberately not built. PLAN.md has what is still unbuilt, and what was looked
at and left alone.
tlog import -dry-run # report, write nothing
tlog import # write, then commitReads a Logseq graph (or its markdown mirror) and translates it:
journals/2026_09_15.md→journals/2026-09-15.mdTODO/LATER/DOING→- [ ],DONE/CANCELED→- [x]- Logseq's page header properties → YAML frontmatter
- block ids →
^anchors, and[[uuid]]/((uuid))→[[Page#^anchor]] Sep 16th, 2026 00:00→2026-09-16collapsed::and other UI state → dropped- pages holding nothing but a Logseq id → not created
- page classes →
tags:frontmatter, read out of Logseq's own database
References whose target no longer exists are left exactly as written and reported rather than guessed at.
Page tags come from the database, not the mirror. Logseq's DB version keeps
the truth in SQLite and writes the markdown mirror as a lossy export: it carries
no page classes at all, so importing from the files alone silently drops every
person, project and topic you have classified. tlog reads the classes back out
of db.sqlite — on a snapshot copy, so a running Logseq is never disturbed — and
writes them as tags: frontmatter. It needs sqlite3 on PATH; without it the
import says so and proceeds untagged.
Your Logseq graph is only ever read. tlog cannot modify, move or delete it, so importing is safe to repeat as often as you like, and the import refuses outright if the destination overlaps the source. What is one-way is the conversion: there is no converter back, so notes you write in tlog will not appear in Logseq. Keep using Logseq until you decide to stop.
The frontend has no build step; its tests are plain node:
node app/frontend/app_test.js # the pure helpers
node app/frontend/smoke_test.js # boots the page against a stubbed API
npm install jsdom # the smoke test skips without itjust test # go vet, go test, and the frontend's own testsEverything is testable without a terminal or a window. The outliner is driven
headlessly by sending key messages to its model and asserting on what lands on
disk (internal/tui/tui_test.go); the desktop app's bindings chain edits using
only what the previous call returned (app/api_test.go), which is exactly what
its frontend does; and the frontend's pure helpers are lifted out of app.js
and tested in node (app/frontend/app_test.js). If a change cannot be tested
that way, the logic is in the wrong layer.
AGENTS.md has the rules that matter for changing any of it.
MIT — see LICENSE.
tlog itself depends on Bubble Tea and Lip Gloss for the terminal and Wails for
the desktop app, all MIT. Everything else it uses is the Go standard library.
Their own transitive dependencies are a mix of MIT, BSD-3-Clause, ISC and
Apache-2.0; go list -m all is the authoritative list.