A terminal-native capture tool that writes structured Markdown to a folder. Config-driven, keyboard-first, no friction. Built for Obsidian users, works without it.
We don't write enough about the things that matter to us. Not because we don't want to - because the friction kills the impulse before we act on it.
Pour exists to close that gap. One command, a few keystrokes, back to what you were doing. If we can capture the thought, the meaning isn't lost to time. A moment, a cup, a song, a passing thought - permanent in your hands.
Write more... pour.
pour coffee # log a brew
pour me # capture a thought into your daily note
pour todo # add a task
pour note # create a fleeting note
pour # open the dashboard
Prebuilt binary (recommended)
Linux / macOS:
curl -fsSL https://raw.githubusercontent.com/mads-jm/pour/main/install.sh | shWindows (PowerShell):
irm https://raw.githubusercontent.com/mads-jm/pour/main/install.ps1 | iexThe installer downloads the latest release from GitHub, places the binary at ~/.local/bin/pour (Unix) or %LOCALAPPDATA%\Programs\pour\pour.exe (Windows), bundles the resources/ folder alongside (sample configs, presets, AI-agent reference), and adds it to your PATH. Pin a specific version: curl ... | sh -s -- 0.2.2 on Unix, or $env:POUR_VERSION = '0.2.2'; irm ... | iex on Windows.
From crates.io (requires Rust toolchain):
cargo install --git https://github.com/mads-jm/pourFrom source:
cargo build --release
# Binary is at target/release/pourRequires Rust 2024 edition. No other system dependencies — Obsidian Local REST API is optional.
0. Initialize
pour initpour init creates the default layout at ~/.pour/ — config.toml, secrets.toml, and example modules — guided by interactive prompts. Run this once before first use. All Pour state lives under ~/.pour/ (override with POUR_HOME).
1. Create the config file (manual alternative)
mkdir -p ~/.pour
touch ~/.pour/config.tomlAll Pour state lives under ~/.pour/ (override with POUR_HOME):
~/.pour/
config.toml
secrets.toml
presets.json
field_presets.json
cache/
state.json
history.jsonl
history-summary.json
2. Point it at your vault
config_version = "1.0.0"
[vault]
base_path = "/path/to/your/vault"3. Define a module and run it
[modules.todo]
mode = "append"
path = "Daily/%Y%m%d.md"
icon = "✅"
append_under_header = "### Tasks"
append_template = "- [ ] {{body}}"
append_shallow = true
[[modules.todo.fields]]
name = "body"
field_type = "text"
prompt = "Task"
required = true
target = "body"pour todoEach module uses one of two modes:
| Mode | Behavior |
|---|---|
append |
Appends content under a header in an existing note (e.g. a daily note) |
create |
Creates a new file per entry with YAML frontmatter |
text- single-line inputnumber- numeric inputtextarea- multi-line; goes to the Markdown body by defaultstatic_select- fixed options listdynamic_select- options pulled from your vault (see Transport below)composite_array- repeatable set of sub-fields (e.g. brew recipe stages)
Fields go to YAML frontmatter by default. Override with target = "body" or target = "frontmatter".
Paths support strftime tokens and field name tokens:
# strftime: %Y, %m, %d, %H, %M, %S
path = "Daily/%Y%m%d.md"
# field token: {{field_name}}
path = "Coffee/{{bean}}@%Y%m%d.md"
# special tokens: {{date}}, {{time}}
path = "Notes/%Y/%m/{{title}}.md"Gate field visibility on another field's value. Hidden fields are excluded from validation and output.
[[modules.coffee.fields]]
name = "shot_style"
field_type = "static_select"
prompt = "Shot style"
options = ["Standard", "Turbo", "Ristretto"]
show_when = { field = "brew_method", equals = "Espresso" }
# or match multiple values
show_when = { field = "brew_method", one_of = ["Pour Over", "Immersion"] }When a dynamic_select field has allow_create = true, typing a novel value opens a sub-form overlay to capture structured frontmatter for the new note before continuing.
[[modules.coffee.fields]]
name = "bean"
field_type = "dynamic_select"
source = "Coffee/Beans"
allow_create = true
create_template = "bean"
post_create_command = "templater:run" # fires an Obsidian plugin command after creation
wikilink = true # wraps the value in [[...]]
[templates.bean]
path = "Coffee/Beans/{{name}}.md"
[[templates.bean.fields]]
name = "origin"
field_type = "static_select"
options = ["Ethiopia", "Colombia", "Kenya"]Save and recall named field-value sets per module.
| Key | Action |
|---|---|
Ctrl+S |
Save current form as preset |
Ctrl+D |
Delete current preset |
p / Ctrl+P |
Open hierarchical picker (when preset_axes is configured) |
Left / Right |
Cycle through saved presets (only when no preset_axes) |
Ctrl+Left/Right |
Reorder presets |
Fields with preset_exclude = true are skipped during save and apply - useful for notes or observations that change every entry.
When you have many presets organised by brewer × bean × intent, add preset_axes to the module and press p to drill through them one level at a time instead of cycling linearly.
[modules.coffee]
mode = "create"
path = "Coffee/log.md"
preset_axes = ["method", "bean"] # drilldown order: Method → Bean → preset listInside the picker: ↑↓ navigate, Enter drill/apply, Backspace/← pop back, Esc cancel. The name field is auto-filled with <method> · <bean> when saving a new preset.
Composite-array fields like recipe or pressure_profile carry their own preset list, scoped per module.field, so a "Hoffmann 4:6" recipe can be replayed without touching the rest of the form. Presets live in ~/.pour/field_presets.json and only surface inside the composite editor overlay.
| Key (inside the composite overlay) | Action |
|---|---|
s |
Save current rows as a named preset |
l |
Open the load picker (Up/Down · Enter · Ctrl+D delete · Esc) |
p |
Quick-cycle to the next saved preset |
Apply replaces the current rows silently. If sub_fields were added or removed since the preset was saved, rows are padded or truncated to match the current schema.
Pour writes to Obsidian via two paths, falling back automatically:
- API - HTTPS to Obsidian Local REST API at
https://127.0.0.1:27124with Bearer token auth. Setapi_keyin~/.pour/secrets.tomlor viaPOUR_API_KEYenv var. - Filesystem - Direct
std::fswrites tovault.base_path. Always available.
# ~/.pour/config.toml
[vault]
base_path = "/path/to/vault"
api_port = 27124# ~/.pour/secrets.toml (keep out of version control)
api_key = "your-key-here" # or POUR_API_KEY env vardynamic_select data fallback chain: API query → disk scan of source path → ~/.pour/cache/state.json → freetext input. The TUI renders immediately from cache while fetching fresh data in the background.
No. Pour writes plain Markdown files with YAML frontmatter to any directory. Obsidian is one excellent consumer of those files, but anything that reads Markdown works — Logseq, Dendron, Hugo, Zola, grep, a 10-line Python script.
Without Obsidian: set base_path to any folder, skip the api_key and api_port settings. Pour writes files directly to disk. You get structured, queryable Markdown — no plugins, no proprietary format.
With Obsidian: you get the REST API transport (faster writes, richer directory listing), [[wikilink]] support, and post-create plugin commands. These are opt-in — if you don't configure them, none of that code runs.
A few config options are Obsidian-flavored (wikilink = true, post_create_command), but they're all opt-in. The core — config, TUI, form engine, presets, YAML output — is vault-agnostic.
For the full story, see Pour Without Obsidian.
The terminal is the right answer at your desk. It's the wrong answer at the kitchen counter or away from the laptop. pour serve is the second front door — same engine, same vault, same Markdown output, accessible from any browser on your LAN.
From the TUI dashboard, press s to suspend the dashboard and start the server inline. The QR code appears in the cooked terminal. Step away, capture from your phone, come back, press Ctrl+C — the dashboard resumes. One process, one terminal, no second window.
From the command line:
pour serve # default port 8421
pour serve --port 9000On startup, a QR code prints to the terminal alongside the raw URL. Scan it with your phone. The URL carries your auth token as a query param for the first-visit bootstrap; after that the PWA stores the token client-side and sends it as a header.
The PWA lists your modules as tappable tiles. Tap a module, fill the form, submit. The capture path is:
Phone → POST /api/v1/submit/:module → axum → engine → vault
Identical to what the TUI writes. The vault never knows or cares which front door was used.
Add to Home Screen — on iOS and Android the browser offers an "Add to Home Screen" prompt. This gives the PWA an app-like icon and launch behavior; no app store involved.
PNG icons. The PWA ships a vector web/icon.svg. Android and iOS also accept raster PNGs for the home screen icon. ImageMagick is not bundled with the project; to generate them yourself run:
magick web/icon.svg -resize 180x180 web/icon-180.png
magick web/icon.svg -resize 192x192 web/icon-192.png
magick web/icon.svg -resize 512x512 web/icon-512.pngThe Rust server embeds everything under web/ and serves files under web/ at /static/{filename}. Place PNGs in web/ and they will be served at /static/icon-{size}.png automatically.
LAN-only by design. The server binds 0.0.0.0:<port> — reachable from any device on the same network, not exposed to the internet. Off-LAN access is your responsibility. Tailscale and ZeroTier are both effective zero-config options.
Auth. A mobile_token is generated on first pour serve run and written to ~/.pour/secrets.toml. All token comparisons are constant-time; the query-param bootstrap is only accepted on first contact. Rotate by deleting the key from secrets.toml — the next pour serve generates a new one and prints a new QR code.
Logs. pour serve emits structured, level-filtered logs to stderr. The default level is info. Control it with the POUR_LOG env var:
POUR_LOG=debug pour serve # verbose — all targets
POUR_LOG=pour=debug,tower_http=warn # fine-grainedWhat is logged at info: server startup (bind address, transport mode, vault path), every API request/response (method, URI, status, latency), auth outcomes (accepted_via_query, rejected), and submit results (module name, vault path, autocreate count). What is never logged: token values, request bodies, field values, or any user content.
Per-module opt-out. Add mobile_visible = false to any module section to hide it from the phone entirely. The PWA cannot see or submit to hidden modules.
[modules.secret]
mobile_visible = falsePhase 1 + Phase 2 shipped. Module list, form rendering for all field types, submit, history list (Phase 1, v0.3.0 initial). IndexedDB offline queue, service worker app-shell cache, sub-form overlay for create_template fields, preset mutation UI (save/edit/delete/reorder from PWA), 90-day history heatmap, bottom-tab navigation, and cursor-paginated history list (Phase 2, closed 2026-04-27). Full closeout report at pour - docs/06 reports/v1.0.0-phase2-closeout.md. Phase 3 scope (TLS, mDNS / pour.local) remains deferred.
| Area | Crate |
|---|---|
| TUI | ratatui + crossterm |
| HTTP server | axum + tower + tower-http + tokio |
| HTTP client | reqwest |
| Static assets | rust-embed — PWA shell embedded in the binary at compile time |
| Serialization | serde + toml + toml_edit + serde_json |
| Time | chrono |
| URL encoding | percent-encoding — encodes vault paths with spaces in REST API URLs |
| QR codes | qrcode — terminal QR code rendering for pour serve |
| LAN address | local-ip-address — discovers the LAN-routable address printed at startup |
| Identifiers | uuid — idempotency keys, capture IDs |
| Constant-time compare | subtle — bearer-token comparison |
| Logging | tracing + tracing-subscriber — structured server logs (POUR_LOG) |
| Unicode width | unicode-width — terminal cursor / column accounting |
| Filesystem paths | dirs — locates the home directory for ~/.pour/ |
| Errors | anyhow — error propagation in non-trivial Result chains |
| Shell open | open — opens notes in Obsidian via the o key on the summary screen |
cargo build
cargo test
cargo clippy
cargo fmt -- --checkTests live in tests/ mirroring src/ structure. Use POUR_CONFIG env var to point tests at a temporary config file.
Install just (cargo install just) and run just to list recipes. Two flows are supported:
Init from a tracked template — copies a resources file into ~/.pour/:
just pour # init from resources/default_config.toml (sanitized contributor template)
just mads # init from resources/mads_config.toml (a real-world working example)Live edits via symlink — bypass the pour init round-trip; edits to the source file show up in git diff directly:
just install # cargo install --path . --force — drops `pour` on $PATH
just link # ~/.pour/config.toml -> resources/mads_config.toml (default)
just unlink # remove the symlink (no-op if not a symlink)just link <path> accepts any tracked config as the target — just link resources/default_config.toml for the contributor template, or point at your own resources/<name>_config.toml.
resources/mads_config.toml is kept in version control as a real-world working example (the maintainer's personal config); resources/default_config.toml is the sanitized contributor template that ships with pour init.
Caution: while a symlink is in place, pour init (and just pour / just mads) writes through the link and overwrites the source file — just unlink first if you want to reset.
Windows: symlink creation requires Developer Mode enabled or an elevated terminal.
Full docs in pour - docs/ - design spec, field type reference, architecture overview, and release notes.
MIT - see LICENSE.
