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.

  • Documents & Office
  • $google-docs-automation
  • 241 lines
  • 2 files
  • ~2,550 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- google-docs-automation

Installs to ~/.agents/skills/google-docs-automation/ for every project on this machine.

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

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.

~/.agents/skills/google-docs-automation/SKILL.md

--- name: google-docs-automation description: Generates and edits Google Docs and Google Slides through the Docs API v1, Slides API v1 and Drive API v3, including template copying, replaceAllText… license: MIT ---

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

NeedApproach
Fixed layout with variable textCopy 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 dataSlides template with placeholder text/shapes, duplicateObject per item, replaceAllText, replaceAllShapesWithImage
Live chart from a SheetSlides/Docs linked chart from Sheets (createSheetsChart in Slides)
Owners want a button inside DocsApps Script (DocumentApp, SlidesApp) bound to the template
Output must be .docx or .pptx anywayGenerate 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:

  1. Re-get the document after placeholder replacement; never reuse indexes from the template.
  2. Find anchors by text (e.g. a {{line_items}} paragraph), take its startIndex.
  3. Send inserts in descending index order in one batch, or insert at one index in reverse content order, so earlier indexes stay valid.
  4. Emoji and some CJK characters count as 2 UTF-16 units; compute lengths with len(s.encode("utf-16-le")) // 2, not len(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 webViewLink or 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

  • replaceAllText replaces text but keeps the placeholder’s formatting; style the placeholder in the template the way the value should look.
  • A replacement containing \n creates 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.get shows them unless you pass a suggestionsViewMode; use PREVIEW_WITHOUT_SUGGESTIONS when 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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem