Docs / The CLI / Frontmatter and tags
Frontmatter and tags
How the Jotura CLI reads and writes YAML frontmatter and tags, what its narrow YAML editor cannot change, and the exit codes each command returns.
Frontmatter is the YAML block at the top of a Markdown note, fenced by --- lines. Jotura stores it as ordinary text inside the .md file, so any tool can read it. The CLI gives you six commands for working with that block: frontmatter get, frontmatter set, frontmatter delete, tag list, tag add, and tag remove.
These commands are deliberately narrow. They handle top-level key: value pairs and the tags: sequence, and they leave everything else in the block untouched. For nested YAML you need the desktop app or a direct file edit. The limits are spelled out under What the frontmatter editor can and cannot change.
Every example on this page was run against a note at notes/meeting.md that starts out like this:
---
title: Hello
tags: [a, b]
due: 2026-09-01
---
# Meeting
Body text.
The six commands
| Command | What it does | Output |
|---|---|---|
jotura frontmatter get <PATH> [KEY] | Print the whole block, or one key’s value. | Respects --json. |
jotura frontmatter set <PATH> <KEY> <VALUE> | Set or add a top-level key. | Always JSON. |
jotura frontmatter delete <PATH> <KEY> | Remove a top-level key’s line. | Always JSON. |
jotura tag list <PATH> | Print the note’s tags. | Respects --json. |
jotura tag add <PATH> <TAG> | Add a tag to the tags: array. | Always JSON. |
jotura tag remove <PATH> <TAG> | Remove a tag from the tags: array. | Always JSON. |
The four writing commands emit JSON on a real run whether or not you pass --json, because every mutating command in the CLI already speaks JSON. There is one exception: with --dry-run they do branch on the flag, printing an indented text preview without it and the structured envelope with it. The two reading commands print plain text by default and JSON when you pass the flag. Errors are always a single-line JSON object on stderr regardless. See Exit codes and JSON output for the full envelope.
Four of the five global flags are useful here, at any position in the command line.
| Global flag | Effect on these commands |
|---|---|
--vault <VAULT> | Selects the vault for this invocation, ahead of $JOTURA_VAULT and the desktop app’s last-opened vault. |
--json | Switches frontmatter get and tag list to structured output, and switches the four writing commands from the text dry-run preview to the JSON one. It changes nothing on a real write, which is JSON either way. |
-v, --verbose | Logs the resolved vault path to stderr. |
-h, --help | Prints usage. jotura frontmatter --help and jotura tag --help carry a one-line description, but the six leaf subcommands have none, so this page is the reference for what they do. |
The fifth global flag, -V / --version, is accepted at the top level only.
Reading frontmatter
jotura frontmatter get takes a path and an optional key.
| Argument | Required | What it does |
|---|---|---|
<PATH> | Yes | Vault-relative path to the note. |
[KEY] | No | Print only this key’s value. Omit it to print the whole YAML block. |
Print the whole block:
jotura frontmatter get notes/meeting.md
---
title: Hello
tags: [a, b]
due: 2026-09-01
---
In JSON mode the whole-block form returns the raw text of the block, fences included, or null when the note has no frontmatter:
jotura frontmatter get notes/meeting.md --json
{
"frontmatter": "---\ntitle: Hello\ntags: [a, b]\ndue: 2026-09-01\n---\n",
"path": "notes/meeting.md"
}
The single-key form returns the raw value text, or null when the key is absent:
jotura frontmatter get notes/meeting.md title --json
{
"key": "title",
"path": "notes/meeting.md",
"value": "Hello"
}
Text mode prints nothing when the key or the block is missing, and prints a bare newline for a key whose value is empty. Those two outcomes look the same on screen, so use --json whenever a script needs to tell them apart.
A key whose value is a block sequence is reported in inline form, so tags: followed by - one and - two reads back as [one, two]. Quotes on the items are left in place here, which is where this command and tag list disagree: on a block sequence written as - "a", frontmatter get reports ["a"] and tag list reports a. A key that heads a nested map returns an empty string, because the indented lines beneath it are not parsed.
Unlike the writing commands, frontmatter get does not check the file extension. It reads whatever file you point it at and parses any leading --- block the bytes happen to contain, so a report.txt that opens with a YAML block reports that block. This is not the same rule as jotura read --json, whose frontmatter field is always null for a file that is not .md.
Reading tags
jotura tag list takes a single path and no flags of its own.
jotura tag list notes/meeting.md
a
b
Text mode prints one tag per line. JSON mode returns a bare array of strings:
jotura tag list notes/meeting.md --json
[
"a",
"b"
]
Both inline sequences (tags: [a, b]) and block sequences (tags: followed by indented - a lines) are read correctly. Surrounding single or double quotes on each item are stripped, and empty items are dropped. A tags: key holding a plain scalar such as tags: work is not a sequence, so the list comes back empty.
Splitting is done on every comma with no quote awareness, so a quoted item that contains a comma is read as two tags: tags: ["a, b"] lists as a and b. Keep commas out of tag names.
Like frontmatter get, tag list does not check the extension, so it reads the tags: key out of any file whose bytes start with a YAML block.
Setting a key
jotura frontmatter set writes a top-level key.
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<PATH> | string | required | Vault-relative path to the note. A path that is not .md exits 9. |
<KEY> | string | required | The key to write. An existing key is replaced in place and keeps its position. A new key is appended just before the closing ---. |
<VALUE> | string | required | The value, parsed as JSON with a plain-string fallback. See the table below. |
--dry-run | bool | false | Compute the change and print the preview without writing anything. |
--diff | bool | false | Include a unified diff in the output. Works with and without --dry-run. |
jotura frontmatter set notes/meeting.md status draft
{
"hash": "a9e540d3dfe2dfcea55ef89c8546268c",
"key": "status",
"path": "notes/meeting.md"
}
If the note has no frontmatter block, set creates one. The new block is prepended to the existing body, so a file reading just body becomes a --- fence, the new key, a closing fence, and then the original text unchanged.
set is the one command in this family with no unchanged-content short circuit. Setting a key to the value it already holds still performs a real write, which touches the file’s modification time and enqueues a sync operation, and its dry run still reports changes: 1 even when the current and new hashes are identical. The other three commands skip the write when nothing would change.
How values are parsed
The value argument is parsed as JSON first. If that succeeds, the parsed type decides what gets written. If it fails, the raw text is treated as a plain string.
| You type | Written to the file | Why |
|---|---|---|
draft | status: draft | Not valid JSON, so it is a plain string that needs no quoting. |
3 | count: 3 | Valid JSON number, written as a number rather than a string. |
true | pinned: true | Valid JSON boolean. |
null | due: null | Valid JSON null. |
["a","b"] | aliases: [a, b] | Valid JSON array, rendered as an inline flow sequence. |
"Notes: part one" | title: "Notes: part one" | The JSON string is unwrapped, then re-quoted because it contains a colon. |
Quoting is applied only when the value contains a colon or a hash, or begins with a hyphen or an opening bracket. Any double quotes inside a quoted value are escaped.
That quoting check runs on a top-level scalar only. Array items are written verbatim, so an item that contains a colon, a comma, or a bracket comes out unquoted and no longer means what you typed:
jotura frontmatter set notes/meeting.md aliases '["Notes: one","b"]'
That writes aliases: [Notes: one, b], which a YAML parser reads as a map inside a sequence rather than as the two strings you supplied. tag add has the same hole, since it renders the tags: list the same way. For any list item that needs quoting, edit the file directly or use the desktop app.
Key names that are rejected
A key is valid when it is non-empty, contains no colon, contains no whitespace anywhere, contains no newline, and does not begin with a hyphen or a hash. Anything else exits 9 with the message invalid key: <key>:
jotura frontmatter set notes/meeting.md "bad key" X
{"code":"InvalidArgs","message":"invalid key: bad key"}
The same validation runs for tag add and tag remove, which always write the key tags, so it never trips there. frontmatter delete does not validate the key at all: an unusable key name simply matches nothing and reports "deleted": false.
Deleting a key
jotura frontmatter delete removes one top-level key’s line.
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<PATH> | string | required | Vault-relative path to the note. A path that is not .md exits 9. |
<KEY> | string | required | The key to remove. |
--dry-run | bool | false | Compute the change, write nothing. |
--diff | bool | false | Include a unified diff. The diff is an empty string when nothing changed. |
jotura frontmatter delete notes/meeting.md due --diff
{
"deleted": true,
"diff": "--- a/notes/meeting.md\n+++ b/notes/meeting.md\n@@ -1,7 +1,6 @@\n ---\n title: Hello\n tags: [a, b]\n-due: 2026-09-01\n ---\n # Meeting\n \n",
"hash": "224e34e4bff7a1feee430b19b662ee1a",
"key": "due",
"path": "notes/meeting.md"
}
Deleting a key that is not present is a success, not an error. The output carries "deleted": false, the hash is unchanged, and nothing is written to disk:
{
"deleted": false,
"hash": "224e34e4bff7a1feee430b19b662ee1a",
"key": "nope",
"path": "notes/meeting.md"
}
The same is true for a note that has no frontmatter block at all. Branch on the deleted field rather than on the exit code when you need to know whether anything actually changed.
Only the key’s own line is removed. Anything indented beneath it stays, which breaks the block when the key owned continuation lines. Deleting tags from a note whose tags are a block sequence leaves this:
---
- one
- two
---
The orphaned items are now invalid YAML at the top of the note. The same applies to a key that heads a nested map: delete the parent and the children remain, indented under nothing. Use frontmatter delete on single-line keys, and edit multi-line keys in the desktop app or by hand.
Adding and removing tags
jotura tag add and jotura tag remove operate on the tags: key in the note’s frontmatter. They take the same arguments.
| Argument or flag | Type | Default | What it does |
|---|---|---|---|
<PATH> | string | required | Vault-relative path to the note. A path that is not .md exits 9. |
<TAG> | string | required | The tag to add or remove. Matching is exact and case-sensitive. |
--dry-run | bool | false | Compute the change, write nothing. |
--diff | bool | false | Include a unified diff. Empty string when nothing changed. |
jotura tag add notes/meeting.md focus
{
"added": true,
"hash": "32a7496556b6bea8e3f300f10874b267",
"path": "notes/meeting.md",
"tag": "focus"
}
Running the same command again changes nothing and says so:
{
"added": false,
"hash": "32a7496556b6bea8e3f300f10874b267",
"path": "notes/meeting.md",
"tag": "focus"
}
Removing a tag the note does not have behaves the same way, returning "removed": false with the hash unchanged and no write. jotura tag remove notes/meeting.md a is the mirror of the add above.
The rewritten tags: value is always an inline flow sequence, whatever shape it had before. A tag containing a comma is joined into that sequence unescaped, so adding a, b as one tag writes tags: [a, b] and tag list then reads it back as two tags.
Removing the last remaining tag leaves tags: [] rather than deleting the key. On a note whose tags were already inline you can follow up with jotura frontmatter delete <PATH> tags to drop the key entirely. Do not do that on a note that still has block-sequence item lines beneath the key, for the reason described under Deleting a key.
Four cases where tag writes surprise people
A note with no frontmatter block at all gains one. Both commands end by setting the tags: key, and setting a key on a note that has no block synthesizes a complete one. So jotura tag remove plain.md anything on a note that never had frontmatter writes this and reports "removed": true:
---
tags: []
---
An agent sweeping a vault with an unconditional remove therefore mutates every note it touches. Check with jotura tag list first.
A note whose frontmatter has no tags: key gains one on tag remove, for the same reason: the command rewrites the key from the parsed list, so removing a tag that was never there writes tags: [] and reports "removed": true.
A note whose tags: value is a plain scalar such as tags: work reads as an empty list, so tag add overwrites the scalar. Running jotura tag add scalar.md x on tags: work leaves tags: [x] and the original value is gone. Convert the scalar to a sequence yourself before using the tag commands on that note.
A note whose tags: value is a block sequence is not fully converted. The key line is rewritten to the inline form, but the indented - item lines beneath it are left in place. Adding three to a note whose tags were - one and - two produces this:
---
tags: [one, two, three]
- one
- two
---
A YAML parser now reads something different from what tag list reports. jotura frontmatter set notes/meeting.md tags '["one","two","three"]' replaces the key line the same way and leaves the same debris, so the reliable fix for an existing block sequence is a direct edit or the desktop app.
What the frontmatter editor can and cannot change
The CLI parses just enough YAML to serve these six commands. Everything it does not recognize is preserved verbatim, which keeps the round trip honest but also limits what you can edit.
| Shape | Supported |
|---|---|
Top-level key: value scalars | Read, set, and delete. |
tags: as an inline sequence, [a, b] | Read, add, remove. |
tags: as a block sequence, - a on following lines | Read only. Writes leave the item lines behind, as described above. |
| Nested maps | Preserved as opaque text. The parent key reads back as an empty value, the child keys are invisible, and deleting the parent orphans the children. |
| Multi-line strings, anchors, aliases | Preserved as opaque text. Not readable or editable. |
Comment lines starting with # | Preserved. Skipped by key lookup, so a commented-out key never matches. |
| Indented lines of any kind | Preserved. Only lines starting at column zero are treated as keys. |
The consequence for nested YAML is concrete. If your block contains nested: with an indented a: 1 beneath it, then jotura frontmatter set n.md a 9 does not edit that inner key. It appends a new top-level a: 9 line to the block, and the file now has two keys named a at different levels. Use the desktop app for nested edits. See The editor for what the app does with frontmatter.
Key order is always preserved. Existing keys are replaced where they sit, new keys are appended immediately before the closing fence, and the original line endings are kept, so a file using CRLF stays CRLF.
What other commands do to frontmatter
Frontmatter is off limits to every content operation in jotura edit. The command splits the YAML block off, transforms only the body, and re-prepends the block byte for byte, so no edit operation can reach a frontmatter line. Line numbers passed to --replace-line, --delete-lines, and the insert operations count body lines, not file lines. See Editing notes for the full operation set.
jotura write preserves the existing frontmatter and replaces only the body unless you pass --full, which replaces the entire file including the block. For a path that is not .md there is no frontmatter to preserve, so both forms behave identically. jotura read --json returns frontmatter and body as separate fields, and its lineCount counts body lines. That separation is what makes the safe-edit loop in Reading and searching work without ever risking the YAML.
Several other commands write frontmatter as a side effect of what they do:
| Command | What it writes |
|---|---|
jotura batch | The same four writing operations, one entry per note. See Frontmatter and tags inside a batch. |
jotura write --full | The whole file, block included. |
jotura memory log | A new memory note carrying kind, status, importance, and date, plus project and tags when you pass them. |
jotura create --template, jotura today --template | Whatever frontmatter the template body contains, after variable substitution. |
jotura templates create --from <PATH> | Copies the source note’s full content, frontmatter included, into the template. |
Plain files and non-Markdown paths
A Jotura vault is an ordinary folder of files on disk with no on-disk encryption, so there is no unlock step and these commands work the moment a vault is resolved. Protection at rest is the operating system’s job, through FileVault or BitLocker. See Security for the full picture.
A file counts as a Markdown note when its filename ends in .md, and the check is case-insensitive, so Note.MD counts. The four writing commands enforce that rule and refuse anything else with exit 9:
jotura frontmatter set report.txt title X
{"code":"InvalidArgs","message":"report.txt: frontmatter applies only to markdown notes"}
Three things sit outside that rule and are easy to trip over. The two reading commands never check the extension, as described above. Neither does jotura batch: a frontmatter-set or tag-add entry pointed at report.txt succeeds and writes a YAML block into the file. And the rest of Jotura treats a non-Markdown file as all body, so the documents you convert for search have no frontmatter split anywhere else in the product. See Documents from the CLI.
Path rules are the same as everywhere else in the CLI. A path containing . or .. components exits 9, a path inside the vault data directory .jotura/ exits 9 unless it is in the templates subtree, and a path that resolves outside the vault, including through a symlink or a Windows junction, exits 8.
Concurrency and the implicit hash check
The four writing commands take no --if-hash flag. They do not need one: each reads the note, computes the new content, and writes it back with the hash it just read as the precondition. A concurrent change from the desktop app, from sync, or from another CLI process makes that precondition fail and the command exits 2 with the current and expected hashes and a currentVsIntended unified diff.
For tag add, tag remove, and frontmatter delete, re-running the command after a conflict is safe. Each of them expresses set membership, so applying it again to whatever the file now contains gives the intended result. frontmatter set is different: exit 2 means somebody else changed the note, and a blind re-run overwrites their value for that key with yours. Read the file first and decide.
Writers serialize on a per-vault advisory lock at <vault>/.jotura/write.lock, held across the hash compare and the write, so two processes validating the same hash can never both succeed. If the note was deleted or moved between the read and the write, the command exits 14 and the file is deliberately not recreated.
Dry runs and diffs
--dry-run computes the change, prints a preview, and writes nothing. All four writing commands share one envelope, and this is the one place where they respect --json. Without the flag you get the indented text form:
jotura tag add notes/meeting.md focus --dry-run --diff
DRY RUN: notes/meeting.md
current hash: 224e34e4bff7a1feee430b19b662ee1a
new hash: 32a7496556b6bea8e3f300f10874b267
changes: 1
added: true
kind: tag-add
tag: focus
--- a/notes/meeting.md
+++ b/notes/meeting.md
@@ -1,6 +1,6 @@
---
title: Hello
-tags: [a, b]
+tags: [a, b, focus]
---
# Meeting
With --json the same run produces the structured form, and --diff adds a diff field alongside preview:
jotura tag add notes/meeting.md focus --dry-run --json
{
"changes": 1,
"currentHash": "224e34e4bff7a1feee430b19b662ee1a",
"dryRun": true,
"newHash": "32a7496556b6bea8e3f300f10874b267",
"path": "notes/meeting.md",
"preview": {
"added": true,
"kind": "tag-add",
"tag": "focus"
}
}
The preview.kind field is frontmatter-set, frontmatter-delete, tag-add, or tag-remove, and it carries the operation’s own fields alongside. For tag add, tag remove, and frontmatter delete, a dry run of a no-op reports changes: 0 with added, removed, or deleted set to false. A frontmatter set dry run always reports changes: 1 and has no such boolean, so compare currentHash against newHash if you need to know whether it would really change the file. Its preview.value is the argument you typed, not the YAML the file will receive.
--diff works on real runs too, not only dry runs. On a real run the diff arrives as the diff string field of the JSON output. It is a unified diff of the file before and after with three lines of context, and it is an empty string when nothing changed.
Frontmatter and tags inside a batch
The batch file format supports all four writing operations, so you can change many notes in one invocation. Each entry pairs a path with an operation object. Full details are in Editing notes.
kind | Fields | Supports ifHash |
|---|---|---|
frontmatter-set | key, value | Yes |
frontmatter-delete | key | Yes |
tag-add | tag | Yes |
tag-remove | tag | Yes |
The value field is parsed exactly as the command-line argument is, JSON first with a string fallback. Note the casing difference: batch JSON uses ifHash, while the command line uses --if-hash on the commands that have it.
cat <<'JSON' | jotura batch -
[ { "path": "notes/meeting.md", "op": { "kind": "tag-add", "tag": "review" } },
{ "path": "notes/b.md", "op": { "kind": "frontmatter-set", "key": "status", "value": "done" } } ]
JSON
batch always prints pretty JSON and ignores --json. The result carries one entry per operation, labelled with the same kind strings the dry-run preview.kind field uses:
{
"opsApplied": 2,
"results": [
{
"changes": 1,
"currentHash": "9fcd34d769f442d45a98deae1d5e1c4b",
"kind": "tag-add",
"newHash": "50cbd5d21320917c3e54819c9f15557d",
"path": "notes/meeting.md"
},
{
"changes": 1,
"currentHash": "a30c6ecee7b850a889fe265a3e8b9d36",
"kind": "frontmatter-set",
"newHash": "bb36d905fa23786c8d8d06968ff9d8f2",
"path": "notes/b.md"
}
]
}
batch takes --dry-run and --diff, which apply to these operations the same way they do on the command line.
Four differences from the single-command form matter when you script a sweep.
There is no idempotence short circuit. Every frontmatter and tag entry writes, and every result reports changes: 1, even when the content is unchanged and currentHash equals newHash. There is also no added, removed, or deleted field in a batch result, so the advice to branch on that boolean has nothing to branch on here. Compare the two hashes instead.
The Markdown-only rule is not enforced. require_markdown runs on the four commands and not on batch, so a tag-add entry pointed at a .txt file succeeds and writes a YAML block into it.
Op-internal failures surface as exit 11 (MultiEditOpFailed) during the planning phase, before anything is written. An invalid key or a missing note is the common cause, and the error carries opIndex, underlyingCode, and a nested underlying object:
{"code":"MultiEditOpFailed","message":"op 0 failed: InvalidArgs","opIndex":0,"underlying":{"code":"InvalidArgs","data":null,"message":"invalid key: bad key"},"underlyingCode":"InvalidArgs"}
Share guards run before any write. Every operation is routed through them in one pass, so a single entry targeting a share you hold as a viewer aborts the whole batch with exit 15 and leaves the disk untouched.
Atomicity is worth stating precisely, because the command’s own help oversells it. Preconditions are all or nothing: every ifHash is verified in one pass before anything is written, and a mismatch aborts the whole batch with exit 2 and no disk changes. The apply phase is not transactional. If an operation fails partway through for an unexpected reason, the operations already committed in that batch are not rolled back. The pre-flight passes make this uncommon but do not eliminate it.
Exit codes
| Code | Meaning in these commands |
|---|---|
| 0 | Success, including the no-op cases where added, removed, or deleted is false. |
| 1 | Generic failure: unclassified I/O or serialization errors. Read the message field. |
| 2 | The implicit hash precondition failed because the note changed underneath you. |
| 3 | The note does not exist. On the four writing commands the extension check runs first, so jotura tag add missing.txt x exits 9 rather than 3. |
| 6 | No vault resolved. Pass --vault, set $JOTURA_VAULT, or open a vault in the desktop app. |
| 8 | The path resolves outside the vault, or the filesystem refused permission. |
| 9 | A non-Markdown path on a writing command, an invalid key name, or a path containing ., .., or the vault data directory. |
| 11 | One operation in a batch failed during planning. The error carries opIndex and the underlying code. Nothing was written. |
| 14 | The note was deleted or moved between the read and the write. It is deliberately not recreated. |
| 15 | The note is inside a share you hold as a viewer, so it is read-only. |
Recovery from exit 2 depends on the command. Re-run tag add, tag remove, or frontmatter delete. For frontmatter set, read the note first, because the conflict means someone else wrote to it.
One caution when you script against exit code 2. Clap, the argument parser, also uses exit code 2 for its own usage errors, such as a missing required argument or an unknown flag. Those print human-readable text on stderr rather than the JSON error envelope, so check whether stderr parses as JSON before concluding you hit a hash conflict. The full list is in Exit codes and JSON output.
Shares and sync
Frontmatter and tag writes are share-aware. A note inside a share you joined as a viewer is read-only, and any write to it exits 15 with the shareId and the mount-relative path in the error. There is no override flag, because a forced viewer write would never sync and would be overwritten by the next remote change. frontmatter get and tag list work normally on viewer mounts.
Successful writes are queued for sync the same way any other note change is, and the desktop app runs the actual upload. Cloud sync and sharing are the paid part of Jotura, so see pricing if the vault you are scripting against is not yet synced. Everything else on this page works on a purely local vault. Sync and shares covers the CLI side, and Sync and sharing the app side.
Related pages
- Editing notes for the body operations that never touch frontmatter, and the full batch format.
- Reading and searching for
read --json, the hash source for the safe-edit loop. - Creating, moving, and deleting for note lifecycle commands.
- Exit codes and JSON output for the full error envelope and every exit code.
- Sync and shares for share mounts and the read-only rules.
- Install and setup for vault resolution and the global flags.