Obr is a local-first web companion for an Obsidian vault. It gives you a fast browser interface for capturing daily notes, editing Markdown blocks, reading and searching notes, managing todos, and uploading images, while keeping your content as plain Markdown files in the vault you already use.
Obr runs on your own machine and can be opened locally or exposed to trusted devices through a stable HTTPS origin such as Tailscale Serve/Funnel. It is built for personal writing workflows where the browser is the quick input surface and Obsidian remains the durable knowledge base.
Install Rust stable, then clone and build:
cargo build --releaseFor the fastest setup, let Obr generate config/local.toml, prepare the vault
directory, start the daemon, and print the service URL:
./target/release/obr init --vault /path/to/obsidian/vaultTo also publish Obr through Tailscale Funnel:
./target/release/obr init --vault /path/to/obsidian/vault --tailscaleThe Tailscale flow starts a separate userspace tailscaled under
$HOME/.local/share/tailscale-obr, asks you to approve the Tailscale login URL
if needed, enables Funnel, writes the resulting *.ts.net hostname into
config/local.toml, starts Obr, and prints both the local and public URLs. Use
--hostname <name> to request a specific Tailscale node name; the actual
published hostname is read back from tailscale funnel status.
If config/local.toml already exists, obr init backs it up before writing the
new config.
For manual setup, create a local config:
cp config.example.toml config/local.tomlPoint vault_path at your Obsidian vault. You can either edit config/local.toml directly:
vault_path = "/path/to/obsidian/vault"or keep the default vault_path = "vault" and create a symlink:
ln -s /path/to/obsidian/vault vaultTo try Obr without touching a real vault, copy the demo vault:
cp -R examples/vault vaultBefore running Obr, generate a password hash and add it to config/local.toml
as shown in the Password section below.
Obr keeps vault-specific paths configurable so it can fit different Obsidian layouts:
# Daily memo files are created as <daily_dir>/<YYYY-MM-DD>.md.
daily_dir = "Daily"
# Named quick-entry pages are created under this directory, for example
# page = "project/foo" writes <entry_dir>/project/foo.md.
entry_dir = "Posts"
# Uploaded images are stored here and served through /images/* and /image-preview/*.
image_dir = "Pics"
# The Todo view and page = "todo" entries use this file.
todo_path = "Posts/todo.md"
# RSS detail annotations are saved here.
annotation_dir = "annotations"These vault layout paths are relative to vault_path. Parent path components such as .. are rejected.
Runtime cache data is separate from the vault and is written under the gitignored data directory in the process working directory.
Obr has a dark-mode toggle in the top-right toolbar. The button switches the current appearance and can return to automatic mode; the manual choice is stored in the browser.
To make automatic mode switch at a fixed local time window, configure both
values in config/local.toml:
dark_mode_start = "21:00"
dark_mode_end = "07:00"Times are interpreted by the browser using its system timezone. Overnight ranges are supported.
Obr can maintain a local RSS reading list. Enable it in config/local.toml:
rss_enabled = true
rss_feeds_path = "Zero/feeds.md"
rss_data_dir = "data/rss"
rss_refresh_minutes = 30
rss_max_items_per_feed = 20
rss_fetch_full_content = true
rss_ai_summary_enabled = true
rss_ai_full_translation_enabled = false
rss_ai_summary_chars = 200
# Optional: enables Chinese summaries for newly fetched non-Chinese posts.
deepseek_api_key = "sk-..."
deepseek_api_base = "https://api.deepseek.com"
deepseek_model = "deepseek-v4-flash"
rss_ai_translation_provider = "deepseek"
# tencent_secret_id = "AKID..."
# tencent_secret_key = "..."
tencent_translate_endpoint = "https://tmt.tencentcloudapi.com"
tencent_translate_region = "ap-guangzhou"
tencent_translate_source = "en"
tencent_translate_target = "zh"
tencent_translate_project_id = 0
tencent_translate_max_chars = 1800rss_feeds_path is relative to vault_path and should contain one RSS, Atom,
or JSON Feed URL per line. Blank lines and # comments are ignored, and
duplicate URLs are skipped.
RSS metadata and read/unread state are stored in data/rss/rss.sqlite. Article
Markdown is stored under data/rss/content/. When
rss_fetch_full_content = true, Obr fetches article pages and uses
rs-trafilatura to extract readable Markdown. If extraction fails, it falls
back to feed content or summary. Each refresh treats the feeds file as the
source of truth: removing a feed URL from the file removes that feed's stored
items and article Markdown on the next scan. The RSS detail page also has an
Unsubscribe action, which removes the feed URL from rss_feeds_path and prunes
that feed's cached items immediately.
If deepseek_api_key is configured and rss_ai_summary_enabled = true, newly
fetched non-Chinese posts are sent to the configured OpenAI-compatible chat API
for an automatic Chinese summary. rss_ai_summary_chars is a soft target for
the prompt, not a hard server-side truncation limit; the default asks for about
200 Chinese characters and allows the model to stay natural. Existing items are
not summarized again during ordinary refreshes. Full-text translation is off by
default to avoid surprise API cost. Set rss_ai_full_translation_enabled = true
to also request and store full Chinese translations during RSS refresh.
rss_ai_translation_provider controls full-text translation. deepseek reuses
the configured chat API and stores the model's bilingual Markdown. tencent
uses Tencent Cloud Machine Translation and requires tencent_secret_id plus
tencent_secret_key in your private config/local.toml; config.example.toml
should keep only placeholders. Tencent translation stores the same bilingual
Markdown shape as the DeepSeek path: each original block followed by a quoted
Chinese translation. The RSS detail page's manual Translate action is available
when the selected translation provider is configured.
RSS detail annotations are saved as Markdown under annotation_dir, which
defaults to annotations. Each RSS post gets one annotation file and additional
notes for the same post are appended to that file.
Obr is designed as a local-first personal app. It can be exposed to a phone or a remote browser, but vault content, uploaded images, page drafts, cached pages, passkeys, logs, and sync outbox data should all be treated as sensitive local data.
Keep these paths out of Git history:
config/local.toml
vault
data
logs
cache
When Obr is reachable outside the local machine, serve it through HTTPS, set
secure_cookies = true, and configure a stable webauthn_rp_id. Obr derives
the WebAuthn origin as https://<webauthn_rp_id> unless webauthn_origin is
set explicitly. Obr validates request Host headers and rejects browser
cross-site write requests with untrusted Origin or Sec-Fetch-Site headers.
Avoid exposing Obr directly to the public internet without an additional
trusted access-control layer.
Generate an Argon2 password hash:
./target/release/obr hash-passwordThe command prompts for the password twice without echoing it, then prints a line you can put in config/local.toml:
username = "admin"
password_hash = "$argon2id$..."
allow_plaintext_password = falseFor scripts, stdin still works:
printf '%s' "$OBR_PASSWORD" | ./target/release/obr hash-passwordPlaintext passwords are disabled by default. Only enable allow_plaintext_password = true for throwaway local development.
For local development:
cargo runFor the release binary:
./target/release/obr runThe release binary embeds the web UI assets (index.html, JavaScript, CSS, service worker, manifest, and favicon). Deploying Obr does not require copying the repo assets/ directory.
Check a deployment before opening it in a browser:
./target/release/obr doctorobr check is an alias. The doctor command validates config, vault access,
WebAuthn origin/RP ID settings, writable runtime data, logs, passkey storage,
and image directories.
Open:
http://localhost:8010/
For local passkey testing, use http://localhost:8010, not http://127.0.0.1:8010, because the default WebAuthn origin uses localhost.
Run from the repo root so relative paths in config/local.toml resolve correctly:
./target/release/obr daemon startobr daemon is an alias for obr daemon start.
Logs are written to the configured path:
log_path = "logs/obr.log"Manage the background process with:
./target/release/obr daemon status
./target/release/obr daemon reload
./target/release/obr daemon stopDaemon mode writes its pid file under the gitignored data directory.
For local testing, the default passkey settings are enough:
listen = "127.0.0.1:8010"For phone or remote browser use, configure a stable HTTPS origin:
secure_cookies = true
webauthn_rp_id = "obr.example.com"webauthn_origin defaults to https://<webauthn_rp_id>. Set it explicitly
only when the browser origin differs from that default.
Changing webauthn_rp_id or the effective WebAuthn origin invalidates existing
passkeys for that domain. Register a new passkey after changing the public
domain.
Once a passkey is registered, password login is disabled outside localhost. Localhost password login remains available as a recovery path.
Tailscale Funnel exposes a local Obr server through a public HTTPS hostname
under your tailnet domain, such as <hostname>.<tailnet>.ts.net.
First, please install tailscale. The init/manual flow will ask you to log in to your tailnet if this userspace instance has not been authorized yet.
The init command can run this whole flow for you:
./target/release/obr init --vault /path/to/obsidian/vault --tailscaleThe examples below use a separate userspace tailscaled instance instead of the
system Tailscale daemon. That keeps Obr's public route isolated in its own state
directory and socket.
HOST=ob
BASE="$HOME/.local/share/tailscale-obr"
SOCK="$BASE/tailscaled.sock"Start the separate tailscaled in the background:
mkdir -p "$BASE"
nohup tailscaled \
--tun=userspace-networking \
--socket="$SOCK" \
--statedir="$BASE" \
> "$BASE/tailscaled.log" 2>&1 &
echo $! > "$BASE/tailscaled.pid"Log this instance into your tailnet and choose the *.ts.net hostname:
tailscale --socket="$SOCK" up --hostname="$HOST" --accept-dns=falseIf this is the first login for this state directory, Tailscale prints an authorization URL. Open it and approve the new node.
With Obr listening on 127.0.0.1:8010, publish it through Funnel:
tailscale --socket="$SOCK" funnel --yes --bg http://127.0.0.1:8010Check the public route:
tailscale --socket="$SOCK" funnel statusThen set the WebAuthn config to the Funnel hostname:
secure_cookies = true
webauthn_rp_id = "<hostname>.<tailnet>.ts.net"Replace <hostname>.<tailnet>.ts.net with the HTTPS hostname from
funnel status. For example, if HOST=ob, the public origin will look like
https://ob.<tailnet>.ts.net.
The --socket flag is important. Without it, the tailscale CLI tries the
system daemon socket, usually /var/run/tailscaled.socket, and will not talk to
the separate Obr tailscaled instance above.
Stop the public Funnel route without stopping tailscaled:
tailscale --socket="$SOCK" funnel resetStop the separate tailscaled process:
kill "$(cat "$BASE/tailscaled.pid")"tailscaled.state is internal state maintained by tailscaled. Do not edit it
by hand; change Funnel and Serve routes with the tailscale --socket=...
commands.
Obr is licensed under the MIT License.