Skip to content

Cookbook

End-to-end recipes for the workflows wordlive was actually built for.

1. Update a contract template

You have a Word template open with three bookmarks (Address, Date, Signatory) and want to populate them from a Python dict. The user is mid-review and shouldn't notice the script ran.

import wordlive as wl

values = {
    "Address":   "123 Main St, Anytown",
    "Date":      "2026-05-19",
    "Signatory": "Jane Doe",
}

with wl.attach() as word:
    doc = word.documents.active

    # Sanity-check that every target bookmark actually exists *before*
    # opening the edit scope. Cheaper than failing partway through.
    missing = [name for name in values if name not in doc.bookmarks]
    if missing:
        raise SystemExit(f"missing bookmarks: {missing}")

    with doc.edit("Populate contract template"):
        for name, text in values.items():
            doc.bookmarks[name].set_text(text)

All three writes collapse to a single Ctrl-Z labelled "Populate contract template". The user's cursor and scroll position are restored on exit.

cat > ops.json <<'JSON'
{
  "label": "Populate contract template",
  "ops": [
    {"op": "write_bookmark", "name": "Address",   "text": "123 Main St, Anytown"},
    {"op": "write_bookmark", "name": "Date",      "text": "2026-05-19"},
    {"op": "write_bookmark", "name": "Signatory", "text": "Jane Doe"}
  ]
}
JSON

wordlive exec --script ops.json

Works, but you get three separate Ctrl-Z steps and three round-trips:

wordlive write bookmark Address   --text "123 Main St, Anytown"
wordlive write bookmark Date      --text "2026-05-19"
wordlive write bookmark Signatory --text "Jane Doe"

Prefer exec whenever the writes belong to a single user-visible intent.

2. Read what the user is looking at

A very common case: the user has selected (or just clicked into) a passage, and your script needs to act on whatever they're focused on — without moving them.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active            # whichever doc is in focus
    sel = doc.selection.info()             # {"start": int, "end": int, "text": str}

    print(f"Active document: {doc.name}")
    print(f"Cursor at offset {sel['start']}{sel['end']}")
    if sel["start"] == sel["end"]:
        print("(no selection — cursor is collapsed)")
    else:
        print(f"Selected text: {sel['text']!r}")

info() is read-only and never moves the user. It's the right primitive when you want to:

  • Capture what the user just highlighted, send it to an LLM as context, and feed the response back as an edit at a named anchor (not back at the selection — keep the cursor still).
  • Detect "no current selection" (start == end) before deciding whether to show a "nothing to act on" message.
  • Drive a hotkey-style workflow: user highlights a phrase, presses a key, your script reads the selection and reacts.

If you need offsets beyond start / end / text (e.g. page number, line number), drop to doc.selection.com — that's the raw Application.Selection and has the full Word object model under it.

From the CLI, cursor read is the same read — plus it resolves which paragraph the cursor sits in, so you can pivot straight to an anchored edit:

$ wordlive cursor read
{"start": 142, "end": 142, "collapsed": true, "text": "", "paragraph": {"anchor_id": "para:7"}}

The paragraph.anchor_id is the bridge: read where the user is, then act on para:7 (or its heading:N) with the polite, cursor-preserving verbs instead of writing back at the live caret.

Variant: read a whole section by heading

When the user's request is "summarize the Risks section", you don't want the selection — you want every paragraph under a heading. Use Heading.section_text() (or wordlive read section):

with wl.attach() as word:
    doc = word.documents.active
    section = doc.heading("Risks").section_text()
    # section is the body from after the Risks heading up to the next heading
    # at level ≤ Risks's level (or end of document).
$ wordlive --text read section "Risks"
The operational risks identified this quarter are 

--text mode prints just the body — perfect for piping into a prompt:

prompt=$(wordlive --text read section "Risks")
claude -p "Summarize these risks in two bullets: $prompt"

3. Add text to a document

Three patterns, picked by where you want the text to land. All three use doc.edit() so the insert collapses into a single Ctrl-Z.

This is the polite default: target a stable anchor, don't touch the cursor.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    with doc.edit("Append note to introduction"):
        # After a heading — gets its own paragraph.
        doc.heading("Introduction").insert_paragraph_after(
            "(Added 2026-05-19: see attached appendix.)"
        )

        # After a bookmark — inline, no new paragraph.
        doc.bookmarks["Address"].insert_after(" (verified)")

        # Before a content control — inline, on the left.
        doc.content_controls["Signatory"].insert_before("Dr. ")

Use insert_paragraph_after when you want a new paragraph (with optional style="Body Text" etc.); use insert_before / insert_after for inline inserts that don't break the surrounding paragraph.

Note

insert_before and insert_after leave the bookmark's stored range unchanged — the new text lands outside the bookmark's span. Only set_text re-creates the bookmark to cover its new content. If you want the bookmark to grow with the appended text, use bm.set_text(bm.text + " (verified)") instead.

3b. Append to the end of the document

The end of the document is the one position no content names, so it gets its own helper — doc.append_paragraph(...) adds a new final paragraph (no need to find the last one first):

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    with doc.edit("Append closing note"):
        doc.append_paragraph("Closing note added by automation.")
        # Optional style, and \r / \n to append several paragraphs at once:
        # doc.append_paragraph("Heading\rFirst line", style="Body Text")

Use doc.append(text) instead when you want the text to continue the last paragraph inline rather than start a new one (the direct, polite form of the old doc.com.Content.InsertAfter(...)).

Both also surface as an anchor — doc.end (id end) — so the end composes with the usual verbs and the CLI's --anchor-id:

doc.end.insert_paragraph_after("Closing note.")   # same as append_paragraph
doc.end.insert_image("logo.png", wrap="inline")   # drop an image at the end
$ wordlive append --text "Closing note added by automation."
$ wordlive insert --anchor-id end --text "Closing note."   # equivalent

The start of the document mirrors all of this: doc.prepend_paragraph(...) / doc.prepend(...), the doc.start anchor (id start), and wordlive prepend — for a title or a "DRAFT" banner above everything else.

3c. At the user's cursor (explicit, moves them)

The cursor is the deliberately non-default target. doc.selection.write() is the first-class way to type at it; pair it with allow_cursor_move() so the edit doesn't snap the cursor back afterwards:

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    with doc.edit("Insert at cursor") as scope:
        scope.allow_cursor_move()           # this edit is *allowed* to move them
        doc.selection.write("This text lands at the cursor.")

Without allow_cursor_move(), wordlive snaps the cursor back to where the user had it — the typed text is still there, but the cursor jump confuses the user. Always pair cursor-moving edits with allow_cursor_move().

By default write replaces the current selection (like typing over highlighted text); pass replace=False to insert at the selection start without removing it. Either way the cursor is left after the inserted text.

The CLI mirrors this with the dedicated, intentionally-separate cursor group — cursor write already opts into the cursor move for you:

$ wordlive cursor write --text "This text lands at the cursor."
{"ok": true, "replace": true}

# Insert without overwriting the user's selection:
$ wordlive cursor write --text "(draft) " --no-replace

4. Fuzzy find + replace (LLM-friendly)

The classic LLM editing flow: the model sees the document, decides "replace this sentence with that", and emits a (find, replace) pair. Naïve substring matching breaks the moment the model normalizes the source text through its tokenizer — smart quotes get straightened, NBSPs become spaces, em-dashes turn into hyphens. wordlive.find_replace() normalizes both sides the same way so cosmetic drift doesn't blow up the match:

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    with doc.edit("Apply LLM-suggested rewrite"):
        # The LLM said: replace "Q1 2025" with "Q2 2025".
        # The doc actually contains "Q1 2025" with a NBSP between Q1 and 2025.
        applied = doc.find_replace("Q1 2025", "Q2 2025")

    print(f"replaced {len(applied)} occurrence(s)")

What's preserved automatically:

  • Character formatting. Word's range-replace inherits the formatting of the first character of the matched span, so a bold "Q1 2025" becomes a bold "Q2 2025". You don't need to teach the LLM to write markdown.
  • The user's cursor. doc.edit() snapshots and restores selection + scroll position, just like every other polite write.

Disambiguating multiple matches

If find matches more than one occurrence and you didn't pass all=True or occurrence=N, you get an AmbiguousMatchError carrying every match's offsets. The CLI version returns the same payload on stdout with exit code 5:

$ wordlive replace --find "Q1" --text "Q2"
{"ok": false, "error": "ambiguous_match", "find": "Q1",
 "matches": [{"anchor_id": "range:412-414", "start": 412, "end": 414, "text": "Q1"},
             {"anchor_id": "range:887-889", "start": 887, "end": 889, "text": "Q1"}]}
$ echo $?
5

The agent's recovery is a fresh call with --occurrence N (or --all):

$ wordlive replace --find "Q1" --text "Q2" --occurrence 2
{"ok": true, "replacements": [{"anchor_id": "range:887-889", "start": 887, "end": 889, "text": "Q1"}]}

For "replace this phrase, but only inside the Risks section":

with doc.edit("Targeted rewrite"):
    doc.find_replace(
        "needs review",
        "approved",
        scope=doc.heading("Risks"),
        all=True,
    )

When scope is a Heading, wordlive expands it to the heading's section (the body up to the next same-or-higher heading) — so the replacement won't accidentally touch identical phrasing in unrelated parts of the document.

CLI equivalent: wordlive replace --find "..." --text "..." --in heading:N --all.

Read-only locate first

If the agent isn't sure whether its match will be unique, use wordlive find to peek without writing:

$ wordlive find --text "the risk register" --in heading:3
[{"anchor_id": "range:412-429", "start": 412, "end": 429, "text": "the risk register"}]

This is the same matcher as replace --find, but read-only — useful as a pre-flight check or to enumerate candidates for an --occurrence pick.

5. LLM tool-use loop

The CLI's JSON-in / JSON-out shape is designed to drop straight into a tool-use loop. The pattern is:

  1. Discover with wordlive outline — gives the model addressable anchors.
  2. Decide — model picks anchors and new values.
  3. Apply with wordlive exec --script ops.json — single round-trip, one Ctrl-Z.
  4. Branch on the exit code2 means a stale anchor (re-fetch outline), 3 means Word is busy (retry).

Tool schema

A minimal tool definition the agent sees:

{
  "name": "wordlive_apply",
  "description": "Apply a batch of edits to the user's open Word document under one Ctrl-Z. All ops succeed or the failure point is reported.",
  "input_schema": {
    "type": "object",
    "required": ["label", "ops"],
    "properties": {
      "label": {"type": "string"},
      "ops":   {"type": "array", "items": {"type": "object"}}
    }
  }
}

The supported op shapes are documented on the CLI page.

Driver loop

import json, subprocess

def wordlive(*args: str) -> tuple[int, dict | list]:
    """Run a wordlive subcommand and return (exit_code, parsed_stdout)."""
    proc = subprocess.run(["wordlive", *args], capture_output=True, text=True)
    try:
        payload = json.loads(proc.stdout) if proc.stdout.strip() else {}
    except json.JSONDecodeError:
        payload = {"raw": proc.stdout}
    return proc.returncode, payload


def agent_turn(claude, outline):
    """One round trip: ask the model what to change, return its plan."""
    response = claude.messages.create(
        model="claude-opus-4-7",
        max_tokens=1024,
        tools=[WORDLIVE_APPLY_TOOL],
        messages=[
            {"role": "user", "content": [
                {"type": "text", "text":
                 "Here is the document outline. Update section 3 to reflect "
                 "the new risk register."},
                {"type": "text", "text": json.dumps(outline)},
            ]},
        ],
    )
    for block in response.content:
        if block.type == "tool_use" and block.name == "wordlive_apply":
            return block.input
    return None


def main():
    code, outline = wordlive("outline")
    if code == 4:
        raise SystemExit("Word is not running")

    for attempt in range(3):
        plan = agent_turn(claude, outline)
        if plan is None:
            return

        with open("ops.json", "w") as f:
            json.dump(plan, f)

        code, result = wordlive("exec", "--script", "ops.json")
        if code == 0:
            print(f"applied {result['ops_run']} ops")
            return
        if code == 2:                       # stale anchor — refresh and retry
            code, outline = wordlive("outline")
            continue
        if code == 3:                       # Word busy — back off
            time.sleep(2 ** attempt)
            continue
        raise SystemExit(f"wordlive failed (exit {code}): {result}")

The key property: every failure is labelled (anchor name, op index) so the next iteration's prompt can include result["failure"] as feedback. The model corrects itself instead of looping blindly.

6. Insert a section without disturbing the user

You want to add a new "Action items" paragraph after the Risks heading without moving the user's cursor or scroll position — even if they're typing on the same page.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    # Snapshot the user's position so we can prove we didn't move them.
    before = wl._selection.snapshot(word)        # noqa: SLF001 — diagnostic only

    with doc.edit("Add action items"):
        doc.heading("Risks").insert_paragraph_after(
            "Action items: follow up with risk owners by Friday.",
            style="Body Text",
        )

    after = wl._selection.snapshot(word)
    assert before.start == after.start, "user's cursor moved!"
    assert before.vertical_percent == after.vertical_percent, "scroll moved!"

The _selection.snapshot call is private (and only used here for the assertion). In real code you'd just trust the politeness contract — but the assertion is useful proof when you're verifying the behaviour in a smoke test.

If you do want to move the user — say, jump them to the freshly inserted paragraph — opt out of restoration:

with doc.edit("Add and jump") as scope:
    risks = doc.heading("Risks")
    risks.insert_paragraph_after("Action items: …")
    scope.allow_cursor_move()
    doc.go_to(risks)

7. Restyle and format a paragraph politely

You want to promote the Risks heading from H3 to H2 and tighten its spacing, without touching the user's cursor or scrolling the view.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    # 1. Sanity-check the style exists before we mutate anything.
    if "Heading 2" not in doc.styles:
        raise SystemExit("doc is missing the Heading 2 style")

    risks = doc.heading("Risks")
    with doc.edit("Restyle Risks heading"):
        risks.apply_style("Heading 2")
        risks.format_paragraph(space_before=12, space_after=4, alignment="left")

Both calls go through doc.edit("…"), so a single Ctrl-Z reverts the whole change. apply_style raises StyleNotFoundError (exit code 2) if the style doesn't exist — discover real names with doc.styles.list() or wordlive style list.

The same intent from the CLI, in one atomic batch:

$ wordlive exec --script - <<'JSON'
{
  "label": "Restyle Risks",
  "ops": [
    {"op": "apply_style",      "anchor_id": "heading:3", "name": "Heading 2"},
    {"op": "format_paragraph", "anchor_id": "heading:3",
      "space_before": 12, "space_after": 4, "alignment": "left"}
  ]
}
JSON

Indent and spacing values are in points — the same unit Word uses in its paragraph dialog. If the anchor spans a partial paragraph (e.g., a bookmark covering five words inside a longer paragraph), format_paragraph applies to the enclosing paragraph, mirroring how Word's own UI behaves.

8. Read and edit a table

A cell is just another anchor (table:N:R:C), so the same polite, atomic-undo patterns apply. Discover the grid first, then address cells by id.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    budget = doc.tables[1]            # by 1-based position
    # …or doc.tables["Budget"] by Title.

    # Read the whole grid as plain text.
    for row in budget.grid():
        print(row)

    with doc.edit("Update budget"):
        budget.cell(2, 2).set_text("$450")          # bump a figure
        budget.add_row(["Lodging", "$600"])         # append a row
        budget.cell(1, 1).apply_style("Heading 4")  # restyle a header cell

Cell text is returned clean — Word's internal end-of-cell markers are stripped. The whole block reverts with one Ctrl-Z.

# Discover cells (each carries its anchor_id).
wordlive table read 1

# Write a single cell by its anchor id.
wordlive replace --anchor-id table:1:2:2 --text "$450"

# Append / drop rows.
wordlive table add-row --table 1 --values '["Lodging", "$600"]'
wordlive table delete-row --table 1 --row 5
wordlive exec --script - <<'JSON'
{
  "label": "Update budget",
  "ops": [
    {"op": "set_cell", "table": 1, "row": 2, "col": 2, "text": "$450"},
    {"op": "add_row",  "table": 1, "values": ["Lodging", "$600"]},
    {"op": "apply_style", "anchor_id": "table:1:1:1", "name": "Heading 4"}
  ]
}
JSON

Note

Cell addressing assumes a rectangular grid. Tables with merged or split cells follow Word's own Table.Cell(row, col) indexing and may raise inside a merged region.

9. Multi-document workflows

When several documents are open, --doc NAME picks the target:

wordlive --doc Draft.docx outline
wordlive --doc Draft.docx write bookmark Address --text "456 Elm St"

In Python, word.documents is iterable and word.documents[name] does the lookup:

with wl.attach() as word:
    for doc in word.documents:
        if "draft" in doc.name.lower():
            with doc.edit("Touch all drafts"):
                doc.bookmarks["LastReviewed"].set_text("2026-05-19")

Document not found raises DocumentNotFoundError, which the CLI maps to exit code 1.

10. Suggest, don't overwrite: comments + tracked changes

The most agent-shaped edits are the non-destructive ones. Instead of rewriting a passage, an agent can flag it with a comment, or make its edits visibly as tracked changes the human accepts or rejects. Both leave the user in control.

Comment on what find located

A find() hit returns a range:START-END id, which resolves to a RangeAnchor — so you can attach a comment to exactly the span you matched, without changing a character of it:

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    hits = doc.find("as soon as possible")
    if hits:
        target = doc.anchor_by_id(hits[0]["anchor_id"])   # a RangeAnchor
        with doc.edit("Flag vague deadline"):
            doc.comments.add(
                target,
                "Can we commit to a concrete date here?",
                author="ReviewBot",
            )
# CLI: discover the span, then comment on it.
$ wordlive find --text "as soon as possible"
[{"anchor_id": "range:512-531", "start": 512, "end": 531, "text": "as soon as possible"}]

$ wordlive comment add --anchor-id range:512-531 \
      --text "Can we commit to a concrete date here?" --author ReviewBot
{"ok": true, "anchor_id": "range:512-531", "comment": {"index": 1, "author": "ReviewBot"}}

List, resolve, and delete comments by their 1-based index:

$ wordlive comment list
$ wordlive comment resolve --index 1
$ wordlive comment delete --index 1

Make edits visible as tracked changes

When you do want to change the text but let the human vet it, wrap the edit in doc.tracked_changes(). Track Changes is turned on for the scope and restored to its prior state on exit:

with wl.attach() as word:
    doc = word.documents.active

    with doc.tracked_changes(), doc.edit("Suggest plainer wording"):
        doc.find_replace("utilise", "use", all=True)

Every replacement lands as a revision in Word's review pane — one Ctrl-Z removes the batch, or the user accepts/rejects each suggestion. From the CLI, set "tracked": true on an exec script so the whole batch is recorded as tracked changes and the prior setting is restored afterwards:

$ wordlive exec --script - <<'JSON'
{
  "label": "Suggest plainer wording",
  "tracked": true,
  "ops": [
    {"op": "find_replace", "find": "utilise", "text": "use", "all": true}
  ]
}
JSON

The standalone wordlive track on / track off toggle is persistent — useful when a human will keep editing in tracked mode — but it doesn't auto-restore, so prefer the scoped forms above for one-shot agent edits.

11. Number a procedure and stamp the header/footer

Template-generation work: take the paragraphs under a Steps heading, turn them into a numbered list, and brand the page with a header and footer — all in one atomic-undo batch. List verbs and header/footer writes are just anchor operations, so they compose with everything else.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    steps = doc.heading("Steps")          # body under the heading
    with doc.edit("Number the procedure"):
        steps.apply_list("numbered")      # 1., 2., 3., …

        sec = doc.sections[1]
        sec.header().set_text("ACME Corporation — Internal")
        sec.footer().set_text("Confidential — do not distribute")

apply_list accepts "bulleted", "numbered", or "outline". To pick up numbering from a list just above, pass continue_previous=True; to force a fresh count on an existing list, call steps.restart_numbering(). Read the current state with steps.list_info() ({type, level, number, string}).

# Discover lists already in the document (each carries a range anchor id).
wordlive list show

# Number a heading's paragraphs, then restart at 1 if needed.
wordlive list apply --anchor-id heading:6 --type numbered
wordlive list restart --anchor-id heading:6

# Headers/footers by section (default section 1, which=primary).
wordlive header write --section 1 --text "ACME Corporation — Internal"
wordlive footer write --section 1 --text "Confidential — do not distribute"
wordlive exec --script - <<'JSON'
{
  "label": "Number the procedure",
  "ops": [
    {"op": "apply_list",   "anchor_id": "heading:6", "type": "numbered"},
    {"op": "write_header", "section": 1, "text": "ACME Corporation — Internal"},
    {"op": "write_footer", "section": 1, "text": "Confidential — do not distribute"}
  ]
}
JSON

A header/footer is just a range (header:S:WHICH / footer:S:WHICH, WHICH ∈ primary/first/even), so the same id also works with replace, style apply, and format-paragraph when you need more than plain text.

12. Address and edit any paragraph, not just headings

outline only shows headings, so a document of plain prose looks unaddressable. It isn't: every paragraph is a para:N anchor. Discover them with paragraphs (or outline --all), then act on a body paragraph with the same verbs you'd use on a heading.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    # Every paragraph, with offsets — headings AND body text AND list items.
    for p in doc.paragraphs.list():
        flag = f"H{p['level']}" if p["is_heading"] else "  "
        print(f"{flag} {p['anchor_id']:8} {p['text'][:50]!r}")

    with doc.edit("Tidy the opening"):
        # Rewrite the second paragraph in place (trailing ¶ preserved).
        doc.paragraphs[2].set_text("A clearer opening sentence.")

        # Drop a new paragraph *before* paragraph 2, styled as body text.
        doc.paragraphs[2].insert_paragraph_before(
            "Executive summary.", style="Body Text"
        )

doc.paragraphs[N] returns a Paragraph anchor (para:N) that inherits every verb — apply_style, format_paragraph, apply_list, the insert pair. Because para:N and heading:N share an index space, a heading at para:5 is also heading:5; use whichever reads better.

# Discover every paragraph (these two are identical):
wordlive paragraphs
wordlive outline --all

# Edit a body paragraph by its para:N id.
wordlive replace --anchor-id para:2 --text "A clearer opening sentence."

# Insert a new paragraph before / after any anchor.
wordlive insert --anchor-id para:2 --text "Executive summary." --before --style "Body Text"
wordlive insert --anchor-id heading:1 --text "Background follows." --after

Inserting inside a paragraph at an offset

insert always makes a new paragraph. To splice text into the middle of an existing one, target a collapsed rangerange:OFFSET-OFFSET — and write to it. The offsets come straight from paragraphs (or find):

# Paragraph 2 starts at offset 13; insert a marker 5 chars in (offset 18).
$ wordlive replace --anchor-id range:18-18 --text "[NOTE] "

Setting text on a zero-width range inserts without overwriting; a non-zero range:START-END replaces that span. Range offsets are live, so compute and use them in the same breath — an edit elsewhere shifts everything after it.

13. Act on whatever the user is pointing at

The hotkey workflow: the user clicks into (or selects) something, triggers your script, and you decide whether to act politely at an anchor or directly at the cursor. cursor read gives you both the raw position and the containing para:N, so you can choose.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active
    sel = doc.selection.info()              # {start, end, collapsed, text}

    # Map the caret to a stable anchor and edit *there* — cursor stays put.
    para = doc.paragraphs.at(sel["start"])
    if para is not None:
        with doc.edit("Annotate current paragraph"):
            para.insert_paragraph_after(f"(reviewed: {para.text[:30]}…)")

That's the polite path: read the cursor, but write at the anchor it resolves to, leaving the user where they were. When the user genuinely wants text at the caret — "insert my signature here" — reach for the explicit cursor write from recipe 3c:

$ wordlive cursor read
{"start": 142, "end": 142, "collapsed": true, "text": "", "paragraph": {"anchor_id": "para:7"}}

# Polite: act on the resolved anchor instead of the caret.
$ wordlive insert --anchor-id para:7 --text "Reviewed by automation." --after

# Or explicit, when the caret is genuinely the target.
$ wordlive cursor write --text "— J. Doe"

The split is deliberate: anchors are addressable, stable, and visible to an LLM as JSON; the cursor is none of those, so wordlive keeps it behind its own clearly-labelled cursor surface rather than letting it leak into --anchor-id.

14. Drop a figure into a document

insert_image works on any anchor and embeds the picture (it never links to a path that could vanish). wrap is required, so layout intent is always explicit; "auto" floats small images with Square wrap and large ones top-and-bottom.

import wordlive as wl

with wl.attach() as word:
    doc = word.documents.active

    with doc.edit("Add diagram after Risks"):
        # From a file, letting the size heuristic pick the wrap.
        doc.heading("Risks").insert_image("diagram.png", wrap="auto")

        # Inline (in the text flow), with explicit size and alt text.
        doc.bookmarks["Logo"].insert_image(
            "logo.png", wrap="inline", width=96, alt_text="Company logo"
        )

An LLM usually holds image data, not a path — pass bytes or a base64 string and wordlive temp-files it, embeds it, and cleans up:

import base64, wordlive as wl

png_b64 = "...base64 from a vision/diffusion model..."

with wl.attach() as word:
    doc = word.documents.active
    with doc.edit("Insert generated chart"):
        doc.heading("Results").insert_image(png_b64, wrap="square", width=240)
        # Equivalently: insert_image(base64.b64decode(png_b64), wrap="square")

From the CLI, use --path for files and --base64 (or --base64 - from stdin) for in-memory data:

$ wordlive insert-image --anchor-id heading:2 --path diagram.png --wrap auto
{"ok": true, "anchor_id": "heading:2", "anchor": {"kind": "heading", "name": "Risks"}, "wrap": "auto", "where": "after"}

$ base64 logo.png | wordlive insert-image --anchor-id bookmark:Logo --base64 - \
    --wrap inline --width 96 --alt-text "Company logo"

A missing file, malformed base64, or an unrecognised format raises ImageSourceError (exit code 1) before anything is inserted — the batch never half-mutates the document.