# What changed ## 1. Root cause of the bug `agent: round 1: llm: ollama error: XML syntax error on line 5: element closed by` is Ollama's own Go-side `encoding/xml` parser failing while rendering Qwen's tool-calling chat template (which wraps tool calls/results in XML-ish delimiters). Two things made this worse and are now fixed: - `compactToolHistory` in `services/agent/internal/infrastructure/agentrun/agent.go` truncated old tool results with a blind byte slice, which could cut a tag in half. Fixed with `safeHeadCut`/`safeTailCut`, which snap to the nearest `<`/`>` (and a valid UTF-8 rune boundary) so a truncated tool result can never hand back a dangling, malformed tag. - There was no bounded retry for this specific, usually-transient error class. Added `ollamakit.IsTemplateParseError` + a bounded 2-attempt retry of the same round in `Agent.Run`, instead of failing the whole turn on the first occurrence. ## 2. Word / Excel / PowerPoint document management New package **`pkg/officekit`** (stdlib only — `archive/zip` + `encoding/xml`, no new dependencies) that creates and edits **real** `.docx`/`.xlsx`/`.pptx` files. Nine new agent tools, modeled on Claude Code's document-editing skills: - `create_document` / `read_document` / `edit_document` (Word) - `create_spreadsheet` / `read_spreadsheet` / `edit_spreadsheet` (Excel) - `create_presentation` / `read_presentation` / `edit_presentation` (PowerPoint) **Design choice that structurally prevents this tool family from reintroducing the Ollama bug:** the model only ever passes plain structured fields (a paragraph's text and style, a grid of cell values, a slide's title and bullets) and gets back a short plain-text summary. It never authors or receives OOXML/XML — `pkg/officekit` does 100% of the actual XML generation and parsing, server-side, using Go's typed XML encoder (always well-formed, always correctly escaped). Files persist through the workspace service's **existing** `UploadFile`/`DownloadFile` gRPC RPCs (binary-safe `[]byte` fields, already present on `WorkspaceServiceClient`) — so this required **zero protobuf/gRPC changes**. New/changed files: - `pkg/officekit/zipkit.go`, `docx.go`, `xlsx.go`, `pptx.go` (new) - `services/agent/internal/infrastructure/toolexec/office.go` (new) - `services/agent/internal/infrastructure/toolcatalog/catalog.go` (+9 tool defs) - `services/agent/internal/infrastructure/toolexec/executor.go` (+9 dispatch cases) - `services/agent/internal/infrastructure/agentrun/agent.go` (system prompt + safe-cut + retry) - `pkg/ollamakit/client.go` (`IsTemplateParseError`) ## Verification performed No Go toolchain ships in the delivery environment by default, so I installed one (`apt-get install golang-1.23-go`, from the already-allowed `archive.ubuntu.com`) to actually verify this, rather than just asserting it compiles: - `pkg/officekit` builds and `go vet`s cleanly in isolation. - A smoke test builds a `.docx`, `.xlsx`, and `.pptx` from scratch, edits each (append paragraph + find/replace, sparse cell writes, add slide + update slide), and reads every one back — including round-tripping text containing literal `<`, `>`, and `&` correctly escaped. - Every generated XML part in all three files was checked for well-formedness (`xml.dom.minidom.parseString`). - The **entire module** (`go build ./...` and `go vet ./...`, all services, not just the touched ones) compiles cleanly against the real `go.mod`/`go.sum` — I cloned the project's own pinned dependency versions from their GitHub mirrors (matching what `go.sum` already recorded) to resolve them under `GOPROXY=off`, since `proxy.golang.org` isn't reachable here. - `gofmt -l` is clean on every touched file. ## 3. Follow-up: "expected element type ... but have ..." (same bug family, different phrasing) Go's `encoding/xml` doesn't have one error shape — `IsTemplateParseError` was only matching its tokenizer-level `SyntaxError`s ("XML syntax error on line N: ..."), which is what "element closed by" was. "expected element type `` but have ``" is a *different* error type from the same package (an `UnmarshalError`, raised when struct-based decoding hits an unexpected root element) — same root cause, no shared prefix, so the old substring check missed it. Fixed by generalizing the detector (`pkg/ollamakit/client.go`, `IsTemplateParseError`) from an enumerated list of exact phrasings to the actual shared signature: an "ollama error" whose message either mentions "xml" or names an angle-bracketed ``-shaped fragment — the one place in this error path literal element/type names show up, regardless of which `encoding/xml` error type produced them. This should now catch future variants (missing end tag, bad entity, duplicate attribute, etc.) without needing another patch each time a new phrasing surfaces. ## 4. Office document skills: silent cell loss in `edit_spreadsheet` **Symptom:** `edit_spreadsheet` (`SetXlsxCells` in `pkg/officekit/xlsx.go`) would, on some calls, silently drop one of several cell writes aimed at rows that didn't exist in the workbook yet — no error, no panic, the RPC reported success, but the written `.xlsx` was missing a cell the model had just asked for. It reproduced intermittently: the same batch of writes would sometimes round-trip correctly and sometimes lose a cell, which is what made it hard to pin down — `go test` first caught it as a flake in `TestSetXlsxCellsFormulaAndOrdering` (`pkg/officekit/officekit_test.go`), failing on roughly half of runs. **Root cause:** `SetXlsxCells` indexed newly-created rows with a `map[int]*sRow` — a pointer taken as `&ws.SheetData.Rows[i]` at the moment a row was first appended. Go's `append` reallocates a slice's backing array once its capacity is exceeded, copying existing elements into the new array; any pointer taken into the *old* array before that point keeps pointing at now-orphaned memory. A second write landing in the same new row, processed after a later `append` triggered that reallocation, mutated the stale pointer's copy — which nothing else ever reads — instead of the live row. Since Go map iteration order is randomized by design, whether a reallocation fell between two writes to the same row (and so whether the bug fired) varied from run to run and from request to request: the same tool call could succeed or silently corrupt the file depending on map iteration order the caller has no control over. **Fix:** `pkg/officekit/xlsx.go`'s `SetXlsxCells` now indexes rows by their integer position (`map[int]int`) instead of a pointer. An index has no equivalent failure mode: `ws.SheetData.Rows[idx]` always re-resolves against whatever backing array the slice header currently points to, reallocated or not. Cell writes are also now applied in a deterministic ascending (row, column) order rather than raw map iteration order, so behavior no longer depends on Go's randomized map ordering at all (the final on-disk row/cell order was already restored by the existing `sortSheetData` sort, regardless; this change makes the *write sequence* itself deterministic too, which matters for debugging a batch of edits, not just for the final file). Added `TestSetXlsxCellsManyNewRowsNoDataLoss` (`pkg/officekit/officekit_test.go`): writes two cells into each of 200 brand-new rows — enough to force several slice reallocations — and reads every one back. Verified against the pre-fix code that this test fails deterministically (not intermittently) on the old pointer- map version, confirming it would have caught this the first time. `go test ./pkg/officekit/... -race -count=20` is clean after the fix. This was the one correctness bug found in `pkg/officekit`; the rest of the office-document path — `docx.go`/`pptx.go`'s append/replace operations, `zipkit.go`'s zip part copy/replace, the `toolexec/office.go` tool handlers, their `toolcatalog/catalog.go` schemas, and the workspace service's binary-safe `UploadFile`/`DownloadFile` RPCs — was reviewed and is sound: no other instance of the pointer-into-a-growing-slice pattern exists anywhere in `pkg/officekit`, every `create_*`/`read_*`/`edit_*` tool name in the catalog has a matching dispatch case in `executor.go`, and `AppendPptxSlide`/`AppendDocxParagraph` keep `[Content_Types].xml` and the relevant `.rels`/`presentation.xml` parts in sync on every write rather than letting them drift. ## 5. Office document skills: an unnecessary round-trip on every "create a document" request **Symptom:** a simple, one-shot document request — e.g. "Crées un CV document Word d'Ingénieur en Machine Learning" — took noticeably longer than it needed to, even though `pkg/officekit` itself (the actual `.docx`/`.xlsx`/`.pptx` generation) is pure in-memory `archive/zip`/`encoding/xml` work and runs in low-single-digit milliseconds — it's never the bottleneck. **Root cause:** both the main `SystemPrompt` (`services/agent/internal/infrastructure/agentrun/agent.go`) and `read_office_skill`'s own tool description (`toolcatalog/catalog.go`'s `officeSkillToolDescription`) *mandated* a `read_office_skill` call "BEFORE your first create_document/ edit_document, create_spreadsheet/edit_spreadsheet, or create_presentation/edit_presentation call in a conversation" — with no exception for a plain, ordinary document. On a local model, a tool call is a full inference round-trip, not a free lookup. So every fresh conversation's first document request paid for an extra round just to read a markdown file, before the model had even started composing the document's actual content — on top of round 0's `think=high` reasoning pass (`DefaultThinkPolicy`) and the round that generates the document's full paragraph list. For a one-page CV, that extra skill-read round added a full local-model generation pass that contributed nothing a well-written tool schema couldn't already convey. **Fix (no behavior change for anything nontrivial, same two files):** - `create_document`'s own description now inlines the handful of conventions that actually matter for an ordinary document (lead with one `heading1`, `heading2`/`heading3` for sections, `bullet` for list-shaped content, `normal` paragraphs split by idea) plus its honest limitations (no tables, images, headers/footers, numbered lists, or mid-paragraph inline formatting) — the same information `docx.md` already had, just paid for once, in the schema the model already sees on every call, instead of behind a mandatory separate round-trip. - `read_office_skill`'s description and the matching `SystemPrompt` bullet both changed from "call this before your first document tool call" to "optional — skip it for a straightforward document and call create_document/create_spreadsheet/create_presentation directly; reach for this before something that leans on a format's sharper edges (Excel formulas, a multi-sheet workbook, a multi-slide deck, `edit_document`'s `replace_text` run-splitting caveat), or when unsure a feature someone asked for is supported at all." Net effect: a plain "make me a CV/memo/simple spreadsheet" request now costs one fewer full LLM round-trip than before — the skill files and `search_office_skills` are both still there, fully documented, and still the right call for anything that actually needs them (tables, formulas, multi-sheet/multi-slide work, or edit-operation nuances); they're just no longer a toll booth in front of the common case. This is a prompt/tool-description change only — `pkg/officekit` itself is untouched here (see §4 above for its one real bug fix) and the actual per-request cost of generating a CV's worth of paragraph text with a local model is inherent to doing that work at all, not something this change (or any prompt change) removes. Also added a second line of defense: if the plain retries don't clear it (the same template error survives every fresh attempt — i.e. it's deterministic, not transient), `Agent.Run` now blanks the most recent tool result(s) in that round's local message history — the newest, and so most likely, thing about the request that differs from the last one that worked — and tries once more before giving up the whole turn (`degradeRecentToolMessages` in `agentrun/agent.go`). This never touches persisted conversation history, only the in-memory copy used for this one request. ## Known limitations (documented honestly, not hidden) - `edit_document`/`edit_spreadsheet`/`edit_presentation` only support a deliberately small set of structured operations (append paragraph, find/replace; sparse cell writes; add/update slide) — by design, so the model never needs richer markup-shaped arguments. - PowerPoint slides are two fixed, absolutely-positioned text boxes (title + bullets) — no custom layouts, images, or manual positioning. - `read_document`/`read_spreadsheet`/`read_presentation` extract plain text only — tables, images, and complex formatting in a real user-uploaded Office file aren't represented. - No PDF support (there's no `create_pdf`/`read_pdf`; the `overview` skill below tells the model to say so rather than silently substituting a `.docx`/`.pptx`). ## 4. Office document *skills* (Claude Code-style progressive disclosure), and multi-sheet workbooks The nine document tools from §2 already told the model *what fields each tool takes* (the JSON-schema `Parameters`), but that schema is sent to Ollama on **every single request**, so it has to stay terse — there was nowhere to put the kind of guidance Claude Code's own `SKILL.md` files carry: which style to use for a document's title, what `replace_text` does and doesn't match, what a `bullet` can't do, how to pick between a single- and multi-sheet workbook, and so on. Two concrete problems this caused in practice: the model had no documented way to discover that `create_spreadsheet` only ever wrote one sheet (see the limitation this removes, below), and nothing told it to say "I can't make a PDF" instead of quietly handing back a `.docx` when a user specifically asked for one. **New package `pkg/skillkit`** (stdlib only — `embed` + `sort` + `strings`) loads four short, human-editable markdown files (`pkg/skillkit/skills/{overview,docx,xlsx,pptx}.md`) at build time via `go:embed` and serves them through `List()` (name + one-line description, for the tool's own description), `Read(name)` (one skill's full body), and `Names()` (for the tool schema's `enum`) — the same progressive-disclosure shape Claude Code uses: a one-line description is always in context, the full skill is only loaded when the model actually asks for it. **New tool `read_office_skill`** (`toolcatalog.Defs()` + `toolexec/office_skill.go`, dispatched in `executor.go` like `write_todos`/`solve_ml_math` — pure in-memory lookup, no gRPC call) takes one `name` argument constrained to `skillkit.Names()`. Its tool description is *generated from* `skillkit.List()` at catalog-build time, so the catalog and the skill files can never drift apart — renaming or adding a skill file is the only edit needed. `SystemPrompt` in `agentrun/agent.go` instructs the model to call it before its first `create_*`/`edit_*` office tool call per conversation. Added to `toolcatalog.subagentToolNames` too (read-only, harmless for a sub-agent that already has `read_document`/`read_spreadsheet`/ `read_presentation`). **Multi-sheet `.xlsx` workbooks, wired up** (closing the limitation `BuildXlsx` already had unused room for): `create_spreadsheet` now accepts either the original single-sheet `rows`/`sheet_name` pair *or* a `sheets` array (`{name, rows}` per tab) — `office.go`'s new `parseSheetsArg` normalizes either shape to `[]officekit.Sheet` before calling `officekit.BuildXlsx`, which now actually writes every sheet given (its own worksheet part, workbook `` entry, and `workbook.xml.rels` relationship each) instead of silently keeping only `sheets[0]`. `read_spreadsheet`/`edit_spreadsheet` both gained an optional `sheet` field (1-based index or name); resolving it reads the target workbook's own `xl/workbook.xml` + `xl/_rels/workbook.xml.rels` (new `resolveXlsxSheetPart`, with a same-as-before `sheet1.xml` fallback if either part can't be parsed) rather than assuming sheet order, so it also works correctly against a user-uploaded workbook whose sheets were reordered in Excel. `read_spreadsheet`'s result now leads with a `# sheet "Name" of N: A, B, C` line whenever the workbook has more than one sheet, so the model (and the person reading the tool output) can see what else is there without a second call. New/changed files: - `pkg/skillkit/skillkit.go`, `pkg/skillkit/skills/*.md` (new) - `services/agent/internal/infrastructure/toolexec/office_skill.go` (new) - `pkg/officekit/xlsx.go` (multi-sheet `BuildXlsx`; sheet-aware `ReadXlsxCells`/`SetXlsxCells`/new `ReadXlsxSheetNames`) - `services/agent/internal/infrastructure/toolexec/office.go` (`parseSheetsArg`; `sheet` plumbed through read/edit) - `services/agent/internal/infrastructure/toolcatalog/catalog.go` (`read_office_skill` tool def + `officeSkillToolDescription`; updated `create_spreadsheet`/`read_spreadsheet`/`edit_spreadsheet` schemas; `read_office_skill` added to the sub-agent whitelist) - `services/agent/internal/infrastructure/toolexec/executor.go` (dispatch case) - `services/agent/internal/infrastructure/agentrun/agent.go` (system prompt) ### Design choices worth calling out - **Markdown, not Go, is the unit of change.** Improving the guidance for how to write a `.docx` is a `pkg/skillkit/skills/docx.md` edit, same as editing a Claude Code `SKILL.md` — no Go recompile of prose baked into a string constant, no risk of a typo in a hand-escaped Go string literal. - **Generated, not duplicated, tool description.** `read_office_skill`'s own description lists every available skill by reading `skillkit.List()` at catalog-build time rather than a second hand-maintained list, so adding a fifth skill file later (e.g. if PDF support gets built) can't leave the tool description stale. - **Backward compatible.** `create_spreadsheet` with the original `rows`/`sheet_name` fields, and `read_spreadsheet`/`edit_spreadsheet` with no `sheet` field, behave exactly as before (first/only sheet) — nothing already relying on the single-sheet shape breaks. ## 5. RAG over the office skills (search_office_skills) `read_office_skill` (§4) is coarse: it returns a whole skill file, which is fine for "give me the full docx conventions before I start" but wasteful for one narrow question mid-task ("can a cell hold a formula?"). This adds a retrieval-augmented complement: semantic search over the same skill content, returning just the matching section(s). **New `pkg/skillkit/rag.go`**, same package as §4's `skillkit.go` (so it shares the embedded `registry` directly, no second embed): - `chunkSkill` splits one skill's markdown on its own `## ` headings — every skill file already follows that convention, so a section *is* the right retrieval granularity; no char-budget chunker (like `services/knowledge/internal/domain.ChunkText`, built for arbitrary source code) is needed. - `Index`, built with `NewIndex(embedder)`, embeds every chunk (~20-30 across the four files) exactly once, lazily, on the first `Search` call — not eagerly at startup, and not re-embedded on every call. A failed first build isn't cached as permanent failure (deliberately not a `sync.Once`): a transient Ollama outage on the first call just means the next call retries the build, rather than `search_office_skills` staying broken for the rest of the process's life. - `Search(ctx, query, topK, skillFilter)` embeds the query and ranks every chunk by cosine similarity, in memory — no ANN index, no database; brute-force over a few dozen vectors is sub-millisecond and outgrowing that would mean the skill corpus itself has gotten too big, which is a reason to add a fifth skill file, not a reason to reach for pgvector. **Why in-process, not through the existing `services/knowledge` Postgres/pgvector pipeline** that already backs `search_codebase`: that pipeline is the right tool for what it indexes — a workspace's actual source files, which are large, per-user, and change constantly, so they need persistence and incremental re-indexing. The office skills are the opposite on every axis: a few dozen short, `go:embed`-fixed sections that are identical for every workspace and never change between deploys. Routing this through the knowledge service would mean a new gRPC message, a schema migration to keep skill chunks from polluting a workspace's own `search_codebase` results (the `documents` table has no scoping column today), and a network round trip — for content that's already sitting in the binary. Embedding it once in memory is simpler, faster, and has nothing to keep in sync. **New tool `search_office_skills`** (`query`, optional `skill` filter), alongside `read_office_skill` in the catalog, dispatch, system prompt, and sub-agent whitelist. `toolexec.Executor` gained a `SkillIndex *skillkit.Index` field, built in `New` from a new `embedder skillkit.Embedder` parameter — `main.go` passes the same `*ollamakit.Client` already used for chat (it satisfies the interface as-is; no adapter). Nil-embedder is handled the same way `Subagent` already is: the field stays nil and the tool reports itself unavailable instead of panicking. New/changed files: - `pkg/skillkit/rag.go` (new) - `services/agent/internal/infrastructure/toolexec/office_skill.go` (`searchOfficeSkills`) - `services/agent/internal/infrastructure/toolexec/executor.go` (`SkillIndex` field, `New`'s new `embedder` param, dispatch case) - `services/agent/internal/infrastructure/toolcatalog/catalog.go` (`search_office_skills` tool def; sub-agent whitelist) - `services/agent/internal/infrastructure/agentrun/agent.go` (system prompt) - `services/agent/cmd/server/main.go` (`toolexec.New`'s new argument) ### Verification not performed in this environment Unlike §2/§3 above, I could not install a Go toolchain here (no network egress in this environment at all — the earlier `apt-get install golang-1.23-go` route isn't available this time) and so could not run `go build ./...`, `go vet ./...`, or `gofmt -l` against these changes. I've kept every edit close to the existing, already-verified code's patterns (the same `zipPart`/`readZipPart`/`replaceZipPart` helpers, the same `argStr`/`errResult` tool-arg conventions, the same `xml:"id,attr"` style already used elsewhere in `officekit` for namespaced OOXML attributes) specifically to minimize the risk of a compile error, but this should be verified with `go build ./... && go vet ./...` and `gofmt -l .` before this is treated as merged, and the three new skill files are worth a skim for tone/accuracy even though they're not code. ## 6. Fixing real correctness bugs in the office document skills §5 above shipped without a Go toolchain to verify it, and said so. This pass went back over all of `pkg/officekit`, `pkg/skillkit`, and the office tool wiring (`toolexec/office.go`, `office_skill.go`, `toolcatalog/catalog.go`) specifically looking for correctness bugs — not style — and fixed four real ones, plus two small, low-risk feature additions that Claude Code's own document-editing skills already offer and that close gaps these fixes exposed. No Go toolchain or network access was available in this environment either (see "Verification performed" below for exactly what that means here). ### Bug 1 — `edit_document` silently destroyed tables, images, and anything else it didn't model, on a real (not self-authored) `.docx` `AppendDocxParagraph` and `ReplaceDocxText` both worked by unmarshaling `word/document.xml` into `wDocument` — a struct that only models direct `` children (paragraphs and the trailing `sectPr`) — mutating it, and re-marshaling the whole document back. Re-marshaling a struct that doesn't model something *drops that something*: a table, an image, a header reference, a second run in a paragraph, tracked changes — anything beyond "a flat list of paragraphs" in a real, Word-authored or previously-edited document would silently disappear the moment `edit_document` touched that file. `read_document` had the same blind spot one layer up: `wBody.Paragraphs` only captures `` elements that are *direct* children of ``, so a paragraph nested inside a table cell (`///`) was never unmarshaled into it at all — a table's content wasn't just unreconstructed as a grid (the already-documented limitation), it was completely invisible to `read_document`, `replace_text`, and anything built on top of them. Fixed by moving both operations off the unmarshal/modify/marshal pattern entirely: - **`read_document`** now walks `document.xml`'s raw token stream (`encoding/xml`'s `Decoder.Token()`, not `Unmarshal`) collecting every `` wherever it occurs, nested in a table or not, in document order — so a table's text is visible (each cell's paragraphs as their own lines; the grid shape itself still isn't reconstructed, which is now the accurately-scoped limitation). - **`edit_document`'s `append_paragraph`** now marshals *only* the one new paragraph and splices its bytes directly into the existing `document.xml`, immediately before the body's trailing `` (OOXML requires section properties to stay the body's last child) or before `` if there's none — so every byte of the original document this package doesn't model survives untouched. The spliced fragment carries its own explicit WordprocessingML namespace declaration (`wParagraphFragment`, distinct from the `wParagraph` type `BuildDocx` still uses internally) so it resolves correctly whether the target document uses Word's own `w:`-prefixed convention or this package's unprefixed default-namespace one — without paying for a redundant `xmlns` on every paragraph of a freshly-built document, which sharing one type between both call sites would have meant. - **`edit_document`'s `replace_text`** now matches and rewrites text directly inside `...` elements in the raw XML (a small, locally-scoped regex selecting the exact local element name `t`, with or without a namespace prefix — not `tab`/`tbl`/`tc`, which start with the same letter), decoding/re-encoding only the matched inner text through the same entity (un)escaping `xml.Marshal`/ `EscapeText` already use. Everything else in the document — tags, attributes, tables, images — passes through byte-for-byte unchanged. One real, newly-documented trade-off: a phrase Word has split across more than one run (common after spell-check or revision tracking) may not be found as a single match, since each `` is matched independently — the docx skill now says so. ### Bug 2 — `edit_spreadsheet` could write cells in an order Excel flags as needing "repair" `SetXlsxCells` appended a newly-written row/cell to the end of the in-memory slice regardless of its row/column number, so a sparse, arbitrarily-ordered set of writes (e.g. writing `"A1"` in the same call as an already-present `"A5"`) could marshal `xl/worksheets/sheetN.xml` with rows or cells out of ascending order — which Excel's own parser (unlike most lenient readers) can flag as needing repair on open. Fixed with `sortSheetData`, called right before marshaling in `SetXlsxCells`: rows sorted ascending by row number, and cells within each row sorted ascending by column, so a schema-valid order comes out regardless of what order the (inherently unordered, since it's a Go map) set of writes was applied in. Covered by `TestSetXlsxCellsFormulaAndOrdering`, which asserts on the raw XML byte offsets, not just this package's own reader, since this bug would never have shown up through this package's own (order-independent) `ReadXlsxCells`. ### Bug 3 — a latent schema-order bug in `wRPr` While adding italic/underline support (feature addition below), found that `wRPr`'s one existing formatting field (`Bold`) was never ordered against anything else, so there was nothing to get wrong yet — but the straightforward way to add `Italic`/`Underline`/keep `Sz` would have produced a `` child order (`b`, `u`, `sz`, `i`, declaration order) that doesn't match OOXML's actual `CT_RPr` sequence (`b`, `i`, `sz`, `u`, ...). Real-world readers are typically lenient about this, but there's no reason to ship something schema-invalid when getting the field order right costs nothing; `wRPr`'s fields are now declared in actual schema sequence. ### Feature addition — italic/underline paragraph formatting (docx) `Paragraph` gained `Italic`/`Underline` bool fields alongside the existing `Bold`, wired through `create_document`'s paragraph schema and `edit_document`'s `append_paragraph` operation (which — separately — was also missing `bold` from its own JSON schema even though the executor already read it off `call.Arguments`; a real, if minor, gap of its own, now fixed by adding `bold` there too). `pkg/skillkit/skills/docx.md` documents both fields. ### Feature addition — live formulas (xlsx) A cell value starting with `"="` (e.g. `"=SUM(A1:A4)"`) is now stored as a real Excel formula (``) rather than literal text — previously `isNumeric` rejected it and it silently became an inline-string cell showing the formula text, never actually computing anything in Excel. `create_spreadsheet` and `edit_spreadsheet` classify values through one shared `setCellValue` now (previously `BuildXlsx`'s create path and `SetXlsxCells`'s edit path each had their own, slightly different, hand-rolled numeric/text branch — now unified, so both paths behave identically). `read_spreadsheet` shows a formula cell's cached computed value when one exists (always true for a real Excel-authored or user-uploaded file) or the formula text itself, prefixed `=`, when nothing has evaluated it yet (always true immediately after this package writes one, since nothing here evaluates formulas). `pkg/skillkit/skills/xlsx.md`'s former "No formulas" limitation is replaced with an accurate description of this behavior, including the no-computed-value-yet caveat. ### Also - `toolcatalog.subagentToolNames`'s map literal had drifted out of `gofmt` alignment (two entries added by §4/§5 weren't gofmt-aligned with the rest) — recomputed and realigned; this is purely cosmetic but `gofmt -l` is supposed to be clean per §2's own verification bar. - Added `pkg/officekit/officekit_test.go` — there were no tests for this package at all before this pass. It covers the two content-preservation fixes directly (append/replace against a hand-built table-containing document, asserting both on `read_document`'s output and on raw-XML byte offsets), the xlsx ordering fix (same: both the package's own reader and raw XML offsets), and round-trips for all three formats including formatting flags and an `&`-containing bullet (exercises escaping both directions through the pptx hand-written-template path). ### Verification performed Static only — no Go toolchain and no network access in this environment (same constraint §5 ran into). What that means concretely: every edit above was re-read multiple times against Go's actual `encoding/xml`/`regexp`/`archive/zip` semantics rather than assumed; every touched file was run through a hand-rolled brace/paren/bracket balance checker (tracking Go string/rune/raw-string/comment lexing) to catch the class of error a missing `go vet` would otherwise have caught instantly; the new regex (`wTextElementRe`) and the new `lastTagIndex`/`xmlUnescapeText` helpers were traced by hand against concrete example inputs (worked through in detail, not just eyeballed) before being written. None of this is a substitute for actually running `go build ./... && go vet ./... && go test ./... && gofmt -l .` — that should happen before this is treated as merged, same as every prior section's caveat. ## Multilingual support (Claude Code-style) enkly's agent already detected and mirrored whatever language a user typed in conversation (`agentrun.SystemPrompt`'s "## Language" section). What was missing was everything around that: a localized UI chrome, an explicit way for a visitor to set a language, and a way for that choice to reach the model as a standing preference rather than relying purely on per-message detection — the same two-layer design Claude Code itself uses (automatic detection, plus an explicit `/language`-style override). **New shared package `pkg/i18n`** — the single source of truth, for every service, of which locales exist and what their strings are: a `Locale` type, `Supported` (en, fr, es, de, pt — en/fr ship complete, the rest cover nav/auth/composer and fall back to English for the rest), JSON catalogs loaded via `go:embed`, `T()` with fallback-to-English-then-to-key, and `NegotiateAcceptLanguage` for parsing a browser's `Accept-Language` header. Dependency-free (stdlib only) so both `gateway` and `agent` can import it without depending on each other. **Gateway (`services/gateway/internal/handlers/i18n.go`)** — `localeFrom` resolves a request's locale (`?lang=` query → `enkly_lang` cookie → Accept-Language → `i18n.Default`); `SetLanguage` (`POST /language`) persists an explicit choice in a year-long cookie, mirroring `SetTheme`'s existing pattern byte-for-byte on purpose. A new `lang_switcher` partial (`web/templates/partials.html`) — a plain `