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 and its value. 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 common flags are useful here. --vault, --json, and --verbose are global, so they work at any position in the command line.

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, -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. jotura read --json follows a different rule: its frontmatter field is always null for a file that is not .md or .markdown.

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"
]

Inline sequences (tags: [a, b]), block sequences (tags: followed by indented - a lines), and a bare scalar (tags: work, read as the single tag work) are all understood. Surrounding single or double quotes on each item are stripped, a leading # is stripped, empty items are dropped, and repeats are collapsed. This is the same frontmatter parser the desktop app and jotura tags use, so the three surfaces never disagree about what a note’s frontmatter says.

tag list reads the frontmatter block only. It is the read side of tag add and tag remove, which edit that block, so an inline #tag written in the body is not listed here. jotura tags counts both.

Splitting is done on every comma with no quote awareness, so a quoted item that contains a comma is read as two tags, and each half keeps the quote that was left dangling: 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.

Vault-wide tags

The six commands above act on one note. jotura tags is the vault-wide view, and takes no path.

jotura tags [--notes <TAG>]

Without --notes it walks every markdown note in the vault and reports each tag with the number of notes carrying it. It counts frontmatter tags: and inline body #tags together, exactly as the desktop Tags pane does, so the two surfaces agree; body tags inside fenced code blocks, inline code spans, and the frontmatter itself are skipped, and #123 is not a tag because a tag needs at least one letter. Counts sort descending; ties break on the lowercased tag name, then on the tag as written. Text output is one tag, tab, count line each; --json returns [{ "tag", "count" }].

jotura tags
jotura tags --json

With --notes TAG it lists the notes carrying exactly that tag, sorted by lowercased title then by path. Paths are vault-relative. Text output is path, tab, title; --json returns [{ "path", "title" }].

jotura tags --notes work --json

Tag matching is exact and case-sensitive, matching the desktop Tags pane. A tag nobody uses returns an empty list and exits 0. Dot-prefixed folders, including the vault data dir .jotura/, are skipped, so the counts match what the desktop shows.

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, along with every line of its old value. 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"
}

The old value is replaced whole. When the key held a block sequence, a nested map, or a multi-line string, the indented lines beneath it go too, so a note reading

---
aliases:
  - Atlas
  - The Atlas work
tags:
  - work
---

becomes aliases: [Atlas, Project Atlas] followed by the untouched tags: block after jotura frontmatter set A.md aliases '["Atlas", "Project Atlas"]'. A value’s lines are the indented lines under the key and any - item lines at column zero, which YAML also accepts as a sequence under a key. A blank line or a # comment that comes after the value stays where it is.

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 matches nothing and reports "deleted": false.

Deleting a key

jotura frontmatter delete removes one top-level key and its value.

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 succeeds and exits 0. 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 changed.

The key’s whole value goes with it, using the same rule as set: a block sequence, a nested map, or a multi-line string beneath the key is removed along with the key line, and the next top-level key is left as it was.

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. Follow up with jotura frontmatter delete <PATH> tags to drop the key entirely.

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 the single tag work, and the first tag write rewrites it as a sequence. Running jotura tag add scalar.md x on tags: work leaves tags: [work, x].

A note whose tags: value is a block sequence is rewritten as an inline one. Adding three to a note whose tags were - one and - two replaces the key line and both item lines with tags: [one, two, three].

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, add, remove. A write rewrites the sequence in inline form.
Nested maps Preserved as opaque text. The parent key reads back as an empty value and the child keys are invisible. Setting or deleting the parent replaces or removes the whole map.
Multi-line strings, anchors, aliases Preserved as opaque text. Not readable, though setting or deleting the key replaces or removes the whole value.
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 or .markdown 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 occurred, 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 or .markdown, 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, because each one 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 change the file. Its preview.value echoes the argument as you typed it, before it is rendered into the YAML the file will receive.

--diff also works on real runs. There 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.