User Guide
From first launch to full setup.
Setup
Getting started
Install
Download Chestnut.dmg, open it, and drag Chestnut into Applications. Or use Homebrew:
brew trust --cask gapmiss/tap/chestnut # required for third-party taps
brew install --cask gapmiss/tap/chestnut
First launch (Gatekeeper)
Chestnut is free and open source but not notarized, so macOS will block the first launch. Pick whichever unblock method you prefer:
- macOS 15+: open the app once (macOS will decline), then System Settings → Privacy & Security → Open Anyway.
- macOS 14: right-click the app → Open → Open.
-
Terminal:
xattr -dr com.apple.quarantine /Applications/Chestnut.app
Accessibility permission
Global hotkeys need the Accessibility permission. macOS usually prompts on first use. If the hotkeys don't work, check System Settings → Privacy & Security → Accessibility and make sure Chestnut is listed and enabled.
Core
Features
Vault Hopper ⌃⌥V
Opens a palette listing every vault Obsidian knows about. Type to filter, arrow keys to move, ⏎ to open.
- ⌘⏎ opens today's daily note in the selected vault.
- ⌥⏎ reveals the vault in Finder.
- ⌘P pins the selected vault as your home vault (or unpins it). The pinned vault sorts first everywhere.
The palette also doubles as the courier's destination picker. When you drop a file onto Chestnut, the same list opens with the source vault excluded.
Quick Capture ⌃⌥Space
A floating panel for jotting markdown. Pick a target vault, type your thought, hit ⌘⏎ and it's appended to today's daily note (or an inbox file if daily notes aren't configured).
- Markdown toolbar: bold, italic, strikethrough, inline code, code block, heading, task, link. Keyboard shortcuts ⌘B, ⌘I, ⌘K work too.
- Drafts survive dismissing and reopening the panel.
- ⌘1–⌘9 picks a vault by position.
See Capture settings for inbox path, date format, and folder options.
Note Courier
Drag a .md file (from Finder or another app) onto Chestnut
to move or copy it into another vault. A destination picker appears;
choose a vault and Chestnut delivers the note.
- Embedded attachments (
![[...]],![]()) are resolved and carried along. References are rewritten to match the destination layout. - Name conflicts get Obsidian-style suffixes (
Note 1.md). Nothing is ever overwritten. - Every delivery is journaled. Undo from the right-click menu.
- Hold ⌥ during the drop to flip between move and copy (the default is set in the menu).
A single non-markdown file or folder can go to a plugin instead, but never without asking: when a plugin matches, Chestnut opens a picker listing it alongside Deliver to a vault. With no matching plugin, the item goes straight to the courier. Drop two or more items at once and the whole drop is a delivery, plugins included.
Drag folders from Finder. Obsidian's file explorer hands over a folder as its name alone, with no path and no vault, so there is nothing for Chestnut to find and deliver. Chestnut says as much rather than acting on a guess. Notes and attachments dragged straight from Obsidian work normally, several at a time included.
Extend
Plugins
Overview
Plugins are shell scripts that teach Chestnut new tricks. They live in
~/.config/chestnut/plugins/<name>/, each containing
a manifest.json and one or more scripts. Chestnut feeds
them input (a URL, a file path, clipboard text) and they produce
output (a note to save, text to capture, a notification).
Directory structure
~/.config/chestnut/plugins/
bookmark/
manifest.json
save-bookmark.sh
ocr/
manifest.json
ocr.py
manifest.json reference
| Field | Type | Description |
|---|---|---|
api | number | Always 1. |
name | string | Display name (shown in menus and the picker). |
description | string | Short description (shown under the name in the Plugins menu). |
script | string | Path to the script, relative to the plugin folder. Must be executable (shebang + chmod +x). |
accepts | string[] | Input types this plugin handles. See input types. |
extensions | string[] | Optional. Narrows file matching to specific extensions (e.g. ["csv", "txt"]). Omit to accept all files. |
output | string | Output mode. See output modes. |
timeout | number | Optional. Max seconds before the script is killed. Default: 10, clamped to 1 to 14400 (4 hours). |
stream | boolean | Optional. Makes each line of stdout one complete JSON envelope, so a slow plugin can report progress while it runs. Needs "output": "structured". Default: false. See long-running plugins. |
Input types
| Type | When it matches |
|---|---|
text | Plain text on the clipboard. |
url | A URL on the clipboard or dropped as a web location. |
image | An image on the clipboard or an image file drop. A pasted image is written to a temp file first, as PNG when the clipboard offers one. |
file | A non-markdown file drop. Narrow with extensions. |
pdf | A PDF file drop, matched on the .pdf extension. |
folder | A folder drop. |
any | Wildcard: matches every input type above. Still narrowable with extensions. |
Output modes
| Mode | What happens |
|---|---|
capture | Pre-fills the Quick Capture panel with the script's stdout. |
save | Writes stdout directly to the active vault. First line = filename, rest = content. Every save is journaled, so Undo Last Plugin Save can take it back. |
clipboard | Copies stdout to the system clipboard. |
notify | Shows stdout in a speech bubble. |
structured | Expects a JSON envelope on stdout for runtime control. |
Environment variables
These are set before your script runs:
| Variable | Value |
|---|---|
CHESTNUT_INPUT_TYPE | The matched input type (text, url, etc.). |
CHESTNUT_FILE_PATH | Absolute path to the dropped file (empty for clipboard input). |
CHESTNUT_SOURCE_APP | Bundle ID of the app that owns the clipboard (when available). |
CHESTNUT_TIMESTAMP | ISO 8601 timestamp. |
CHESTNUT_PLUGIN_DIR | Absolute path to this plugin's folder. |
HOME | User's home directory. |
PATH | Includes /opt/homebrew/bin and /usr/local/bin. |
Stdin
For text and url input types, the clipboard
content is piped to the script's stdin. For other types, stdin is
/dev/null; use CHESTNUT_FILE_PATH instead.
Structured JSON envelope
When a manifest sets "output": "structured", the script
must print a JSON object to stdout:
{
"action": "save",
"content": "# My Note\n\nBody text here.",
"filename": "My Note.md",
"vault": "ask",
"folder": "imported",
"notify": "Saved to Work",
"attachments": [
{ "source": "/tmp/image.png", "filename": "image.png" }
]
}
| Field | Required | Description |
|---|---|---|
action | yes | One of capture, save, clipboard, notify. |
content | no | The note body or text to output. |
filename | no | Filename for save. Defaults to Untitled.md, so it is worth setting. Unlike the plain save output mode, the envelope's first line is not used as a title, and the content is not split. |
vault | no | Where to save. One of "pinned", "last" (the vault you captured to most recently), "ask" to show the vault picker, or the absolute path of a vault. A vault name will not match: Chestnut keys vaults by path, because names collide. Omitting the field behaves like "ask". |
folder | no | Subfolder within the vault. |
notify | no | Speech bubble text shown after the action completes. |
attachments | no | Array of { source, filename } objects. Each source is an absolute path to a file. Copies go into the vault's attachment folder, meaning the attachmentFolderPath from that vault's Obsidian settings, or the vault root when it isn't set. Note that folder moves the note but not its attachments. With capture, a file is copied only if the submitted note refers to it by filename, so write a ![[filename]] link into content. |
Dispatch flow
- Chestnut classifies the clipboard content (for ⌃⌥C) or the dropped item.
- It finds all enabled plugins whose
acceptsandextensionsmatch. - For a drop, the courier could always take the item too, so a picker opens listing every matching plugin plus Deliver to a vault. The first plugin starts selected, so ⏎ runs it; ↓ reaches the courier.
- For ⌃⌥C there is no file to deliver, so a single match just runs and several matches show a plugin-only picker.
- If none match, the item falls through to the courier (for drops) or nothing happens (for paste).
Drag-and-drop routing
A plugin never takes a dropped file away from the courier. When both could handle it, Chestnut asks.
.mdfiles always go to the courier, with no picker, whatever plugins are installed.- A single non-
.mdfile with a matching plugin opens the picker. With no matching plugin it goes straight to the courier. - A single folder opens the picker if a
folderplugin is enabled. Otherwise the courier copies or moves it as-is. - Two or more items dropped together are a courier delivery, all of them, even when a plugin would match one. Drop an item on its own to reach a plugin.
- Notes and attachments dragged out of Obsidian follow the same rules as files from Finder.
- A folder dragged from Obsidian's file explorer arrives as a bare name, with no path, so Chestnut explains the limitation instead of routing it anywhere. Drag folders from Finder.
Long-running plugins
Some plugins take minutes or hours: transcribing a recording, OCR-ing a
long scan, converting a folder of documents. Set timeout to
cover the worst case, up to 4 hours, and the run stays supervised the
whole time.
- Chestnut chews until the last run finishes, so two plugins at once behave the way you would expect.
- Right-click → Running Plugins lists every live run. Click one to stop it: the script and anything it started are sent SIGTERM, then SIGKILL a second later.
- Quitting Chestnut from the menu stops running plugins too. This is best-effort, so a force quit or a crash can leave a script running.
- A plugin that finishes more than a minute after it started never
steals focus. A
capturethat arrives that late parks its draft behind a notice you click to open.
To report progress, set "stream": true alongside
"output": "structured" and print one complete JSON envelope
per line. Each notify line updates the speech bubble as it
arrives.
for i in $(seq 50); do
process_one "$i"
printf '{"action":"notify","notify":"%d of 50"}\n' "$i"
done
printf '{"action":"save","filename":"result.md","content":"..."}\n'
Only notify happens while the plugin runs. Everything that
writes something - save, clipboard,
capture - waits for a clean exit, so a plugin that fails
still writes nothing. Print one non-notify envelope; if you
print several, the last one wins. A line that is not valid JSON is
skipped and the run carries on, and each line is capped at 64 KB.
Without stream, nothing changes: the whole of stdout is one
envelope, pretty-printed or not. That is why it is opt-in.
Hot-reload
Chestnut watches ~/.config/chestnut/plugins/ with FSEvents.
Adding, editing, or removing a manifest takes effect immediately. No
restart needed.
Enable/disable
Right-click Chestnut → Plugins. Each installed plugin is listed
with a checkmark. Click to toggle. Disabled plugins are persisted in
state.json as disabledPlugins.
Example: a bookmark saver
{
"api": 1,
"name": "Bookmark",
"description": "Save a URL as a markdown note",
"script": "./save-bookmark.sh",
"accepts": ["url"],
"output": "save"
}
#!/bin/bash
title="$CHESTNUT_TITLE"
url=$(cat)
echo "${title:-Untitled}.md"
echo "# ${title:-Untitled}"
echo ""
echo "[${url}](${url})"
echo ""
echo "Saved on $(date +%Y-%m-%d)."
Copy the URL of a page, press ⌃⌥C, and a note is saved to your vault.
Settings
Configuration
Chestnut keeps two files in
~/Library/Application Support/Chestnut/.
config.json is yours: hotkeys, custom sprite themes,
capture destination. Chestnut never writes to it, apart from creating
it on first run. Hand-editable; changes take effect on next launch.
Right-click the pet and pick
Settings → Edit Configuration… to open it.
state.json is Chestnut's: window position, size, opacity,
theme, notice duration, reduce motion, pinned vault, disabled plugins,
last capture vault. Everything
in it has a control in the right-click menu, so there's no reason to
edit it by hand. Chestnut rewrites it every time you move the window.
Keeping them apart means editing config.json while
Chestnut is running is safe. Nothing the app does can overwrite it.
Upgrading: an upgrade is just a new .app.
Neither file is touched, so nothing resets. If a release ever moves a
setting from one file to the other, that one value goes back to its
default and the release notes say so; it's always something you can
re-pick from the right-click menu. Keys Chestnut no longer reads are
ignored rather than removed, so nothing you edited by hand is ever
rewritten or thrown away. You can delete a stale key yourself, or
leave it.
If either file can't be parsed, Chestnut moves it aside and starts
with defaults. Existing backups are never overwritten: a second
failure becomes config.json.bak.1, and so on, so you can
always recover your settings.
Hotkeys
{
"hotkeys": {
"capture": "control+option+space",
"hopper": "control+option+v",
"paste": "control+option+c",
"notice": "control+option+o",
"menu": "control+option+m"
}
}
menu opens the right-click menu at Chestnut without a
right-click, so Size, Theme, Settings, Plugins, both Undo items and Quit
are all reachable from the keyboard. Arrow keys move through the menu and
Return picks an item. Chestnut has no Dock icon and no menu bar item, and
an app like that isn't listed in Force Quit either, so this is the only
way to reach those items, including Quit, without landing a click on the
sprite itself.
Keys: a–z, 0–9,
space, tab, return,
escape, delete,
f1–f12.
Modifiers: control/ctrl,
option/alt,
command/cmd, shift.
Every binding needs at least one of control,
option or command. shift can be
added to those but doesn't count on its own, since a global hotkey takes
the keystroke away from every other app: a binding of "space"
would leave you without a space bar. Bindings without a modifier are
ignored, and the reason is written to the log.
Set a binding to "" or "none" to disable it.
Using VoiceOver? Control-Option is VoiceOver's own
modifier, so it takes every default binding above before Chestnut sees
it, menu included. Swap option for
shift in all five and relaunch:
"control+shift+space", "control+shift+v",
"control+shift+c", "control+shift+o",
"control+shift+m".
Custom themes
Four built-in themes: Obsidian Night (default), Classic Wood, Brushed Steel, and Sunbleached. Add your own:
{
"customThemes": [
{
"id": "dracula",
"title": "Dracula",
"palette": {
"s": "#44475A",
"S": "#6272A4",
"d": "#282A36",
"m": "#BD93F9",
"o": "#191A21"
}
}
]
}
| Role | Key | Description |
|---|---|---|
| Shell | s | Main body color. |
| Highlight | S | Rivets, raised edges. |
| Shadow | d | Recessed areas. |
| Trim | m | Metal fittings. |
| Outline | o | Border pixels. |
Optional roles: p/P (gem/gem glint),
k (mouth), t (tongue), e (eye
white), b (pupil), z (sleep pixels). These
have shared defaults and can be overridden per-theme.
For single-color tweaks without a whole theme, petPalette
overrides individual roles on top of the active theme:
{
"petTheme": "classic-wood",
"petPalette": { "m": "#C0C0C0" }
}
Capture settings
Captures land on a note in the selected vault. The target is resolved in order:
- Obsidian daily note, if the daily-notes core plugin is enabled. Uses the vault's configured format and folder.
- Chestnut daily note, if you set
captureFormat. Same Moment.js token subset:YYYY,YY,MM,M,DD,D,[literal]. - Static inbox (
Inbox.mdat the vault root by default).
{
"captureFormat": "YYYY-MM-DD",
"captureFolder": "captures",
"captureInboxName": "Inbox.md"
}
Notice duration
How long the speech bubble stays visible. Set it from the right-click
menu → Settings → Notice Bubble, which lists 3, 5, 10, 20 and 30 seconds
with the current one checked. The next bubble uses it: no relaunch.
It's stored in state.json.
{
"noticeDuration": 10
}
Disabled plugins
Plugins toggled off in the right-click menu are stored in
state.json, not config.json. Use the menu
rather than editing the file: it takes effect immediately, and it's
the only place the two can't disagree.
{
"disabledPlugins": ["ocr", "code-snippet"]
}
Shortcuts
Hotkey reference
| Shortcut | Action | Notes |
|---|---|---|
| ⌃⌥Space | Quick Capture | Toggle the capture panel. |
| ⌃⌥V | Vault Hopper | Toggle the vault palette. |
| ⌃⌥C | Paste | Classify the clipboard and dispatch to a matching plugin. |
| ⌃⌥O | Notice action | Follow through on the active speech bubble (e.g. open the note in Obsidian). Only registered while a bubble is visible. |
| ⌃⌥M | Right-click menu | Opens the menu at Chestnut, with arrow keys and ⏎. The only route to Size, Theme, Settings, Plugins, Undo and Quit that doesn't need a mouse. |
All four are configurable in config.json.
Help
Troubleshooting
Gatekeeper blocks the app
See Getting started. The quickest fix is
the xattr command.
The block comes back after an update and a restart
Updating Chestnut does not ask you to allow it again. The next time you restart your Mac, though, macOS may block it once, with a dialog titled "Chestnut.app" Not Opened offering Done and Move to Trash. If you go weeks between restarts, the update and the block can be far enough apart to look unrelated.
Click Done, then go to System Settings → Privacy &
Security and click Open Anyway again, exactly as you did the
first time. Do not click Move to Trash, which deletes
the app. The xattr command above also clears it. Restarting
again, with no update in between, does not ask.
Hotkeys don't work
Chestnut needs the Accessibility permission. Check System Settings → Privacy & Security → Accessibility. If Chestnut is listed but greyed out, remove it and re-add it (toggle the switch off then on). A restart of the app may be needed after granting the permission.
If VoiceOver is running, that is the more likely cause. Control-Option is
VoiceOver's own modifier, the "VO key", so VoiceOver claims all five of
Chestnut's default shortcuts before Chestnut ever sees them:
Control-Option-V is speech verbosity, Control-Option-M is the menu bar.
Rebind them to a Control-Shift set in config.json (see
Hotkeys) and relaunch. Right-clicking
Chestnut still opens the menu meanwhile, and the menu, the capture panel
and the vault palettes all read correctly with VoiceOver.
A file undo took back isn't in the Trash
Look in the vault's own .trash folder. Obsidian's
Settings → Files and links → Deleted files
decides where deletions from a vault go, and Chestnut follows whatever
that vault is set to. If it says Move to Obsidian trash, the
files undo takes back land in .trash at the vault root,
where Obsidian's own deleted-files list can see and restore them. If it
says Move to system trash, or you have never changed it, they go
to the macOS Trash as before. The setting is per vault, so two vaults can
behave differently.
The third option, Permanently delete, is the one exception: Chestnut still sends those files to the macOS Trash. An undo takes back something Chestnut did rather than something you chose to delete, and a permanent one would leave nothing to recover if you changed your mind a moment later.
An undo says it failed
Undo refuses when the file it needs is no longer where it was left, most often because the note was renamed, moved or edited in Obsidian since. The alert offers Keep and Discard Entry. Keep leaves the record in place, which means it comes up again on the next click and nothing older can be reached until it's dealt with. Discard Entry drops the record and touches no files: use it once you've decided you don't need that one reversed.
A delivery of several notes can also come back partly done. Undo tries every file rather than stopping at the first failure, so the ones it could reach are home and the alert names the ones that stayed put. That record is spent; a second try can only re-fail on the files already returned.
obsidian CLI not found
Chestnut looks for the obsidian CLI in
/opt/homebrew/bin and /usr/local/bin only.
It does not search $PATH. If you installed the CLI
elsewhere, symlink it into one of those directories. The CLI is
optional: every feature has a direct-filesystem fallback.
Corrupt config
If config.json can't be parsed, Chestnut renames it to
config.json.bak and loads defaults. Your old settings are
preserved in the backup, untouched. If a backup already exists it is
kept, and the new one becomes config.json.bak.1. Fix the
JSON syntax and rename it back.
File locations:
~/Library/Application Support/Chestnut/config.json (yours)
~/Library/Application Support/Chestnut/state.json (Chestnut's)