The CLI runs without a config file. Reading pages, writing journal entries, searching, moving blocks — none of it needs one.
A few things do, because they depend on how your graph is organised and the code cannot guess them:
- which heading journal writes go under by default,
- short names for headings you write under often,
- the namespace that marks your project pages,
- the property that marks a person page,
- the words and tags
analyze-journal-patternslooks for, - whether a write at the end of a section goes before the empty blocks that end it.
The first four have no defaults: a command that needs one and does not find it says so and exits non-zero. The fifth has English defaults that work out of the box and silently find nothing in a journal written in another language, which is why it is worth setting. The last is off until you turn it on.
Everything else is connection settings, and those live in environment variables or flags (see Environment variables and flags).
Three example files sit in the repository root:
| File | For |
|---|---|
config.example.toml |
An English graph, every option commented |
config.example.de.toml |
A German-language graph, same options |
config.example.minimal.toml |
Journal heading only |
Copy one, delete what you do not need, keep what you do.
| Variable | Default | Description |
|---|---|---|
LOGSEQ_HOST |
127.0.0.1 |
Logseq API host |
LOGSEQ_PORT |
12315 |
Logseq API port |
LOGSEQ_TOKEN |
(empty) | Bearer token for authentication; --token overrides it |
LOGSEQ_API_URL |
auto | Full API URL override |
LOGSEQ_JOURNAL_HEADING |
(none) | Default heading for journal writes: add-journal-block, add-journal-content, add-block-ref (e.g. ## Log) |
LOGSEQ_CLI_CACHE_TTL |
60 |
In-memory read-cache TTL in seconds (0 = disabled). Per process, not shared between invocations |
LOGSEQ_CLI_RANGE_WORKERS |
5 |
Parallel workers for get-journal-range (1–16) |
LOGSEQ_CLI_CONFIG |
(none) | Path to a config file, overriding the default locations |
LOGSEQ_CLI_KEEP_EMPTY_BLOCKS_LAST |
(none) | 1, true, yes or on turns on, 0, false, no or off turns off, over [graph] keep_empty_blocks_last (see [graph] keep_empty_blocks_last); empty is not set, any other value counts as off with a warning |
LOGSEQ_CLI_READ_ONLY |
(none) | 1, true, yes or on switches every command that writes off; 0, false, no, off and empty do nothing; any other value switches it on with a warning. Only tightens (see [safety] read_only) |
LOGSEQ_CLI_REQUIRE_PRECONDITIONS |
(none) | Same values and same rule as LOGSEQ_CLI_READ_ONLY, for [safety] require_preconditions |
All connection settings can also be passed as CLI flags: --host, --port, --token. --read-only and --require-preconditions are flags too, not connection settings: see [safety] read_only and [safety] require_preconditions.
By default, add-journal-block appends blocks at the top level of the journal page. Set LOGSEQ_JOURNAL_HEADING to automatically insert blocks under a specific heading:
# In your shell profile (~/.zshrc, ~/.bashrc, etc.)
export LOGSEQ_JOURNAL_HEADING="## Log"This can be overridden per call:
# Use a different heading
logseq-cli add-journal-block --under-heading "## Notes" --content "..."
# Force top-level (ignore env var)
logseq-cli add-journal-block --top-level --content "..."In this order, first hit wins:
$LOGSEQ_CLI_CONFIG— when set, only this path is considered. A missing file at that path is an error for a command that writes and a warning for one that only reads (see below).$XDG_CONFIG_HOME/logseq-cli/config.toml, or~/.config/logseq-cli/config.tomlwhenXDG_CONFIG_HOMEis unset.~/.logseq-cli.toml.
No file anywhere is normal and silent. A file that exists but is unreadable or is not valid TOML raises an error instead — you meant to configure something, so failing quietly would hide the mistake.
Errors from this layer come back like every other CLI error: a message on
stderr, a non-zero exit, and with --json a "reason": "config_error" so a script can
tell a broken setting apart from a broken connection.
If LOGSEQ_CLI_CONFIG is set but the file it names is gone — deleted, renamed,
or a typo in the path — a command that only reads and looks at the config
prints a warning to stderr and carries on without it (commands that never read
a setting do not look, and say nothing): a stale variable should not stop commands that
need no settings, and a command that does need one still fails loudly, naming
the setting. A command that writes refuses (reason: config_error, --dry-run
included): the file may hold the limit in [safety], and
the variable is the one way to name it. The same holds for a file that does not
parse: a command that writes refuses, one that only reads reports it where it
needs a setting.
Passing a path explicitly is different: that is an instruction, so a file that is not there is an error.
Highest first:
command-line flag > environment variable > config file > built-in default
For the journal heading that reads: --under-heading / --top-level beats
LOGSEQ_JOURNAL_HEADING, which beats [journal] default_heading, and with
none of them set, entries go to the top level of the page.
LOGSEQ_JOURNAL_HEADING predates the config file and stays authoritative, so
existing shell profiles keep working unchanged after you add a config file.
One rule breaks the order: the [safety] switches (read_only,
require_preconditions) only tighten. A config
that says true is not switched off by an environment value of false or by
leaving out the flag; the flag or the environment can switch it on when the
config says nothing.
The heading journal writes go under when you pass neither --under-heading
nor --top-level.
[journal]
default_heading = "## Log"Applies to add-journal-block, add-journal-content and add-block-ref.
Without it: entries are appended at the top level of the journal page. That is the behaviour of the CLI before any config existed.
Short names for headings, so you can type --under-heading tasks instead of
--under-heading "## Tasks".
[journal.headings]
log = "## Log"
tasks = "## Tasks"
meeting = "## Meetings"Without it: --under-heading always takes the literal heading text, which
keeps working either way — a value that is not a key in this table is passed
through unchanged.
Spelling has to match exactly. Heading matching ignores decoration — a
trailing renderer macro (## Tasks {{renderer :todomaster}}) and block
properties on the lines below (id::, collapsed::) are stripped before
comparing, and runs of whitespace collapse. It does not ignore case:
## Log and ## log are two different headings, in Logseq as much as here.
That matters because of what happens on a miss. --under-heading creates the
heading when it cannot find it, so a wrong capitalisation does not produce an
error — it silently adds a second heading next to the one you meant, and writes
underneath that. Nothing warns you.
So copy the value out of your graph rather than typing it from memory; see Finding your own values for the command that prints your headings as they are actually stored.
The namespace prefix that marks your project pages, e.g. projects/Project Alpha or vorhaben/Gartenhaus.
[graph]
projects_namespace = "projects/"Used by smart-query --request "projects" only.
Without it: that one query reports the missing setting and exits non-zero:
smart-query --request 'projects' needs 'projects_namespace' under [graph] in your config.
expected in: ~/.config/logseq-cli/config.toml
see config.example.toml in the repository for the format.
this setting describes your own graph, so there is no default.
It does not guess a prefix and return an empty list, because an empty list is indistinguishable from "you have no projects". Every other command is unaffected.
The page property that marks a person page, and the value it must carry. A
page with type:: Person matches the pair below:
[graph]
person_property = "type"
person_value = "Person"Used by smart-query --request "persons" only. Both are required — the
property name alone does not identify a person page.
Without them: same as above, the query names the missing setting and exits non-zero. Nothing else changes.
Whether a write at the end of a section goes before the empty blocks that end it, so they stay last.
[graph]
keep_empty_blocks_last = trueMany Logseq users keep an empty block at the end of a section as a place to
click and type. The CLI appends as the last child, so every entry it writes
lands behind that block and an empty line stays in front of it. With this on,
the write goes directly before the empty blocks at the end. Given TODO a and
an empty block, insert-block --child-of <section> --content "TODO b" leaves
TODO a, TODO b and the empty block.
- Nothing is overwritten or deleted. The empty block is never written to,
so one you are typing in, or one another writer has just filled, is untouched;
the cursor can stay in it, except with
--keep-ids, which refuses any write while a block is open, as it always did. The empty blocks stay last. - One command is one write. Every block of a command (a second
--content, the second root of a tree) goes in order directly before the empty blocks. - What counts as empty: no text, no properties and no children. An empty block with an empty child is not empty: the child was put there on purpose. Empty blocks in the middle of a section are left alone.
- Which writes: the ones that end a section, a last child or the end of a
page:
insert-block --child-of(not--first) and--page,add-note-content,add-journal-block,add-journal-content,add-block-ref, the root ofcopy-block, and a heading that has to be created. Not--after,--before,--first, and not the deprecatedadd-journal-entry. --keep-idsworks as it does for--before: the ids are kept and the blocks go before the empty blocks.- Page properties. A block of nothing but
key::lines is not put before the first block of a page: Logseq would read it as the page's properties, andtitle::renames the page. It is appended as it would be without the setting. --dry-runnames the block the write would go before (would go before the empty block ...;would_go_before_empty_blockin JSON). After a write that did, the JSON hasbefore_empty_block.
Without it: writes append as they always did. This is the default, because an empty last block can be deliberate, as a spacer or a template's placeholder.
The environment variable LOGSEQ_CLI_KEEP_EMPTY_BLOCKS_LAST overrides the file in
both directions (1, true, yes, on or 0, false, no, off); an empty
one is not set, and any other value counts as off, with a warning. A value in the
file that is not true or false is a config error.
What analyze-journal-patterns looks for when it scores moods and counts
project mentions. Unlike the settings above, these have defaults — English
ones — so the command runs either way. In a journal written in another
language it simply finds nothing, and a journal with no moods in it looks
exactly the same, which is why this is worth setting rather than assuming it
works.
[analysis]
mood_positive = ["happy", "great", "productive", "grateful"]
mood_negative = ["tired", "stressed", "frustrated", "overwhelmed"]
# Labels for an explicit mood line, as in `mood: good`.
mood_labels = ["mood", "feeling"]
# Projects, written as a namespace. Both `#projects/alpha` and
# `[[projects/alpha]]` count — a graph that namespaces its project pages
# usually contains both spellings.
project_tag_prefix = "#projects/"
# For graphs that tag flatly instead (`#alpha`, `#beta`): those names cannot
# be derived from a prefix, so list them.
project_tags = ["alpha", "beta"]Words are matched whole and case-insensitively, and are escaped before they reach the pattern — a word may contain regex characters without surprising you. An empty list matches nothing rather than everything.
The mood words judge stated moods, not the whole journal. A line has to
say one — mood: good, stimmung: mies, with the label coming from
mood_labels — and the word lists then decide whether that value is positive
or negative. Counting every occurrence instead measured how often such words
appear in technical prose: "nicht erfolgreich" and "schmeckt nicht gut" both
counted as positive, because a word list cannot see a negation. If you never
write a mood line, the counts stay at zero, which is the honest answer.
Without it: the English defaults apply and #project/ is assumed. Measured
against a German journal of 120 days: 3 mood hits with the defaults, 148 with a
German list.
[safety]
read_only = true| Values | true or false (a TOML boolean); default false |
| Effect | every command that writes refuses before its first request, --dry-run included, with reason: read_only; commands that only read, --help and init run as before |
| Also set by | LOGSEQ_CLI_READ_ONLY and --read-only; all three only tighten, there is no way to switch a true off from outside the file |
| Shown by | logseq-cli doctor, every run: read_only: on (config …) or off (no [safety] in …) or off (no config file found), and unknown, commands that write refuse until this is fixed: … when the config cannot be used; it does not turn a healthy check red |
[safety] is the one section checked strictly. An unknown key
(readonly = true), a [safety] key outside [safety] (at the top level, in
another section, nested as [journal.safety], or under a misspelt [saftey]) and a value that is not a
boolean are errors that name the key and, where there is one, the key meant:
Error: …/config.toml: unknown key `readonly` in [safety], did you mean `read_only`?
A command that writes refuses with reason: config_error; one that only
reads runs, mostly without a word about it (doctor shows it every time), so a slip in [safety] does not stop your reads. Other
sections stay tolerant: an unknown key in them is ignored, and doctor names a
section it does not read.
init writes to the config file that is in use, so with your config in
~/.logseq-cli.toml it does not create a second file under ~/.config/ that
would be found first. Since that file exists, init --force is needed, and it
keeps the [safety] section; it refuses a file it cannot parse.
How binding each way of setting it is, and what it does not stop, is in safety.md.
[safety]
require_preconditions = true| Values | true or false (a TOML boolean); default false |
| Effect | a command that changes a block it read refuses, before its first request, unless the call says what it read: --expect-hash (also --expect-marker for set-todo-status), --expect-tree-hash for remove-block. Refused with reason: precondition_required, and the message names where the switch was set. The rule is the call's, not the command's: a command that changes nothing in a given form stays free in it. Only the commands that take such an option ask for it. add-journal-block --upsert-heading asks only when a block matches; creating one stays free. An upsert whose content would replace the matched block's children owes --expect-tree-hash even with the switch off |
| Also set by | LOGSEQ_CLI_REQUIRE_PRECONDITIONS and --require-preconditions; all three only tighten, like read_only |
| Order | read_only comes first: with both on, a call without a precondition is refused as read_only |
| Shown by | logseq-cli doctor, every run, in the same forms as read_only |
A config that cannot be read, a LOGSEQ_CLI_CONFIG that names a file that is
not there and a value that is not a boolean refuse the commands that ask for a
precondition, as they do for read_only: the switch cannot be told to be off.
Graphs differ. The answer is almost always "leave the setting out", and the paragraphs below say what happens then.
Leave [journal] out entirely, or drop default_heading from it. Journal
entries are then appended at the top level of the page — which is exactly what
the CLI did before config files existed.
If you have LOGSEQ_JOURNAL_HEADING exported in a shell profile, unset it too;
it wins over the config file, so the config alone cannot turn it off. For a
single call, --top-level ignores both.
Leave projects_namespace out. The consequence is scoped to one query:
smart-query --request "projects" prints the message shown above and exits non-zero.
It fails rather than returning nothing on purpose. A guessed prefix would
produce an empty result that looks exactly like a graph without projects, and
you would go looking for the wrong bug. All other commands — including every
other smart-query request — work normally without this setting.
projects_namespace matches on the page name prefix, so it cannot express
type:: project. There is no property-based project setting, and leaving
projects_namespace out costs you nothing but that one smart-query request.
Use query-pages-by-property instead — it needs no config at all:
logseq-cli --token "TOKEN" query-pages-by-property --key "type" --value "project"
# Omit --value to list every page carrying the key, with its values
logseq-cli --token "TOKEN" query-pages-by-property --key "type"For anything the built-in templates do not cover, smart-query --advanced
passes a raw Datalog query through untouched:
logseq-cli --token "TOKEN" smart-query --advanced \
--request '[:find (pull ?p [*]) :where [?p :block/name] [?p :block/properties ?props] [(get ?props :type) ?t] [(= ?t "project")]]'If you want to see the query a template generated — the namespace variant
included — add --include-query to smart-query.
That is fine. --under-heading creates the heading when the journal page does
not have it — the option's own help says "Creates heading if missing". The
same applies to a default_heading that only exists in your config so far: the
first write creates it, subsequent writes append under it.
The flip side is that a misspelled heading is indistinguishable from a new
one. Write ## log in your config while the journal contains ## Log, and the
existing section is not found: a second, lowercase heading is created beside it
and your entries go there. No error, no warning — to Logseq those are simply
two different headings.
So take the spelling from the graph, not from memory. The heading command under Finding your own values prints your headings exactly as they are stored; copy one of those lines.
Nothing resolves. Names from [journal.headings] are looked up; anything not
found is used verbatim as the heading text. So --under-heading taks writes
under a heading literally called taks (creating it, per the rule above) —
it never quietly falls back to tasks, default_heading, or top level.
The same holds for a typo in the value: matching tolerates decoration
(renderer macros, id::/collapsed:: lines, extra whitespace) but not a
different capitalisation.
If entries turn up in an odd place, check the spelling first — the short name
and the heading it maps to. --dry-run on add-journal-block shows where a
write would land before it happens.
logseq-cli --token "TOKEN" init --dry-run # show what it would write
logseq-cli --token "TOKEN" init # write itinit reads the graph — never writes to it — and proposes a config from what
it finds: the headings your recent journals use, the namespace most of your
pages sit under, the type:: value that appears most. Every suggestion comes
with the count it rests on (## Log 147/150), because these are counts, not
certainties.
It looks at the most recent journals only (120 by default, --days to change
that). A section you stopped using years ago still sits in hundreds of old
files and would otherwise outrank the one you use now.
It writes to the config file that is in use, so a config in ~/.logseq-cli.toml
gets no second file under ~/.config/logseq-cli/ that would be found first and
hide it. --output PATH writes there instead.
Two things it will not do: overwrite an existing config without --force, and
guess [analysis] — which words carry mood in your journal is not something a
count can tell.
If the answer looks wrong, the section below shows how to check it by hand.
Run these against your own graph folder — the directory holding journals/
and pages/.
Which headings do your journals actually use?
grep -rhoE "^[[:space:]]*-?[[:space:]]*#{1,4} .*" journals/*.md \
| sed -E 's/^[[:space:]]*-?[[:space:]]*//' | sort | uniq -c | sort -rn | headEach line is a count and a heading. The ones at the top are the sections you
really write under — good candidates for default_heading and for
[journal.headings]. Copy the heading text exactly as it appears, ##
included, and with the same capitalisation: matching is case-sensitive, and a
heading that does not match is created rather than reported (see
[journal.headings]). A trailing {{renderer ...}} may be
left off — that part is ignored when matching.
Which namespaces exist?
Logseq encodes the / of a namespaced page name in the filename. Depending on
the graph's age and settings that is ___ or %2F, so match both:
ls pages/ | grep -E '___|%2F' | sed -E 's/(___|%2F).*//' | sort | uniq -c | sort -rnEach line is a count and a namespace prefix. A prefix with many pages under it
is what projects_namespace wants — written with a trailing slash, as it
appears in the page name (projects/), not as it appears in the filename.
If that returns nothing, ask the graph itself instead of the filenames:
logseq-cli --token "TOKEN" get-all-pages --json \
| jq -r '.[] | .originalName // .name' | grep / | sed -E 's|/.*||' | sort | uniq -c | sort -rnCapitalisation does not matter for this setting: the prefix is lowercased
before it is matched, so Projects/ and projects/ behave the same.
Which properties do your pages use to classify themselves?
grep -rhoE "^\s*-? *[a-zA-Z-]+:: .*" pages/*.md | sed -E 's/^\s*-? *//' | sort | uniq -c | sort -rn | head -30Each line is a count and a property:: value pair. Look for the pair you use
on person pages — type:: person, typ:: person, kind:: contact, whatever
your convention is. The left half goes into person_property, the right half
into person_value.
To narrow it to one property:
grep -rhoE "^\s*-? *type:: .*" pages/*.md | sort | uniq -c | sort -rnChecking the result
logseq-cli --token "TOKEN" doctordoctor is read-only and checks the connection end to end: is anything
listening on the port, is a token supplied, does the API answer, is a graph
loaded. Each step is reported separately, so a failure names which one broke.
Exit 0 means ready to read. It also says whether writes are switched off
(read_only) and whether a precondition is required (require_preconditions), and by what; that does not fail it, and the closing line says
"Ready to read; writes are off." when they are. A config file that does not
parse fails it, and the advice is to fix the file, never to remove it: removing
a file that holds a limit lifts the limit.
Once your config is in place, the fastest functional check is the query that needs it:
logseq-cli --token "TOKEN" smart-query --request "projects"Exit 0 with results means the namespace matches. A non-zero exit with a message
naming projects_namespace means the setting is missing. Exit 0 with no results means
the setting is there but the prefix matches no page — check the spelling
against the namespace listing above.
The config file is TOML. Python 3.11 and newer parse it with the standard
library's tomllib; nothing to install.
On Python 3.10 — the oldest version this CLI supports — tomllib does not
exist, so tomli is pulled in automatically as a conditional dependency
(tomli>=2.0; python_version < '3.11' in pyproject.toml). A normal pip install handles it.
If a config file is present and no parser can be found at all, the CLI says so by name rather than ignoring the file.