Commands
A command is a named action with typed arguments and a return value, run by name: from the command bar the user types, from code in the application or in an extension, and later by agents (each command carries a description written for a model). This page explains the model; Writing commands shows how to write and register a suite, and The command bar how the UI side works. The user's view is in the user guide.
flowchart LR
bar["CommandBar<br/>/name arg arg"] -->|executeLine| svc
code["Application / extension code"] -->|execute, executePositional| svc
svc["CommandService<br/>registry + dispatcher"] -->|overload that fits| suite["Command suite<br/>@Command methods"]
suite -->|"setReturn / fail"| fut["CompletableFuture"]
ext["Extension<br/>onExtensionLoad"] -->|registerCommands| svcConcepts
The context
A command never receives its arguments as Java parameters; it reads them from the context by the names of its signature:
Arguments and dispatch
A call names the command and passes the arguments in one of three ways:
Each of them also has a form with a last argument boolean uninterruptable, see
When several forms fit, the closest types win. Each argument costs 0 when it already has the parameter's type, 1 for a
parameter of type Object ("any"), 2 when text or a number has to be converted (to a number, boolean, character or
enum); a form that takes any arguments costs 1000, so it is the last resort. The form with the lowest total is called,
ties go to the form that is listed first (declared forms by the number of parameters, then by method name). The
Object.class parameter is "any": it takes the argument as it is, including a List of objects, so a command can be
given a list as one argument.
Conversions (CommandValues): an instance of the type is passed as it is; a number to another number type when the value
fits; text to a number, boolean, character or enum constant.
The command line
CommandLine.parse(text) splits a command typed as text. The text is a command when it starts with /.
-
Arguments are separated by spaces; repeated spaces count as one.
-
\is a space inside an argument,\\a backslash and\"a quote. -
A double quote starts or ends a quoted part in which spaces need no escape. Quotes may start anywhere in an argument (
a"b c"disab cd),""is an empty argument, a quote that is never closed runs to the end of the text. -
Any other backslash stays as it is (
C:\diris unchanged). -
trailingSeparatortells whether the text ends with a space that starts a new, still empty argument; withtypingName()andcurrentArgument()the help knows what the user is typing.CommandLine.escape(argument)writes an argument so thatparsereads it back as one argument.
Return values
@Command(returns = @CommandReturn(name, type, description)) declares what the command returns; the type is a direct
class reference (String.class, Object.class for "any", void.class by default). It is documentation for the user and
for agents (the help shows it); the service does not check the value against it.
Synchronous and asynchronous commands
A caller can cancel the future (the Stop button of the progress window does). Then isTerminated() becomes true, the
worker thread is interrupted, and a command that has not started yet does not start. A long-running async command must
cooperate: look at isTerminated() (and the interrupt flag) between the steps of its work and return at once. What it
sets afterwards is ignored. /hello-world-async of HelloWorldCommands (a suite of the tests, src/test/java, not part of the application) is the model of such a loop.
Uninterruptable runs
Some work can't be abandoned half way, a change that is being written to the database. A command declares that with
context.setUninterruptable(true) just before it, and false after it: meanwhile CommandRun.cancel does nothing (it
returns false) and the progress window of the command bar disables its Stop button
(CommandRun.isUninterruptable(), addUninterruptableListener(...), called on the thread of the command like the status
listener). The search and replace commands do so for the replace, after the user confirmed it.
A caller can start a run that way too: every execute* has a form with a trailing boolean uninterruptable
(execute(name, Map, true), executePositional(name, List, true), executeLine(line, true)). Such a run is
uninterruptable from the start and the command can't lift it with setUninterruptable(false). A command that is
uninterruptable and starts another asynchronous command passes context.isUninterruptable(), so the nested one is not
abandoned either.
Status
A command that takes time tells the user what it is doing with setStatus (the main line, "Taking a backup of Dune") and
setSubStatus (the detail, "Chapter 3 of 12"). The lines are kept in the CommandRun that execute* returns
(getStatus(), getSubStatus()); addStatusListener((status, subStatus) -> ...) is called on the thread of the command on
each change, so a view must hop to its own thread (the progress window of the command bar does with ui.access). A
listener does not get what was set before it was added: read the getters after adding it. A listener that throws is
ignored. /take-backup (BackupCommands) is an example.
Data severity
@Command(dataSeverity = ...) says what the command does to the data that exists, as a CommandDataSeverity:
The default is the safe assumption: a command that does not say is treated as destructive, so a command that only reads or
adds declares NON_DESTRUCTIVE. All forms of a command have the same severity (registerCommands throws
IllegalArgumentException otherwise), and CommandDefinition.dataSeverity() returns it. The severity is for two readers:
the command bar shows it as a label next to the command for the user, and an agent that runs
commands on its own is meant to ask the user before it runs a DESTRUCTIVE one. Nothing in the service enforces that
today: the severity does not stop a run, and a command that must ask the user still does it itself, see
Asking the user.
Failures
A future fails with:
CommandExceptionwhen the command throws or callsfail(the cause is the original error),IllegalArgumentExceptionwhen there is no command of that name, no form fits the arguments, or the text forexecuteLinedoes not start with/.
Registration
registerCommands(Object suite) validates the whole suite and registers all of it or nothing, and throws
IllegalArgumentException for: a class without a @Command method; a command method that is not public void with a
CommandContext as the only parameter; a name with characters other than letters, digits, _, -, .; two forms
with the same signature; forms with different humanName; a signature that is also any; a duplicate parameter name; a
name another object already holds. It returns the CommandDefinitions. Registering the same object again does nothing.
unregisterCommands(Object suite) removes the suite, unregisterCommand(String) one command with all its forms. The
service does not know which bundle a suite came from: an extension unregisters its suites in onExtensionUnload.
Runs in progress are not interrupted by unregistering.
The application registers its own suites in CommandServiceImpl.afterPropertiesSet (BackupCommands, the two search and replace commands and the
Statelessness
A suite is one object shared by all users and runs in parallel, so it must be stateless: no fields that hold what a run or a user did. What a command needs to know about the current state it reads when it runs:
- from the services, which resolve the current user and check ownership (
ManuscriptService, and so on), - from the scope of the call,
context.getScope()(below): the book the command works on, its lorebooks and the way to ask a question, - from the session-scoped
SessionPoint(getLanguage(),getWorkspace()), throughCommandUtils.
The commands that report or set the settings of a book (get-book-info, the book settings commands) work with the effective values, those the
book gets through its folders (ManuscriptService.getLanguage, getPov, resolve(...).isCustom()), not the
book's own fields.
The scope
CommandContext.getScope() is a CommandScope: what the command works on and who answers its questions. A command takes the book from it
(getManuscript(), read again from the database every time, getLorebook() the root lorebook of the book) and asks with
context.confirm(question); it never reaches for the open window. The user at the command bar gets the scope of their session
(CommandScopes.session(): the open book, a dialog for the questions), an agent the scope of its working copy
(CommandScopes.forRun(run): the copy of the run, whichever it is now, and a yes without asking, since what an agent may do without
asking was decided when it called the command). CommandService.execute, executePositional and executeLine have a form with a last
argument scope; without one the scope of the session of the calling thread is made on that thread.
getModel() answers which AI and protocol work that a command starts should use when the user did not choose (a ModelChoice; null is "no opinion"). For an agent it is the model of the agent, or the one of its book when the agent was told not to use its own (Agent.useAgentModel); the user at the command bar has none, so a command falls back to its own choice (the Lore Generator to the default model of the user, the Arc Outline to the model of the book first). A command of an extension that calls a model must ask it first, or it ignores what the author of the agent decided. A command that writes through the story services asks nothing: the working copy already holds the model (see Agents). Tools with their own request to a model inside a long-lived agent should also take a place at the AI with AIGate (ManuscriptWriteSupport.placeAt).
The scope is also the cage. resolveMessage(uuid), resolveLorebook(uuid) and resolveEntry(uuid) find an item by the uuid, and only
the ones of the book of the scope: the parts of the book, the lorebook of the book and its subbooks however deep, and their entries. A uuid of
anything else (the original book of a copy, another book, a lorebook the book does not use, a deleted item) is refused with an
IllegalArgumentException as if it did not exist, so a command that takes an item as an argument can't be aimed outside of the book by what
it is given. A command that takes an item does it through the scope and not through findForUser or find(id).
One exception: resolveUnusedLorebook(uuid) also finds a lorebook of the working copy that the book does not use any more, because a command deleted it
(LorebookService.delete only marks it, with its entries and tags) or took it out of its parent. It is for /add-subbook, which brings such a lorebook
back (LorebookService.restore, the restore of the trash); the cage is that the lorebook is one of the copy (agentic, of the owner of the run) and that the
run has a handle for it, so an agent can only name what it was shown. The scope of the user at the command bar answers like resolveLorebook; the user
restores from the trash.
CommandUtils (package utils) reads the session of the caller – also on the worker thread of an async command, which
gets the request attributes – and returns null when there is nothing to return (no session, no workspace yet, no book
open), so a command checks its results. The book-related methods only delegate to the SessionPoint, which knows where the
book comes from: for a user the SessionPoint reads it from the open window of the workspace, for an agent the
AgenticSessionPoint (a SessionPoint without workspace and window) holds the book directly, so the same command runs for
both:
confirm and reloadManuscriptWindow talk to the window, so in a session without one they answer no and do nothing (CommandContext.confirm is what a command calls, it is routed by the scope).
A command that changes the UI does it on the UI thread: from a synchronous command that is the calling thread, from an
async one use ui.access(...) (see UI extensions → Threads).
Commands that read the book
The package domain.service.impl.commands.manuscript.read holds one class per command (GetBookInfoCommand, ListPartsCommand,
GetPartCommand, ListSwipesCommand, ListBranchesCommand, GetPartMetricsCommand, ShowPromptCommand, SearchPartsCommand,
FindOccurrencesCommand, ComparePartsCommand, CountTokensCommand, ListSummariesCommand, GetSummaryCommand,
CheckSummariesCommand, GetTemplateVariablesCommand, GetBookStatisticsCommand, GetStatisticsCommand), each a @Configurable suite registered by CommandServiceImpl. They share
ManuscriptReadSupport (the services, the Book loaded again from the current manuscript, the JSON helpers) and StoryTree (the
parts as a tree, with the numbers of the branches). All are NON_DESTRUCTIVE, synchronous and answer JSON text.
-
Statistics.
get-book-statistics [range] [side](the open book, day by day) andget-statistics [range] [by] [side](all the books of the author:byisday, the default, orbook, the books in the order of their words, withhandle,name,folderanddeleted, also the books that are gone) answer fromStatisticReportService. Therangeis a name ofStatisticRange(today,yesterday,last-7-days, the default,last-30-days,this-month,all-time), a day (2026-03-14) or two (2026-03-01..2026-03-14); thesideisall, the default,meoragents. The numbers are the counts, the time in minutes (minutesOpen,minutesEditing,minutesGenerating,minutesAgentsRunning: the union, so books that ran together are counted once) and the growth of the size.get-statisticsneeds no open book. -
Handles, positions and numbers. Every row of an answer has a
handle, the uuid of the part (an agent sees it ash:m17: the border of the agent turns the uuids of its copy into handles and back, seeCommandCage; a command knows nothing of it). A part parameter is aStringthatBook.locate(part, branch)reads as a handle (CommandScope.resolveMessage, so only a part of the scope's book is found) or as a position (depth on a branch, the first part is 1); a branch is the handle of one of its parts (the branch through it to its end,StoryTree.leafOf) or its number: the ends of the branches (parts without children) ordered by creation, the oldest is 1. Positions and numbers move when parts are added or deleted, so the commands that write accept handles only.list-branchesgives the numbers andlist-swipes,search-parts(withincludeVersionsit also searches the earlier versions of the parts, and a hit found through one has aversionobject) andfind-occurrencessay the branch that shows a part that is not on the active branch. Without a branch the active branch (getBranchFromLeaf, the root first) is used; the other branches need the whole tree (ChatMessageService.getAllMessages, one query), so a command loads it only when asked for one. A form with a singleStringthat could be a text or a part (count-tokens,preview-lore-activation) takes the handle in the text: the dispatcher can't tell two one-Stringforms apart. -
Time limited patterns. The regular expressions of
find-occurrences(andsearch-lore) run overutils.TimeSafeText: the text is wrapped in aCharSequencewhosecharAtchecks the clock and throwsRegexTimeoutExceptionafter 30 seconds for the whole command (the system propertymarginalia.regexTimeoutMillischanges it), which the command turns into an error withERROR_REGEX_TIMEOUT. A pattern the JDK can't optimise, such as one with a back reference ((a+)+\1b), can run for hours otherwise;TextSearchuses the same wrapper for each text. -
Bounded answers. Lists take
offsetandlimit(50, at most 200); texts come in slices (offset,length, 6000 characters, at most 20000) withtotalLengthandtruncated. -
Forms. The options are the usual ladder of overloads (every prefix of the parameter list is a form), so a command can be called by name only with a prefix of its parameters.
-
Summaries. The state of a summary (
valid,outdated) comes fromSummaryService.collectBlocks; the "covers" range of a block runs from the part after the previous block to the part that holds the summary. -
compare-partsusesutils.TextDiff, a longest common subsequence of the sentences (a line break ends a sentence too), refused above 4 million cells.
Tests: ManuscriptReadCommandsTest runs them in an agent session (AgenticRequestAttributes in the test sources puts an
AgenticSessionPoint into the session scope of the logged in test user).
Commands for annotations
ListAnnotationsCommand (list-annotations [status [part [offset [limit]]]]) and GetAnnotationCommand (get-annotation) read, AnnotationWriteCommands
(create-annotation, update-annotation, resolve-annotation, reattach-annotation, delete-annotation, generate-annotation) change the annotations. An annotation is given
by its uuid (Book.annotation checks that it is one of a part of the book of the scope). They change only the notes, never the story, so they are NON_DESTRUCTIVE
except the delete. An agent writes them in its working copy with the source AGENT (CommandScope.isAgent); the annotations are items of the
merge like parts and entries. CommandScope.resolveAnnotation finds one of the book of the scope (the handles of the agent, h:a17).
generate-annotation part [profile] is Annotate with AI as a command: it calls AnnotateService (answer format Automatic, the default task of the annotate prompt)
and stores every note it finds at once with the source AI; it is async, BatchPolicy.SINGLE (it calls a model) and, like the other commands that write with a model,
takes the model from CommandScope.getModel (the model of the agent when the agent's setting says so, else the one of the book; profile is a model profile of the agent).
Tests: AnnotationCommandsTest, AgentAnnotationsTest, GenerationCommandsTest.
Commands that read the lorebooks
The package domain.service.impl.commands.lorebook.read has ListLorebooksCommand, ListLoreEntriesCommand, GetLoreEntryCommand,
SearchLoreCommand and PreviewLoreActivationCommand on LorebookReadSupport, which extends ManuscriptReadSupport (so the Book, the paging and the slices are the same).
Its Lore is the lorebook tree of the open book: the root lorebook of the manuscript, then the subbooks level by level, each once
(LorebookService.getSubbooks skips the deleted ones). A lorebook is its number in that order (the root is 1), an entry its number in
the lorebook (LorebookEntryService.getEntriesForLorebook: by ordinal, then by id, deleted ones left out). Nothing outside the tree is
reachable, so an agent never sees the other lorebooks of the user, and no uuid is given out or accepted. list-lore-entries filters by
name and tag in Java and keeps the numbers of the whole lorebook; search-lore scans name, text and comment case-insensitively
(plain text or a regular expression) and reports one hit per entry with the fields that match.
preview-lore-activation asks LorebookActivationService (domain.service), the rules the generation step ProcessLorebookStep uses too: collectLorebooks (the enabled
tree with the entries filled in), orderedEntries (by order, the older first), evaluate(entry, bookTags, text, parts) (parts: the story before the turn, from recentParts with partsNeeded as the count - the active branch of the book, or the parts in front of the given part - for the entries that look into it; a Verdict with a Reason: CONSTANT,
FILTER_MATCHED, LORE_MATCHED, DISABLED, OUT_OF_RANGE, NEGATIVE_TAG, MISSING_TAG, FILTER_NOT_MATCHED; OUT_OF_RANGE: the entry is limited to some parts and the turn - the next part of the branch, or the given part - is outside them), evaluateAll (all the entries and the same parts and the position of the turn, with the passes of the scan depth of the root
lorebook: an entry found by the content of others is LORE_MATCHED, its row has scanDepth, the pass) and renderPayload. The step only adds its events around them, so what the command
shows is what a generation does. With a part the filters are looked for in the user prompt of the book rendered from the turn input stored with the part, in a
fresh TemplateContext (the book, the narration settings, the variables of the book and of the part; not the story), so a payload that reads the story renders
without it. The form with text and the form with part both have one argument, a number on the command line is taken for the text: give a part as a number
to executePositional, or by name as an agent does.
Tests: LorebookReadCommandsTest, the same way, with a lorebook tree, a lorebook the book does not use and a deleted entry.
Commands that change the book
The packages domain.service.impl.commands.manuscript.write and lorebook.write hold the commands that change the book, on
ManuscriptWriteSupport (which extends ManuscriptReadSupport) and LorebookWriteSupport (which extends LorebookReadSupport):
PartEditCommands (edit-part, replace-part-text, append-to-part, set-part-fields), PartStructureCommands (create-part,
insert-part, branch-part, delete-part, switch-branch), SummaryWriteCommands (set-summary, remove-summary, revalidate-summaries),
BookSettingsCommands (set-book-info, set-book-prompts, set-book-tags, set-book-history-limit over BookSettings.historyMaxVersions, a negative number takes the limit away), StoryGenerationCommands (next, swipe,
regenerate), SummaryGenerationCommands (regenerate-summary, create-meta-summary), RedraftCommands (redraft-part, over RedraftService; the model is the one of the book as the scope gives it, which for an agent follows Agent.useAgentModel, or a profile through ManuscriptWriteSupport.options), AnnotationWriteCommands.generate (generate-annotation, over AnnotateService in AUTO mode with the default task of the prompt; batch = SINGLE, async; adds every proposal with AnnotateService.accept, labelled with the model; the model is CommandScope.getModel like in the Lore Generator, or a profile through ManuscriptWriteSupport.options), ImpersonateCommands (impersonate, over ImpersonateService, returns the drafted instructions; availableForAgent = false and readOnly), LoreEntryWriteCommands and
LorebookWriteCommands. They call the services the screens use (ChatMessageService.updateResponse / createPart / insertPart /
branchAndSwitch / swipeAndSwitch / deleteNodeAndMigrateChildren, SummaryService.updateSummaryText / removeSummary /
revalidate, ManuscriptService.updateSettings, LorebookEntryService.create / update / move / deleteEntry,
LorebookService for the subbooks) and add no rule of their own, so what the user does in a window and what a command does is one path.
-
Handles only. A part, lorebook or entry parameter must be a handle (
ManuscriptWriteSupport.part,LorebookWriteSupport.book/entry); a position or number is refused withERROR_COMMAND_HANDLE_REQUIRED. -
Severity. Adding and changing is
NON_DESTRUCTIVE; what removes text or data (replace-part-text,delete-*,remove-summary,regenerate,set-book-prompts) isDESTRUCTIVEand callscontext.confirm(question): the user at the command bar gets a dialog, an agent is not asked again (its destructive mode decided before the call). A refusal answers Nothing was changed. -
Outdated summaries. An edit can invalidate the hash of a summary block.
reportOutdatedputs the summaries an edit made outdated (the ones outdated now and not before) and a hint into the answer;revalidate-summariesis the author's way to say they are still right. -
The window. After a change
changed()reloads the book window of the user (CommandUtils.reloadManuscriptWindow, nothing for an agent, which has none). -
Generation.
next,swipeandregenerateareasyncand callStoryGenerationService.generateNextTurnwith a listener that completes a future; each movesactiveLeaf, so one generation of a book runs at a time (a lock per manuscript, the others wait). Swipe and regenerate need the part to be the end of its branch (they make it the active leaf first, as the pipeline expects), and take the turn input from the part unless given.profilenames a model profile of the agent of the run (AgentService.resolveProfile,GenerationOptions.withModel); for the user it is refused. The generation question about outdated summaries is passed on withcontext.confirmand the answer yes is reported as a warning. Stopping the command cancels the generation (CancellationToken). -
search-replace-partsandsearch-replace-lore-entrieshavemaxHits(refuse above it) anddryRun(count and show five matches, change nothing).
Tests: ManuscriptWriteCommandsTest, LorebookWriteCommandsTest (a scope that answers the questions), GenerationCommandsTest
(the real generation against the mock server), AgentHandlesTest (the commands through the cage of a run, on the working copy).