google-docs — create, read, and edit Google Docs
Work with Google Docs documents through the document verbs below. Each verb does
one thing to a document; you call it and relay the result. The tool handles all
of the document mechanics, so you work in plain document terms — title, text,
headings, find-and-replace — and never track positions or state yourself.
When to use
Activate when the user wants to:
- Create a new document, optionally with starting text.
- Read a document's title and text back.
- Read the comments on a document — what people wrote and the text each
comment is anchored to.
- Comment on a section of a document — attach the agent's own note to
specific text.
- Add text to the end of a document, or insert text at a located spot.
- Replace text throughout a document (find-and-replace).
- Format occurrences of some text (bold, italic, underline, or a heading).
- Remove text from a document.
When NOT to use
- Spreadsheets — numbers, tables, cells, tabs, totals. That is a different
surface; use the sheets/donations tooling, not this.
- Sending or sharing a document to someone, or moving it between folders.
This skill writes document contents; it does not manage sharing.
- A document the agent cannot reach. This skill only sees documents in the
agent's shared folder or ones explicitly shared with it. If a
read/edit
reports the document isn't found, it hasn't been shared — tell the user.
The tool
One script at ${HERMES_SKILL_DIR}/scripts/docs.py, invoked as
python3 <path> <verb> [args]. Each call prints ONE JSON object on stdout
({"ok": true, ...}; failures are {"ok": false, "error": "..."} with exit 1).
Editing verbs take a <doc_id> — the document's ID (the long string in its URL,
https://docs.google.com/document/d/<doc_id>/edit). create returns that id
and url; keep them to edit the same document afterward.
| Verb |
Purpose |
find [query] [--title-only] [--anywhere] [--limit N] |
Finds documents without an id. No query lists everything in the folder, newest first. A query matches the title and the body text. --anywhere looks beyond the folder at everything shared with the agent. Returns document_id, title, url, modified for each. |
create --title "<t>" [--text "<initial>"] |
Creates a new document in the shared folder. Returns its document_id and url. |
read <doc_id> |
Gets a document's title and full plain text. |
rename <doc_id> --name "<new title>" |
Changes the document's title. Returns the new title. |
read-comments <doc_id> |
Lists every comment on the document, each with its quoted_anchor — the exact highlighted text the comment was attached to — plus author, resolved state, and replies. Pagination is handled internally. |
comment <doc_id> --text "<note>" --on "<section>" |
Adds a comment to a section of the document. The section is quoted as the first line of the comment, followed by the note. The section must be text that appears in the document (case-insensitive by default). |
append <doc_id> --text "<t>" |
Adds text as a new paragraph at the end. |
insert <doc_id> --text "<t>" --after "<anchor>" |
Inserts text right after the first occurrence of the anchor text. |
insert <doc_id> --text "<t>" --at-start |
Inserts text at the very beginning. |
replace <doc_id> --find "<s>" --with "<s>" |
Replaces every occurrence of one string with another. |
style <doc_id> --find "<text>" [--bold] [--italic] [--underline] [--heading N] |
Formats every occurrence of the text. --heading 1–6 makes its paragraph a heading; 0 returns it to normal. |
delete <doc_id> --find "<text>" --confirm |
Destructive. Removes every occurrence of the text. Needs --confirm. |
insert-image <doc_id> (--url "<public_url>" | --file "<local_path>") (--replace "<placeholder>" | --after "<anchor>" | --at-start) [--width N] [--height N] |
Inserts an image, either from a public HTTPS URL or a local file (PNG/JPEG/GIF). Placement is one of: --replace (swap a placeholder like [IMAGE:x] for the image), --after (right after some text), --at-start, or nothing (end of document). |
resize-image <doc_id> (--url "<public_url>" | --file "<local_path>") (--nth N | --after "<anchor>") [--width N] [--height N] |
Resizes an existing image. Pass its source again (--url or --file) — the resize re-inserts it. |
delete-image <doc_id> (--nth N | --after "<anchor>") --confirm |
Destructive. Removes an image. Needs --confirm. |
Add --match-case to insert --after, replace, style, comment,
delete, or the image verbs when the match must respect capitalization; by
default matching ignores case.
Images: give the image with EITHER --url (a public HTTPS image URL) OR
--file (a path to a local PNG/JPEG/GIF, which is uploaded for you) — not both.
--width/--height are in points; give just --width and the height scales
to keep the image's aspect ratio (a full text-column width is ~468). Address an
existing image by --nth N (1-based, in reading order) or --after "<nearby text>".
Turning the user's words into calls
Resolve loose phrasing to a verb BEFORE calling. Editing verbs need the
document_id of the document in play — the one from the last create, or one
the user names.
| User said |
Call |
| "start a doc called Trip Plan" |
create --title "Trip Plan" |
| "make a doc titled Notes that says 'Hello team'" |
create --title "Notes" --text "Hello team" |
| "what does the doc say / read it back" |
read <doc_id> |
| "rename the doc to 'Trip Plan — v2'" |
rename <doc_id> --name "Trip Plan — v2" |
| "what comments are on the doc / what did people comment on" |
read-comments <doc_id> |
| "comment on 'Day 1' that it needs a second pass" |
comment <doc_id> --text "Needs a second pass" --on "Day 1" |
| "leave a comment on the Rome section: it's too short" |
comment <doc_id> --text "Too short — expand it" --on "Rome trip" |
| "add a line: 'Bring sunscreen'" |
append <doc_id> --text "Bring sunscreen" |
| "put a title line at the top: 'Agenda'" |
insert <doc_id> --text "Agenda\n" --at-start |
| "after 'Day 1' add 'Fly to Rome'" |
insert <doc_id> --text " Fly to Rome" --after "Day 1" |
| "change every 'Rome' to 'Milan'" |
replace <doc_id> --find "Rome" --with "Milan" |
| "make 'Agenda' a heading" |
style <doc_id> --find "Agenda" --heading 1 |
| "bold the word 'urgent'" |
style <doc_id> --find "urgent" --bold |
| "remove the line 'draft — do not send'" |
delete <doc_id> --find "draft — do not send" --confirm (confirm first) |
| "put this banner where it says [IMAGE:Gents]" |
insert-image <doc_id> --url "https://…/banner.jpg" --replace "[IMAGE:Gents]" --width 468 |
| "add the logo after the title" |
insert-image <doc_id> --url "https://…/logo.png" --after "Trip Plan" |
| "insert this image file I have at ~/pics/map.png" |
insert-image <doc_id> --file "~/pics/map.png" --after "Directions" |
| "make the first image smaller / 300pt wide" |
resize-image <doc_id> --url "https://…/banner.jpg" --nth 1 --width 300 |
| "remove the second image" |
delete-image <doc_id> --nth 2 --confirm (confirm first) |
Notes:
- When the text should start on its own line, include a
\n in --text (as in
the title-at-top example).
- If the user asks to edit a document but no
document_id is in play, ask which
document (or offer to create one). Don't guess an id.
Output shape
create → {"ok": true, "document_id": "1AbC...", "title": "Trip Plan", "url": "https://docs.google.com/document/d/1AbC.../edit"}
read → {"ok": true, "document_id": "1AbC...", "title": "Trip Plan", "text": "Day 1\nFly to Rome\n..."}
rename → {"ok": true, "document_id": "1AbC...", "action": "renamed", "title": "Trip Plan — v2", "url": "https://docs.google.com/document/d/1AbC.../edit"}
read-comments → {"ok": true, "document_id": "1AbC...", "count": 2, "comments": [{"id": "...", "content": "Can we ship this Friday?", "author": "Jane", "quoted_anchor": "launch on Monday", "anchor_segment": "kix.abc123", "resolved": false, "created": "2026-08-24T23:00:00Z", "replies": []}]}
comment → {"ok": true, "document_id": "1AbC...", "action": "commented", "comment_id": "...", "section": "Day 1", "created": "2026-08-24T23:00:00Z"}
append → {"ok": true, "document_id": "1AbC...", "action": "appended", "characters": 16}
insert → {"ok": true, "document_id": "1AbC...", "action": "inserted", "at_index": 42, "characters": 12}
replace → {"ok": true, "document_id": "1AbC...", "action": "replaced", "occurrences": 3}
style → {"ok": true, "document_id": "1AbC...", "action": "styled", "occurrences": 1}
delete → {"ok": true, "document_id": "1AbC...", "action": "deleted", "occurrences": 1}
insert-image → {"ok": true, "document_id": "1AbC...", "action": "image_inserted"}
resize-image → {"ok": true, "document_id": "1AbC...", "action": "image_resized"}
delete-image → {"ok": true, "document_id": "1AbC...", "action": "image_deleted"}
After create, give the user the url so they can open the document. After an
edit, confirm what changed (e.g. "Replaced 3 occurrences of 'Rome' with
'Milan'.") so a mis-heard word is caught immediately.
When a replace or delete returns "occurrences": 0 with a note, relay the
note — the text wasn't in the document, so nothing changed.
The user rarely knows a document id
Assume they don't. When they refer to a document by what it is rather than by id or URL —
"my regimen doc", "the trip plan", "search my docs, it's in there", "the one I made yesterday"
— start with find, then use the id it returns.
# "the regimen is in my docs somewhere"
python3 ${HERMES_SKILL_DIR}/scripts/docs.py find regimen
# -> {"count": 1, "documents": [{"document_id": "1B6l…", "title": "David's Supplement & Medication Regimen", …}]}
python3 ${HERMES_SKILL_DIR}/scripts/docs.py read 1B6l…
Pick a search word from what the user said — a distinctive noun beats their full phrasing.
find matches body text too, so a doc whose title never says "regimen" is still found.
- Exactly one match → use it.
- Several matches → show the titles and ask which one. Do not guess.
- No matches → try a different word, or
--anywhere to look outside the folder, before
telling the user it isn't there. Bare find lists everything, which is the fastest way to
see what exists.
Common flows
"Write me a doc titled Trip Plan with a first line, then add a day."
create --title "Trip Plan" --text "Rome trip\n"
→ "Created Trip Plan: https://docs.google.com/document/d/<id>/edit"
append <id> --text "Day 1: fly to Rome"
→ "Added it."
"Make the title bold and turn 'Rome trip' into a heading."
style <id> --find "Rome trip" --heading 1
style <id> --find "Rome trip" --bold
"Change Rome to Milan everywhere and read it back."
replace <id> --find "Rome" --with "Milan"
read <id>
"Comment on the Day 1 line that it needs a second pass."
comment <id> --text "Needs a second pass" --on "Day 1"
→ "Commented on 'Day 1'."
The comment's first line is the quoted section, so the reader always sees
what the note refers to, even though the editor does not highlight it.
"Delete the 'do not send' warning."
# confirm the exact text with the user first, then:
delete <id> --find "do not send" --confirm
"Build a doc with banner images for each section."
Write the text first with a placeholder where each image goes, then swap each
placeholder for its image — one insert-image --replace per banner:
create --title "Theme Nights" --text "Thursday — GENTS & MAIDS\n[IMAGE:Gents]\nFriday — DESERT OF DESIRE\n[IMAGE:Desert]"
insert-image <id> --url "https://…/gents.jpg" --replace "[IMAGE:Gents]" --width 468
insert-image <id> --url "https://…/desert.jpg" --replace "[IMAGE:Desert]" --width 468
Pitfalls learned the hard way
style --find matches SUBSTRINGS, case-insensitively. style --find "Alerting" --heading 3 hit every paragraph containing the word (9 in one doc: section headings, bullet lines, numbered items, "Alerting layer detail"). Use a longer near-unique anchor, or do headings via a positional script (below), and verify with a paragraph-style dump afterwards.
comment cannot produce a highlighted selection. Google's Drive API cannot create a comment whose text is highlighted in the editor: the Docs API has no comment request, and a developer-supplied anchor is stored but treated as un-anchored by the Workspace apps (verified live — the text never highlights and quotedFileContent comes back empty). Do NOT try to "fix" this with different anchor shapes or raw-API calls; the section is quoted as the comment's first line instead, which is the most the API allows. read-comments reports quoted_anchor empty for these comments — that is expected, not an error.
- For surgical edits beyond the verbs, drive the raw API. Import the script as a module:
import sys; sys.path.insert(0, "${HERMES_SKILL_DIR}/scripts")
import docs as d
docs = d._docs()
doc = d._get_doc(docs, DOC_ID)
# paragraphs: doc["body"]["content"] -> [{"paragraph": {"elements":[...], "paragraphStyle": {...}}}]
# run text: el["paragraph"]["elements"][i]["textRun"]["content"]; indexes: element["startIndex"]
d._batch(docs, DOC_ID, [requests...])
Verified request names (the API rejects unknowns with 400): replaceAllText (find/replace), insertText (location: {index} + text; a \n in the text creates the paragraph break), updateParagraphStyle (range + paragraphStyle: {namedStyleType} + fields: "namedStyleType"; NORMAL_TEXT resets), deleteContentRange (range is INCLUSIVE of the trailing \n). There is no deleteText and no deleteRange request — those names 400.
Request shapes (verified against the live API): replaceAllText needs containsText: {"text": "…"} and replaceText: "…" (add matchCase to containsText to respect capitalization). insertText at the very end of the document 400s — the index must be strictly less than the body's end index; use d._end_index(doc). batchUpdate is ATOMIC: any invalid request aborts the whole batch and nothing is applied, so one bad index silently undoes every other request in the same batch.
- Index discipline in one batch: requests apply in order against a mutating document. Collect all positional requests, then sort by index DESCENDING and send as one batch — nothing applied later shifts a range you already applied. Compute indexes from a fresh
d._get_doc call, never from text read earlier in the session (edits between reads shift everything).
updateParagraphStyle bleeds across paragraphs when a textRun spans them. insertText containing \n puts the whole multi-paragraph block into a single textRun, and a style range that intersects that run applies to EVERY paragraph the run covers, not just the characters in the range. (Verified incident: inserting a 5-paragraph block, then styling the first 14 characters styled all 5 paragraphs HEADING_2.) Safe patterns: (a) insert the heading as its own insertText ("Heading\n") at the same index AFTER inserting the body block, so the heading is its own run; or (b) accept the bleed and reset the over-styled paragraphs individually with per-paragraph ranges (each ending strictly inside the paragraph, before its trailing \n; empty paragraphs take a zero-width range at their start index). Always verify with a paragraph-style dump after any heading styling.
- A human editing the document live will break positional ops. (Verified incident: a typo the user fixed in the Docs UI between the snapshot and a second batch shifted character indexes, and three positional deletes each clipped one character off a short line —
Plan→lan, Cost:→Cot:.) The failure is silent: the batch succeeds and the corruption only shows up on re-read. Rules: before every batch of surgical edits, take a fresh snapshot (read or d._get_doc); after every batch, re-read and diff against it, and treat ANY diff beyond your own intended changes as corruption to fix. Prefer string-based ops (replaceAllText) over positional ones whenever the user might be in the document. When a planned string match comes back with 0 occurrences, do NOT retry it — the plan is stale; re-read the document, rebase the plan on the fresh text, and if the mismatch is unexpected ask the user what they changed rather than assuming the document is wrong. Fix corrupted short lines with short, unique string matches (a whole paragraph is fine; lan→Plan worked), then sweep the document for the corruption pattern (e.g. lan|Cot:|Phae) rather than trusting a visual pass.
- Paragraph text runs include the trailing
\n. An equality check like para_text(el) == "- some line" misses because the run is "- some line\n". Use startswith/in, or compare para_text(el).rstrip("\n").
When a verb reports an error
"couldn't find '<x>' in the document…" (from insert/style/image verbs) →
the anchor or placeholder text isn't in the document. Read it back with read
to see the actual text, then retry with text that appears.
- An image error mentioning the URL /
"Invalid image" / fetch failure → the
--url isn't a public, directly-reachable image (PNG/JPEG/GIF). Ask the user
for a public image URL, or — if the image is a file on disk — insert it with
--file "<path>" instead (that path uploads the file for you).
"isn't a supported image type" / "no such image file" (from --file) → the
path is wrong or the file isn't a PNG/JPEG/GIF. Confirm the path with the user.
"the document has N images; say which…" (image verbs) → be specific with
--nth N or --after "<nearby text>".
- A
"...not found" or permission error on an existing <doc_id> → the document
isn't shared with the agent. Tell the user it needs to be shared (or dropped in
the shared folder); don't try to reach it another way.
"...has not been used in project…" / "Access Not Configured" → the Docs API
isn't enabled yet for the project. Point the user to README.md.
- Any credential/config error (
GOOGLE_APPLICATION_CREDENTIALS…, auth/build failed, GOOGLE_DOCS_FOLDER_ID not set) → the skill isn't configured. Point
the user to README.md.
Always ask the user for guidance when there is an error; do not proactively try to resolve errors yourself.
Empty results
read with an empty text means the document genuinely has no text yet — say so
plainly ("that document is empty"); don't re-check or speculate.
1---2name: google-docs3description: Reads, writes, renames, and comments on Google Docs. Create a new document, add or insert text, find-and-replace, format text (bold/italic/underline or headings), remove text, rename a document, list every comment on a document with the exact text each one is anchored to, or add a comment on a section of the text. Works through a pre-configured identity; new documents land in the agent's shared Drive folder, and existing documents are reachable once shared with the agent. PREFER THIS SKILL for anything about a Google Doc / document's contents, its comments, or its title. It is a different, self-contained setup from `google-workspace` — reach for this one for Docs. Finds documents by name or content, so the user never needs a document ID or URL. Activate on any of: "google doc", "doc", "document", "write a doc", "create a document", "read the doc", "what does the document say", "add to the doc", "insert into the document", "edit the doc", "find and replace in the doc", "make this a heading", "bold this in the 4license: MIT5---67# google-docs — create, read, and edit Google Docs89Work with Google Docs documents through the document verbs below. Each verb does10one thing to a document; you call it and relay the result. The tool handles all11of the document mechanics, so you work in plain document terms — title, text,12headings, find-and-replace — and never track positions or state yourself.1314## When to use1516Activate when the user wants to:17- **Create** a new document, optionally with starting text.18- **Read** a document's title and text back.19- **Read the comments** on a document — what people wrote and the text each20 comment is anchored to.21- **Comment on a section** of a document — attach the agent's own note to22 specific text.23- **Add** text to the end of a document, or **insert** text at a located spot.24- **Replace** text throughout a document (find-and-replace).25- **Format** occurrences of some text (bold, italic, underline, or a heading).26- **Remove** text from a document.2728## When NOT to use2930- **Spreadsheets** — numbers, tables, cells, tabs, totals. That is a different31 surface; use the sheets/donations tooling, not this.32- **Sending or sharing** a document to someone, or moving it between folders.33 This skill writes document *contents*; it does not manage sharing.34- A document the agent **cannot reach**. This skill only sees documents in the35 agent's shared folder or ones explicitly shared with it. If a `read`/edit36 reports the document isn't found, it hasn't been shared — tell the user.3738## The tool3940One script at `${HERMES_SKILL_DIR}/scripts/docs.py`, invoked as41`python3 <path> <verb> [args]`. Each call prints ONE JSON object on stdout42(`{"ok": true, ...}`; failures are `{"ok": false, "error": "..."}` with exit 1).4344Editing verbs take a `<doc_id>` — the document's ID (the long string in its URL,45`https://docs.google.com/document/d/<doc_id>/edit`). `create` returns that id46and url; keep them to edit the same document afterward.4748| Verb | Purpose |49|---|---|50| `find [query] [--title-only] [--anywhere] [--limit N]` | **Finds documents without an id.** No query lists everything in the folder, newest first. A query matches the title *and* the body text. `--anywhere` looks beyond the folder at everything shared with the agent. Returns `document_id`, `title`, `url`, `modified` for each. |51| `create --title "<t>" [--text "<initial>"]` | Creates a new document in the shared folder. Returns its `document_id` and `url`. |52| `read <doc_id>` | Gets a document's title and full plain text. |53| `rename <doc_id> --name "<new title>"` | Changes the document's title. Returns the new `title`. |54| `read-comments <doc_id>` | Lists every comment on the document, each with its `quoted_anchor` — the exact highlighted text the comment was attached to — plus author, resolved state, and replies. Pagination is handled internally. |55| `comment <doc_id> --text "<note>" --on "<section>"` | Adds a comment to a section of the document. The section is quoted as the first line of the comment, followed by the note. The section must be text that appears in the document (case-insensitive by default). |56| `append <doc_id> --text "<t>"` | Adds text as a new paragraph at the end. |57| `insert <doc_id> --text "<t>" --after "<anchor>"` | Inserts text right after the first occurrence of the anchor text. |58| `insert <doc_id> --text "<t>" --at-start` | Inserts text at the very beginning. |59| `replace <doc_id> --find "<s>" --with "<s>"` | Replaces every occurrence of one string with another. |60| `style <doc_id> --find "<text>" [--bold] [--italic] [--underline] [--heading N]` | Formats every occurrence of the text. `--heading` 1–6 makes its paragraph a heading; 0 returns it to normal. |61| `delete <doc_id> --find "<text>" --confirm` | **Destructive.** Removes every occurrence of the text. Needs `--confirm`. |62| `insert-image <doc_id> (--url "<public_url>" \| --file "<local_path>") (--replace "<placeholder>" \| --after "<anchor>" \| --at-start) [--width N] [--height N]` | Inserts an image, either from a public HTTPS URL or a **local file** (PNG/JPEG/GIF). Placement is one of: `--replace` (swap a placeholder like `[IMAGE:x]` for the image), `--after` (right after some text), `--at-start`, or nothing (end of document). |63| `resize-image <doc_id> (--url "<public_url>" \| --file "<local_path>") (--nth N \| --after "<anchor>") [--width N] [--height N]` | Resizes an existing image. Pass its source again (`--url` or `--file`) — the resize re-inserts it. |64| `delete-image <doc_id> (--nth N \| --after "<anchor>") --confirm` | **Destructive.** Removes an image. Needs `--confirm`. |6566Add `--match-case` to `insert --after`, `replace`, `style`, `comment`,67`delete`, or the image verbs when the match must respect capitalization; by68default matching ignores case.6970**Images:** give the image with EITHER `--url` (a public HTTPS image URL) OR71`--file` (a path to a local PNG/JPEG/GIF, which is uploaded for you) — not both.72`--width`/`--height` are in points; **give just `--width` and the height scales73to keep the image's aspect ratio** (a full text-column width is ~468). Address an74existing image by `--nth N` (1-based, in reading order) or `--after "<nearby75text>"`.7677## Turning the user's words into calls7879Resolve loose phrasing to a verb BEFORE calling. Editing verbs need the80`document_id` of the document in play — the one from the last `create`, or one81the user names.8283| User said | Call |84|---|---|85| "start a doc called Trip Plan" | `create --title "Trip Plan"` |86| "make a doc titled Notes that says 'Hello team'" | `create --title "Notes" --text "Hello team"` |87| "what does the doc say / read it back" | `read <doc_id>` |88| "rename the doc to 'Trip Plan — v2'" | `rename <doc_id> --name "Trip Plan — v2"` |89| "what comments are on the doc / what did people comment on" | `read-comments <doc_id>` |90| "comment on 'Day 1' that it needs a second pass" | `comment <doc_id> --text "Needs a second pass" --on "Day 1"` |91| "leave a comment on the Rome section: it's too short" | `comment <doc_id> --text "Too short — expand it" --on "Rome trip"` |92| "add a line: 'Bring sunscreen'" | `append <doc_id> --text "Bring sunscreen"` |93| "put a title line at the top: 'Agenda'" | `insert <doc_id> --text "Agenda\n" --at-start` |94| "after 'Day 1' add 'Fly to Rome'" | `insert <doc_id> --text " Fly to Rome" --after "Day 1"` |95| "change every 'Rome' to 'Milan'" | `replace <doc_id> --find "Rome" --with "Milan"` |96| "make 'Agenda' a heading" | `style <doc_id> --find "Agenda" --heading 1` |97| "bold the word 'urgent'" | `style <doc_id> --find "urgent" --bold` |98| "remove the line 'draft — do not send'" | `delete <doc_id> --find "draft — do not send" --confirm` (confirm first) |99| "put this banner where it says [IMAGE:Gents]" | `insert-image <doc_id> --url "https://…/banner.jpg" --replace "[IMAGE:Gents]" --width 468` |100| "add the logo after the title" | `insert-image <doc_id> --url "https://…/logo.png" --after "Trip Plan"` |101| "insert this image file I have at ~/pics/map.png" | `insert-image <doc_id> --file "~/pics/map.png" --after "Directions"` |102| "make the first image smaller / 300pt wide" | `resize-image <doc_id> --url "https://…/banner.jpg" --nth 1 --width 300` |103| "remove the second image" | `delete-image <doc_id> --nth 2 --confirm` (confirm first) |104105Notes:106- When the text should start on its own line, include a `\n` in `--text` (as in107 the title-at-top example).108- If the user asks to edit a document but no `document_id` is in play, ask which109 document (or offer to `create` one). Don't guess an id.110111## Output shape112113- `create` → `{"ok": true, "document_id": "1AbC...", "title": "Trip Plan", "url": "https://docs.google.com/document/d/1AbC.../edit"}`114- `read` → `{"ok": true, "document_id": "1AbC...", "title": "Trip Plan", "text": "Day 1\nFly to Rome\n..."}`115- `rename` → `{"ok": true, "document_id": "1AbC...", "action": "renamed", "title": "Trip Plan — v2", "url": "https://docs.google.com/document/d/1AbC.../edit"}`116- `read-comments` → `{"ok": true, "document_id": "1AbC...", "count": 2, "comments": [{"id": "...", "content": "Can we ship this Friday?", "author": "Jane", "quoted_anchor": "launch on Monday", "anchor_segment": "kix.abc123", "resolved": false, "created": "2026-08-24T23:00:00Z", "replies": []}]}`117- `comment` → `{"ok": true, "document_id": "1AbC...", "action": "commented", "comment_id": "...", "section": "Day 1", "created": "2026-08-24T23:00:00Z"}`118- `append` → `{"ok": true, "document_id": "1AbC...", "action": "appended", "characters": 16}`119- `insert` → `{"ok": true, "document_id": "1AbC...", "action": "inserted", "at_index": 42, "characters": 12}`120- `replace` → `{"ok": true, "document_id": "1AbC...", "action": "replaced", "occurrences": 3}`121- `style` → `{"ok": true, "document_id": "1AbC...", "action": "styled", "occurrences": 1}`122- `delete` → `{"ok": true, "document_id": "1AbC...", "action": "deleted", "occurrences": 1}`123- `insert-image` → `{"ok": true, "document_id": "1AbC...", "action": "image_inserted"}`124- `resize-image` → `{"ok": true, "document_id": "1AbC...", "action": "image_resized"}`125- `delete-image` → `{"ok": true, "document_id": "1AbC...", "action": "image_deleted"}`126127After `create`, give the user the `url` so they can open the document. After an128edit, confirm what changed (e.g. "Replaced 3 occurrences of 'Rome' with129'Milan'.") so a mis-heard word is caught immediately.130131When a `replace` or `delete` returns `"occurrences": 0` with a `note`, relay the132note — the text wasn't in the document, so nothing changed.133134## The user rarely knows a document id135136Assume they don't. When they refer to a document by what it *is* rather than by id or URL —137"my regimen doc", "the trip plan", "search my docs, it's in there", "the one I made yesterday"138— start with `find`, then use the id it returns.139140 # "the regimen is in my docs somewhere"141 python3 ${HERMES_SKILL_DIR}/scripts/docs.py find regimen142 # -> {"count": 1, "documents": [{"document_id": "1B6l…", "title": "David's Supplement & Medication Regimen", …}]}143 python3 ${HERMES_SKILL_DIR}/scripts/docs.py read 1B6l…144145Pick a search word from what the user said — a distinctive noun beats their full phrasing.146`find` matches body text too, so a doc whose title never says "regimen" is still found.147148- **Exactly one match** → use it.149- **Several matches** → show the titles and ask which one. Do not guess.150- **No matches** → try a different word, or `--anywhere` to look outside the folder, before151 telling the user it isn't there. Bare `find` lists everything, which is the fastest way to152 see what exists.153154## Common flows155156### "Write me a doc titled Trip Plan with a first line, then add a day."157```158create --title "Trip Plan" --text "Rome trip\n"159 → "Created Trip Plan: https://docs.google.com/document/d/<id>/edit"160append <id> --text "Day 1: fly to Rome"161 → "Added it."162```163164### "Make the title bold and turn 'Rome trip' into a heading."165```166style <id> --find "Rome trip" --heading 1167style <id> --find "Rome trip" --bold168```169170### "Change Rome to Milan everywhere and read it back."171```172replace <id> --find "Rome" --with "Milan"173read <id>174```175176### "Comment on the Day 1 line that it needs a second pass."177```178comment <id> --text "Needs a second pass" --on "Day 1"179 → "Commented on 'Day 1'."180```181The comment's first line is the quoted section, so the reader always sees182what the note refers to, even though the editor does not highlight it.183184### "Delete the 'do not send' warning."185```186# confirm the exact text with the user first, then:187delete <id> --find "do not send" --confirm188```189190### "Build a doc with banner images for each section."191Write the text first with a placeholder where each image goes, then swap each192placeholder for its image — one `insert-image --replace` per banner:193```194create --title "Theme Nights" --text "Thursday — GENTS & MAIDS\n[IMAGE:Gents]\nFriday — DESERT OF DESIRE\n[IMAGE:Desert]"195insert-image <id> --url "https://…/gents.jpg" --replace "[IMAGE:Gents]" --width 468196insert-image <id> --url "https://…/desert.jpg" --replace "[IMAGE:Desert]" --width 468197```198199## Pitfalls learned the hard way200201- **`style --find` matches SUBSTRINGS, case-insensitively.** `style --find "Alerting" --heading 3` hit every paragraph containing the word (9 in one doc: section headings, bullet lines, numbered items, "Alerting layer detail"). Use a longer near-unique anchor, or do headings via a positional script (below), and verify with a paragraph-style dump afterwards.202- **`comment` cannot produce a highlighted selection.** Google's Drive API cannot create a comment whose text is highlighted in the editor: the Docs API has no comment request, and a developer-supplied anchor is stored but treated as un-anchored by the Workspace apps (verified live — the text never highlights and `quotedFileContent` comes back empty). Do NOT try to "fix" this with different anchor shapes or raw-API calls; the section is quoted as the comment's first line instead, which is the most the API allows. `read-comments` reports `quoted_anchor` empty for these comments — that is expected, not an error.203- **For surgical edits beyond the verbs, drive the raw API.** Import the script as a module:204 ```python205 import sys; sys.path.insert(0, "${HERMES_SKILL_DIR}/scripts")206 import docs as d207 docs = d._docs()208 doc = d._get_doc(docs, DOC_ID)209 # paragraphs: doc["body"]["content"] -> [{"paragraph": {"elements":[...], "paragraphStyle": {...}}}]210 # run text: el["paragraph"]["elements"][i]["textRun"]["content"]; indexes: element["startIndex"]211 d._batch(docs, DOC_ID, [requests...])212 ```213 Verified request names (the API rejects unknowns with 400): `replaceAllText` (find/replace), `insertText` (`location: {index}` + `text`; a `\n` in the text creates the paragraph break), `updateParagraphStyle` (`range` + `paragraphStyle: {namedStyleType}` + `fields: "namedStyleType"`; `NORMAL_TEXT` resets), `deleteContentRange` (range is INCLUSIVE of the trailing `\n`). There is no `deleteText` and no `deleteRange` request — those names 400.214 Request shapes (verified against the live API): `replaceAllText` needs `containsText: {"text": "…"}` and `replaceText: "…"` (add `matchCase` to `containsText` to respect capitalization). `insertText` at the very end of the document 400s — the index must be strictly less than the body's end index; use `d._end_index(doc)`. `batchUpdate` is ATOMIC: any invalid request aborts the whole batch and nothing is applied, so one bad index silently undoes every other request in the same batch.215- **Index discipline in one batch:** requests apply in order against a mutating document. Collect all positional requests, then sort by index DESCENDING and send as one batch — nothing applied later shifts a range you already applied. Compute indexes from a fresh `d._get_doc` call, never from text read earlier in the session (edits between reads shift everything).216- **`updateParagraphStyle` bleeds across paragraphs when a textRun spans them.** `insertText` containing `\n` puts the whole multi-paragraph block into a single textRun, and a style range that intersects that run applies to EVERY paragraph the run covers, not just the characters in the range. (Verified incident: inserting a 5-paragraph block, then styling the first 14 characters styled all 5 paragraphs HEADING_2.) Safe patterns: (a) insert the heading as its own `insertText` ("Heading\\n") at the same index AFTER inserting the body block, so the heading is its own run; or (b) accept the bleed and reset the over-styled paragraphs individually with per-paragraph ranges (each ending strictly inside the paragraph, before its trailing `\n`; empty paragraphs take a zero-width range at their start index). Always verify with a paragraph-style dump after any heading styling.217- **A human editing the document live will break positional ops.** (Verified incident: a typo the user fixed in the Docs UI between the snapshot and a second batch shifted character indexes, and three positional deletes each clipped one character off a short line — `Plan`→`lan`, `Cost:`→`Cot:`.) The failure is silent: the batch succeeds and the corruption only shows up on re-read. Rules: before every batch of surgical edits, take a fresh snapshot (`read` or `d._get_doc`); after every batch, re-read and diff against it, and treat ANY diff beyond your own intended changes as corruption to fix. Prefer string-based ops (`replaceAllText`) over positional ones whenever the user might be in the document. When a planned string match comes back with 0 occurrences, do NOT retry it — the plan is stale; re-read the document, rebase the plan on the fresh text, and if the mismatch is unexpected ask the user what they changed rather than assuming the document is wrong. Fix corrupted short lines with short, unique string matches (a whole paragraph is fine; `lan`→`Plan` worked), then sweep the document for the corruption pattern (e.g. `lan|Cot:|Phae`) rather than trusting a visual pass.218- **Paragraph text runs include the trailing `\n`.** An equality check like `para_text(el) == "- some line"` misses because the run is `"- some line\n"`. Use `startswith`/`in`, or compare `para_text(el).rstrip("\n")`.219220## When a verb reports an error221222- `"couldn't find '<x>' in the document…"` (from `insert`/`style`/image verbs) →223 the anchor or placeholder text isn't in the document. Read it back with `read`224 to see the actual text, then retry with text that appears.225- An image error mentioning the URL / `"Invalid image"` / fetch failure → the226 `--url` isn't a public, directly-reachable image (PNG/JPEG/GIF). Ask the user227 for a public image URL, or — if the image is a file on disk — insert it with228 `--file "<path>"` instead (that path uploads the file for you).229- `"isn't a supported image type"` / `"no such image file"` (from `--file`) → the230 path is wrong or the file isn't a PNG/JPEG/GIF. Confirm the path with the user.231- `"the document has N images; say which…"` (image verbs) → be specific with232 `--nth N` or `--after "<nearby text>"`.233- A `"...not found"` or permission error on an existing `<doc_id>` → the document234 isn't shared with the agent. Tell the user it needs to be shared (or dropped in235 the shared folder); don't try to reach it another way.236- `"...has not been used in project…"` / `"Access Not Configured"` → the Docs API237 isn't enabled yet for the project. Point the user to `README.md`.238- Any credential/config error (`GOOGLE_APPLICATION_CREDENTIALS…`, `auth/build239 failed`, `GOOGLE_DOCS_FOLDER_ID not set`) → the skill isn't configured. Point240 the user to `README.md`.241242Always ask the user for guidance when there is an error; do not proactively try to resolve errors yourself.243244## Empty results245246`read` with an empty `text` means the document genuinely has no text yet — say so247plainly ("that document is empty"); don't re-check or speculate.