Writer content is now exchanged as Markdown by default. An agent that
parses the HTML of text_get_range must pass format: "html", or set
Options > Nelson > Document format back to HTML. That change, the access token,
and the new tools (calc_query, calc_columns, style_set, doc_insert_toc,
writer.change_author) make this a minor version. It also fixes the
cold-start deadlock of GitHub #35/#37: LibreOffice could hang for good when
started with a document.
Security
- Nelson could be exposed to the network or the internet with no
authentication at all — there was no access control anywhere in the
server. Binding to0.0.0.0, or following the Tailscale guide, which runs
tailscale funnel onand publishes the server on the public internet, gave
whoever found the URL read and write access to every open document and
doc_openon any file the user can read — and the guide said none of that.
There is now an access token (http.auth_token, Options > Nelson MCP >
HTTP): once set, every request needs it, asAuthorization: Bearer <token>
or?token=<token>for clients that can only be given a URL, compared in
constant time, applied without a restart. Nelson refuses to bind to
anything but localhost, and refuses to start a tunnel, until a token is
set — a tunnel's traffic arrives from localhost, so only a token can tell
it apart from a local client. The built-in launchers write the token into
the Claude Code, Gemini CLI and OpenCode configs they generate. Local use
with no token set is unchanged. The Tailscale guide now opens with what
exposing Nelson gives away, and the README has a Security section.
make smokechecks the token live: 401 without it, 200 with either form,
applied without a restart, and the server reopened to local clients once it
is cleared
Changed
- Writer content is exchanged as Markdown by default (#2650).
text_get_rangereturned HTML unless told otherwise. Measured on the bench
documents, Markdown is about a third smaller: the RGPD.docxreads in one
call as Markdown (362 000 characters), while its HTML (626 000) exceeds the
500 000-character read limit, so rewriting it from HTML lost 40% of the
text. Rewriting from Markdown loses what Markdown cannot carry (text
colours, underline, footnotes, merged cells);text_apply_rangenow says
so and recommendsformat: "html"or a targeted rewrite for formatted
documents. HTML stays available per call and through Options > Nelson >
Document format; a setting already saved as HTML is kept
Fixed
-
Comments added by an agent showed "(no date)" —
comment_add, the
reply ofcomment_resolveand the workflow status comment are now dated
when they are created -
LibreOffice sometimes never came up when started with a document
(GitHub #35, #37; #2625). Two threads waited on each other: the main
thread, opening the document, held LibreOffice's global lock and waited for
the menu configuration, which a Nelson background thread held while it
waited for the global lock to set menu icons. Reproduced on 5 of 20 cold
starts with/healthpolled during launch. Nelson now does all its UNO
work at startup on the main thread (menu icons, module start, cache
prebuild removed), the sidebar panel and menu commands wait for startup
instead of forcing it, and/healthandtools/listanswer from a
document-event watcher instead of querying LibreOffice from the HTTP
thread. 50 cold starts out of 50 came up.make coldstart-wboxrepeats
the scenario and saves a gdb backtrace of any hang;make smokefails
when UNO is used off the main thread -
A crash in a tool was reported as
retryable(#2637) — only
disconnections, timeouts and a disposed document now are. Parameters that
belong to another action of a merged tool are refused with the action and
the parameters it accepts, instead of being silently ignored -
text_searchdid not look inside tables, andcomment_addcould not
anchor there (#2637).text_searchsearches table cells by default
(include_tables), reporting the table and cell of each match;
comment_addtakesoccurrence, anchors in cells, frames and footnotes,
says where it anchored, and refuses text it cannot find as not retryable -
track_changes_listreturned every change in one block (#2636) — 120
changes on a reviewed contract. It now returns a summary by author and
type, filters byauthorandtype, pages withoffset/limit, and gives
each change its text and paragraph index -
Writing one formula into a range put the same formula in every cell
(#2633) —=C2+D2written into E2:E9 referenced row 2 eight times. The
formula is now filled like Calc's fill handle: relative references shift,
$references stay -
Charts had no legend, and
chart_createignoredhas_legend(#2641);
a chart with several series, or a pie, now gets one by default -
calc_read_rangeread cell by cell (#2630) — four UNO calls per cell,
5.4 s on the main thread for A1:CA2000. It now reads a range in two calls
(getDataArray,getFormulaArray), with the same output.format="rows"
returns values as plain rows and formulas listed apart, about ten times
smaller than the per-cell objects, and allows 100 000 cells per call -
FILTER, SORT, UNIQUE and other array formulas returned one value with
status: ok(#2631). LibreOffice does not spill array results: written
into a single cell,=SORT(FILTER(...))showed its first value and the
rest of the table silently disappeared.calc_write_formula(and a lone
formula incalc_write_range) now recognises a formula whose outermost
function returns an array, measures its result, and enters it as an array
formula over exactly those cells — refusing, without writing anything, if
any of them is occupied. An error result (a FILTER that matches nothing) is
reported as an error, a result over 100 000 cells is refused, and the
answer gives the range, its size and the first rows.=SUM(FILTER(...))
stays an ordinary formula;arrayforces either way. On an explicit range
the formula fills that range and warns when the result does not fit.
calc_read_rangereportsarray_rangefor cells of an array formula -
Closing a document could crash LibreOffice (#2651). Nelson wraps every
change in an undo context, so one Ctrl+Z undoes a whole agent action — and
doc_closecounted as a change: the document was destroyed while its undo
manager still had that context open. After a rewrite involving tables,
notes or comments, LibreOffice aborted, taking every open document with
it. It was in every version since 0.7.1.doc_closenow runs outside any
undo context and leaves and clears the undo stack before closing; a tool
declaresopens_undo_context = Falsewhen it destroys its document.
make smokerewrites a document with a table twice and closes it, three
times over — which crashed LibreOffice before the fix -
Markdown given to
text_apply_rangelanded as literal#and**
(#2635). The tool says it takes Markdown or HTML, but the import filter came
from a global format setting, HTML in practice, so a Markdown report went
through the HTML filter and came out as plain text with its syntax showing —
withstatus: ok. The filter now follows the content: HTML when it has
HTML tags, Markdown when it has Markdown syntax (headings, lists, tables,
emphasis, code, links), using LibreOffice's own Markdown filter;format
forces one or the other -
The first full-text search on a long document took seconds — building
the index stemmed every word through the pure-Python Snowball stemmer, 90%
of the time. Moby Dick is 213 000 words but 17 000 distinct ones: each is
now stemmed once, same index: in LibreOffice the build logs 0.6 s instead
of several seconds. A first call right after opening a long document can
still wait a few seconds while LibreOffice lays the document out on the
thread tools run on; the tool description now says so -
Reads had no size limit, and the two settings meant to set one were
never read (#39).text_get_rangereturned Moby Dick as 1.36 MB of HTML
in one result, andcalc_read_range13 MB for A1:CA2000, while
writer.max_content_charsandcalc.max_rows_displaysat in Options with
no effect.text_get_rangenow stops atmax_chars, or the Max Content
Size setting (50 000 characters), never above 500 000, cutting on a clean
boundary rather than inside a tag;calc_read_rangestops at the Max Rows
Display setting (1 000 rows) and 20 000 cells per call, across all the
ranges asked for. A capped result is never silent:truncated: true, the
totals, ahinton reading the rest, and for Calcnext_range, the exact
range to read next. Both settings take effect without a restart.make smokereads a 65 000-character document and 1 500 rows, follows
next_rangeto the last row, and changes both settings live -
Listing documents marked them modified and wrote an id into them
(#2627).doc_idwas stored in each document as aNelsonDocIdcustom
property, written the first time Nelson touched it —doc_list_open, the
_resolvedof any call, even resolvingid:— so documents the agent had
only listed could ask "Save changes?" on close, and a saved.odt/.docx
carried the id, visible in File > Properties > Custom Properties to
whoever received it. The id now lives in memory: stable while the document
is open (Save and Save As included), distinct for every open document,
forgotten when it closes, and nothing is written into the document. A
doc_idtherefore no longer survives a LibreOffice restart — usepath:
to find a document again across sessions.doc_save_askeeps the
document'sdoc_idinstead of minting a new one. Files saved by earlier
versions may still hold aNelsonDocIdproperty; Nelson ignores it and
does not remove it (that would modify the document) — delete it from
File > Properties > Custom Properties if you want it gone.make smoke
opens a file outside Nelson, lists and addresses it, and checks over UNO
that it is neither modified nor carrying the property -
make wbox-downkilled itself — itspkill -fpattern matched the
shell runningpkill, whose command line contains the pattern, so make
stopped there andmake wbox-deploynever deployed. The pattern no longer
matches its own text -
A read could return another document's paragraphs, and edits made in
the GUI were invisible to navigation and search — two faults in the
document caches. The paragraph cache was keyed by theid()of a pyuno
proxy, but every lookup of a document returns a new proxy: the cache
almost never hit, never emptied (holding on to closed documents'
paragraphs), and when Python reused an id a call read a different or
closed document — a search afterdoc_opencounting 0, or
"SwXParagraph: disposed or invalid" in the middle of an edit. And the
document service was never connected to the event bus, so the
heading-tree, bookmark, proximity and full-text-index caches, which are
keyed by URL and do persist, were never told of any change: after an edit
nav_treeandtext_search_fulltextkept answering about the old text.
Caches are now matched by UNO identity, each document has a modify
listener, so an edit from any source — a tool or the user — invalidates
them, and a closed document's entry is dropped. A tool still sees stable
paragraph numbering while it runs (a batch across all its steps) and
invalidates once when it returns; Nelson's hidden heading bookmarks and
saves invalidate nothing.make smokechurns 30 documents, then edits a
document over UNO from outside Nelson and checksnav_treeand the index
see it -
nav_heading_contentcould return the wrong section — it walked the
outline to the heading, then looked the heading up again by its title, so
with two headings of the same title and level (a "Notes" section in every
chapter) it returned the first one's text, without an error. It now reads
from the heading's own paragraph index.nav_outlinegives every heading
itspath("2.4", whatnav_heading_contenttakes) andpara_index,
so an agent no longer counts positions by hand;nav_heading_contentalso
accepts an exact title when only one heading has it, lists the matching
paths when several do, and every lookup error states the expected format -
Responses named two documents at once —
_resolvedwas read after the
tool ran, from the document that was active before the call:doc_open
anddoc_createreturned the new document'sdoc_idnext to a_resolved
naming the previous one, anddoc_closea_resolvedwith no id, since
the document it described was gone._resolvedis now read before the
call, and only for tools that work on a document -
doc_closereported success whether or not it closed anything (#36,
fault 2). It returned "Document closed." wheneverclose()did not raise,
and a close LibreOffice declined or ignored looked exactly like a real one.
It now checks the document is really gone, returnsdocument_not_closed
(with the reason when LibreOffice vetoed) otherwise, and says which
document it closed (closed: title and URL), so a caller can tell a close
of the wrong document from the right one.make smokecloses a document
that is not the active one and checks which one went. Faults 1 and 3 of
#36 are not reproduced yet -
Calc charts landed on the sheet their data came from (#31). A chart over
'Data Sheet'.A1:B5was added toData Sheet,sheet_namewas ignored by
calc_chart create, andpositionwas measured on the active sheet while
the chart went on another. Charts now go where Insert > Chart puts them —
onsheet_nameif given, else the sheet named inposition, else the
active sheet — with the data read from whereverdata_rangepoints. The
response names the chart and both sheets -
A second chart on another sheet failed with an empty error (#32). New
charts were named after the count of charts on their own sheet, but names
must be unique across the document, soChart_0came back on the second
sheet andaddNewByNamerefused it with an exception that has no text.
Names are now unique across the workbook, and chart errors always carry a
message -
Calc writes accepted a sheet prefix that contradicted
sheet_name
(#33). The refusal introduced in 0.12.1 lived in the read path only;
calc_write_rangeandcalc_commentpicked the prefix silently. Both now
refuse the contradiction, and an unknown sheet lists the sheets that exist
on writes as it already did on reads. Sheet names compare case-insensitively,
as Calc does. A single-cell read no longer echoes the address with an
upper-cased sheet prefix ('DATA SHEET'.B2): it returnsB2, like a range -
Reading a Writer document's structure changed the document, and could
save it behind the user's back —nav_tree,nav_heading_children,
nav_headingandnav_surroundingsgive every heading a hidden
_mcp_<hex>bookmark so agents can address it by a stable name. Adding
them marked the document modified, left undo steps, and — when the
document had a file — calledstore()right away, so a pure read saved
whatever the user had typed, and the bookmarks ended up in.odt/.docx
files shared with others.bookmark_cleanupsaved silently too. Neither
saves any more, and creating the bookmarks no longer touches the modified
flag or the undo stack. A new option decides what happens to them,
writer.nav.heading_bookmarks(Options > Nelson MCP > Writer navigation):
strip_on_save(default) keeps them while the document is open and
leaves them out of every save — removed just before the file is written and
put back under the same names right after, so agents' names stay valid;
keepsaves them with the document, as before;offnever creates
them, and headings are addressed by paragraph index.make smokesaves a
document under each value and counts the bookmarks incontent.xml -
The call right after
doc_openordoc_createcould miss the new
document (#34) — both returned as soon as LibreOffice had loaded it, but
its window only became the active one a few tens of milliseconds later. The
next call, which resolves the active document, then failed with
no_document(retryable: false), or silently worked on whatever document
was active before. It only happened with the real GUI and when the Start
Center was the current component, which is why headless tests never saw it.
Both tools now activate the new document's frame before returning — the
synchronous step the_documentpath always took, and the reason naming the
document explicitly never raced — and say so in awarningif it still is
not active. When documents are open but none is active, the error is now
no_active_document, retryable, with a hint to pass_document.
make smoke-wboxreproduced the race on every run before the fix and passes
after it;make smokegains a check that the first opened document is the
one the next call targets -
A LibreOffice restart left every MCP client silently dead (#38) — a
session id from before the restart was answered with409, which no client
acts on, so the client kept listing the server as connected and failed on
every call until someone reconnected it by hand. It is now404, the signal
the Streamable HTTP spec requires a client to answer with a fresh
initializeon the same URL — which Nelson already accepted. Conformant
clients now recover by themselves. It restores the connection, not the work:
documents that were open when LibreOffice went down are still gone -
DELETE /mcpclaimed to end a session it did not end (#38) — it answered
200and terminated nothing. Nelson has one session id for the whole process,
shared by every client, so ending it on one client's request would cut all
the others off. It now answers405, the spec's answer for a server that
does not let clients terminate sessions
Added
-
writer.change_author: sign the agent's tracked changes with a name of
its own (#2636). Writer signs a tracked change with the user's name
(Tools > Options > User Data), so an agent's edits could not be told
from the user's, or read "Unknown Author" on a profile without a name.
Set this option, e.g. to "AI agent (Nelson)", and Nelson puts that name
in the LibreOffice profile for the length of each editing call, then
puts the user's name back. If LibreOffice dies mid-call, the name is
restored at the next start.change_setnow says who will sign, and
warns when that is "Unknown Author" -
ISO dates are written as dates, and
calc_columnssizes columns and
freezes panes (#2632).calc_write_rangeandcalc_write_formulastored
"2026-10-01" as text, so a planning column neither sorted nor formatted as
dates. ISO dates and date-times (2026-10-01,2026-10-01 14:30) now
become real date values, counted from the document's own NullDate, and get
a YYYY-MM-DD format only if the cell still has the standard one; a leading
apostrophe keeps one as text. Nothing else changes: "1-2" stays text. The
newcalc_columnssets a width in mm, auto-fits, hides or shows columns,
and freezes or unfreezes panes at a cell, answering with the widths before
and after -
calc_query— answer a question about a sheet without reading it all
(#2630). Filters (=,!=,<…,contains,in,empty,
not_empty), column selection, sorting,group_bywithsum,avg,
min,maxandcount, on columns named by header or letter; numbers
stored as text compare as numbers. It reads only the columns the query
uses, in bulk, and never changes the sheet. "Top 10 emitters of 2022" on a
50 000-row sheet is one call instead of megabytes of cells or sorting the
user's table in place.calc_sheet_overviewnow also gives each column's
header, type and filled count, and five sample rows -
Page numbers, tables of contents and page breaks for reports (#2634).
header_footer_setturns{page},{pages},{date}and{title}into
real fields, soPage {page} of {pages}numbers pages instead of printing
the braces ({{for a literal brace);doc_insert_tocinserts a table of
contents built from the headings, at the start, after the first heading or
before a paragraph;text_insert_breakstarts a paragraph on a new page or
column.make smokesaves such a report as .docx and finds the PAGE and
NUMPAGES fields, the TOC field and the page break in the XML -
Choose Markdown or HTML for reading Writer content (#2650). The format
setting existed in the code but was never declared, so reads were always
HTML. It is now Document format for agents in Options (HTML by default
for now), andtext_get_rangetakesformatper call and says which
format it returned. Markdown costs about a third less than HTML on a
formatted document -
style_set— change a style (#2647). "Change the font of the body
text" had no answer:style_listandstyle_infoonly read.style_set
changes a paragraph, character or page style through readable properties —
font_name,font_size(pt),bold,italic,color,parent_style;
space_before,space_after(mm),line_spacing(%),alignfor
paragraphs; margins (mm) andorientationfor pages — rather than raw UNO
names and units. It answers with each property's value before and after,
refuses an unknown property or a bad value with what is allowed, suggests
close names for a misspelt style, and warns when a font is not installed
(LibreOffice accepts any name and shows a substitute). One undo step.
make smokerestyles the body text and checksstyles.xmlafter saving -
text_searchandtext_readreport each paragraph's style, and
text_searchfilters on it (style,exclude_style). A table of
contents repeats every chapter title in body text; without the style an
agent searching for "CHAPTER" could not tell an entry from the heading, and
restyling the results restyled the table of contents too. A style filter
searches the body with the direct backend -
make smoke-wboxand thewbox-*targets — run LibreOffice's real GUI
inside a nested wbox compositor from make, offscreen by default and visible
withWBOX_VISIBLE=1. Headless LibreOffice has no event loop and no Start
Center, which hides a class of bugs; the smoke suite run this way already
reproduces the document-activation race of #34 on every run