Google Docs Automation skill for Codex
Template copies in, finished Docs and Slides out. Codex generates Google Docs and Slides from templates kept in Drive: it inventories placeholders across tabs, headers and footers, copies the template into the right folder, batches replaceAllText and index-ordered inserts so offsets stay valid, duplicates slides per data item, exports PDFs, and re-reads every output for leftover placeholders. Sharing and deletion always wait for your confirmation.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- google-docs-automation Installs to ~/.agents/skills/google-docs-automation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install google-docs-automation The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project google-docs-automation Installs to .agents/skills/google-docs-automation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/google-docs-automation.zipunzip -o google-docs-automation.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) google-docs-automation PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $google-docs-automation, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Generates and edits Google Docs and Google Slides through the Docs API v1, Slides API v1 and Drive API v3, including template copying, replaceAllText placeholders, index-safe insertText and table filling, images, document tabs, Sheets-linked charts and PDF export, plus Apps Script DocumentApp and SlidesApp. Use when asked to create Google Docs or Slides from data, fill a Docs or Slides template, bulk-generate proposals or reports in Drive, export them to PDF, or fix index and permission errors in Docs API batchUpdate calls.
Use it when
- Google Docs or Slides must be generated or updated from data
- A Drive template needs filling and exporting to PDF, DOCX or PPTX
- Docs API index errors or missing text in tabbed documents need debugging
- Document owners want an Apps Script button that fills a template
Use something else when
- The target is a Google Sheet: use google-sheets-automation
- Outputs are local .docx files with no Google Drive involved: use docx-templates
How to check the result
Re-read each generated file including all tabs, headers and footers to assert no placeholders remain, list its permissions, and rerun once to confirm no duplicate files are created.
The mistake it prevents
Reusing indexes from the template or sending inserts in ascending order, so each insert shifts the next one into the wrong place or past the end of the document.
Done well means documents come from a template owned in Drive, each output is created
with one copy and one or two batchUpdate calls, no placeholder survives, output files
land in the right folder with the right sharing, and the job is safe to rerun.
Choose the approach
| Need | Approach |
|---|---|
| Fixed layout with variable text | Copy a template (Drive files.copy), then replaceAllText |
| Variable-length content (tables with N rows, repeated sections) | Template copy, then index-based inserts computed from a fresh documents.get |
| Deck from data | Slides template with placeholder text/shapes, duplicateObject per item, replaceAllText, replaceAllShapesWithImage |
| Live chart from a Sheet | Slides/Docs linked chart from Sheets (createSheetsChart in Slides) |
| Owners want a button inside Docs | Apps Script (DocumentApp, SlidesApp) bound to the template |
| Output must be .docx or .pptx anyway | Generate locally instead: see docx-templates or pptx-presentations |
All calls need network access. Develop request-building code offline against saved
documents.get JSON fixtures, and run live calls only with approval.
Auth and scopes
Same model as Sheets: a service account with the template and output folder shared to
it, or domain-wide delegation when files must be owned by users. Scopes:
https://www.googleapis.com/auth/documents, .../presentations, and
.../drive.file (files the app created or was given) or .../drive if it must find
arbitrary files. Service-account-owned files count against that account’s storage and
are invisible to people until shared; prefer writing into a shared drive folder
(supportsAllDrives=True on Drive calls).
Workflow
- [ ] 1. Inspect the template: placeholders, tabs, tables, image slots
- [ ] 2. Copy the template into the target folder
- [ ] 3. Build all requests offline, then one batchUpdate
- [ ] 4. Re-read the document and check for leftovers
- [ ] 5. Export or share as requested (sharing needs confirmation)
1. Inspect the template
from googleapiclient.discovery import build
docs = build("docs", "v1", credentials=creds, cache_discovery=False)
drive = build("drive", "v3", credentials=creds, cache_discovery=False)
doc = docs.documents().get(documentId=TEMPLATE_ID, includeTabsContent=True).execute()
def walk(elements):
for el in elements:
if "paragraph" in el:
yield "".join(r.get("textRun", {}).get("content", "") for r in el["paragraph"]["elements"])
elif "table" in el:
for row in el["table"]["tableRows"]:
for cell in row["tableCells"]:
yield from walk(cell["content"])
import re
for tab in doc.get("tabs", []):
text = "".join(walk(tab["documentTab"]["body"]["content"]))
print(tab["tabProperties"]["title"], sorted(set(re.findall(r"\{\{[a-z0-9_]+\}\}", text))))
Without includeTabsContent=True, body only reflects the first tab. Headers, footers
and footnotes are separate segments (doc["headers"], doc["footers"]); placeholders
there are replaced by replaceAllText too, but index-based inserts need their segmentId.
2. Copy
copy = drive.files().copy(
fileId=TEMPLATE_ID,
body={"name": f"Proposal - {client['name']} - {today}", "parents": [OUTPUT_FOLDER_ID]},
supportsAllDrives=True,
fields="id, webViewLink",
).execute()
doc_id = copy["id"]
For idempotency, search the folder for an existing file with the same name (or store the created id against the record) before copying again.
3. Requests
Placeholders first; they do not depend on indexes:
def replace(ph, value):
return {"replaceAllText": {"containsText": {"text": "{{" + ph + "}}", "matchCase": True},
"replaceText": str(value)}}
requests = [replace(k, v) for k, v in fields.items()]
docs.documents().batchUpdate(documentId=doc_id, body={"requests": requests}).execute()
Index-based inserts (variable sections). Indexes are UTF-16 code unit offsets in the body, starting at 1; every insert shifts everything after it. Rules:
- Re-
getthe document after placeholder replacement; never reuse indexes from the template. - Find anchors by text (e.g. a
{{line_items}}paragraph), take itsstartIndex. - Send inserts in descending index order in one batch, or insert at one index in reverse content order, so earlier indexes stay valid.
- Emoji and some CJK characters count as 2 UTF-16 units; compute lengths with
len(s.encode("utf-16-le")) // 2, notlen(s).
def para_start(doc, marker):
for el in doc["body"]["content"]:
p = el.get("paragraph")
if p and marker in "".join(r.get("textRun", {}).get("content", "") for r in p["elements"]):
return el["startIndex"], el["endIndex"]
raise LookupError(marker)
doc = docs.documents().get(documentId=doc_id).execute()
start, end = para_start(doc, "{{line_items}}")
text = "".join(f"{i['desc']}\t{i['total']}\n" for i in items)
reqs = [
{"deleteContentRange": {"range": {"startIndex": start, "endIndex": end - 1}}}, # keep the paragraph mark
{"insertText": {"location": {"index": start}, "text": text.rstrip("\n")}},
]
docs.documents().batchUpdate(documentId=doc_id, body={"requests": reqs}).execute()
Tables: insertTable creates empty cells; to fill them, get the document again, collect
each cell’s paragraph startIndex, and insert text into cells from the last cell to the
first. For many rows, a template table with one styled row plus insertTableRow requests
keeps formatting consistent.
Images: insertInlineImage needs a URI Google can fetch (public or signed URL, PNG/JPEG/GIF,
under 50 MB). Use a short-lived signed URL rather than making files public.
Slides
slides = build("slides", "v1", credentials=creds, cache_discovery=False)
pres = slides.presentations().get(presentationId=deck_id).execute()
template_slide = pres["slides"][1]["objectId"]
reqs = []
for n, region in enumerate(regions):
new_id = f"region_{n:03d}" # 5-50 chars, [a-zA-Z0-9_-:]
reqs.append({"duplicateObject": {"objectId": template_slide,
"objectIds": {template_slide: new_id}}})
reqs.append({"replaceAllText": {"containsText": {"text": "{{region}}", "matchCase": True},
"replaceText": region["name"], "pageObjectIds": [new_id]}})
reqs.append({"deleteObject": {"objectId": template_slide}})
reqs.append({"replaceAllShapesWithImage": {
"imageUrl": chart_url, "imageReplaceMethod": "CENTER_INSIDE",
"containsText": {"text": "{{chart}}", "matchCase": True}}})
slides.presentations().batchUpdate(presentationId=deck_id, body={"requests": reqs}).execute()
Duplicated slides land right after the original; their child element ids are generated
unless mapped. Restrict replaceAllText with pageObjectIds so per-slide values do not
leak onto other duplicates.
Export
import io
from googleapiclient.http import MediaIoBaseDownload
req = drive.files().export_media(fileId=doc_id, mimeType="application/pdf")
buf = io.BytesIO(); dl = MediaIoBaseDownload(buf, req)
done = False
while not done:
_, done = dl.next_chunk()
open(f"out/{doc_id}.pdf", "wb").write(buf.getvalue())
Export is limited to about 10 MB of output; very large decks need splitting.
Other targets: application/vnd.openxmlformats-officedocument.wordprocessingml.document
(docx), ...presentationml.presentation (pptx), text/markdown for Docs.
Apps Script equivalent
function fillProposal(templateId, folderId, data) {
const copy = DriveApp.getFileById(templateId).makeCopy(`Proposal - ${data.client}`, DriveApp.getFolderById(folderId));
const doc = DocumentApp.openById(copy.getId());
const body = doc.getBody();
for (const [k, v] of Object.entries(data)) {
body.replaceText(`\\{\\{${k}\\}\\}`, String(v)); // pattern is a regex string
}
doc.saveAndClose();
return copy.getUrl();
}
replaceText takes a regular expression string, so braces and other metacharacters in
placeholders must be escaped. The REST replaceAllText matches literally.
Quotas and retries
Each batchUpdate counts as one write request no matter how many requests it holds;
per-user write quotas are low (on the order of 60 per minute), so batch per document.
Retry 429 and 5xx with exponential backoff and jitter. A failed batchUpdate applies
none of its requests (it is atomic), so retrying the whole batch is safe.
Verify
- Re-read each output and assert no
{{remains in any tab, header or footer. - Spot-check one output visually (open the
webViewLinkor export a PDF thumbnail). - Confirm the file is in the target folder and has only the intended permissions:
drive.permissions().list(fileId=doc_id, supportsAllDrives=True).execute(). - Rerun on the same record: it must find the existing file, not create a duplicate.
Hard rules
- Sharing files (
permissions.create), especially with external addresses or “anyone with the link”, and sending notification emails require explicit user confirmation. - Deleting or trashing Drive files requires explicit confirmation; prefer moving to an archive folder.
- Do not edit the template itself from automation; always work on copies.
Gotchas
replaceAllTextreplaces text but keeps the placeholder’s formatting; style the placeholder in the template the way the value should look.- A replacement containing
\ncreates new paragraphs that inherit the placeholder’s paragraph style (bullets included). - Smart chips (people, dates) and suggestions mode content are not plain text runs; they will not match placeholders.
- Documents with suggestions:
documents.getshows them unless you pass asuggestionsViewMode; usePREVIEW_WITHOUT_SUGGESTIONSwhen reading final text.
Output
## Docs generation: Q4 proposals
- Template: Proposal template v3 (1XyZ...), 2 tabs, 14 placeholders
- Created: 42 docs in Shared drive/Proposals/2026-Q4, 2 batchUpdates each, 0 retries
- Checks: 0 leftover placeholders; permissions inherited from folder only; PDFs exported for 42/42 What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Google Docs Automation"
short_description: "Template copies in, finished Docs and Slides out"
default_prompt: "$google-docs-automation Create a Google Doc proposal for each client in clients.csv from our Drive template and save PDFs too" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.