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 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 routes to a matching plugin first. If no plugin matches, the courier handles it as a plain file copy. 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 300. |
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. |
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. - If one matches, it runs. If several match, a picker lets you choose.
- If none match, the item falls through to the courier (for drops) or nothing happens (for paste).
Drag-and-drop routing
.mdfiles always go to the courier.- A single non-
.mdfile goes to a matching plugin, or falls through to the courier. - A single folder goes to a
folderplugin if one exists, otherwise the courier copies/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 route it to a plugin.
- 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.
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.
Whether a later brew upgrade asks again varies by macOS
version. If it does, the same fix applies.
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.
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)