
Content Gdocs
- 1 installs
- 7 repo stars
- Updated April 12, 2026
- isaac-flath/agent-starter-skills
Push blog posts to Google Docs for review, then pull reviewer comments and suggestions back as feedback.
About
Pushes blog posts to Google Docs for collaborative review, then pulls reviewer comments and tracked suggestions back into the project. A developer uses it to run content through a Google Docs review loop and discuss the feedback.
- Pushes blog posts to Google Docs for reviewer comments and suggestions
- Pulls tracked comments and changes back into the project as feedback
Content Gdocs by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,983 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Jul 8, 2026 (Skillselion catalog sync)
npx skills add https://github.com/isaac-flath/agent-starter-skills --skill content-gdocsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 7 |
| Last updated | April 12, 2026 |
| Repository | isaac-flath/agent-starter-skills ↗ |
What it does
Push blog posts to Google Docs for review, then pull reviewer comments and suggestions back as feedback.
Files
/content-gdocs
Push content to Google Docs for collaborative review. Reviewers add comments and suggestions (tracked changes) in Google Docs, then pull that feedback back into the project for discussion with Claude.
Usage
# One-time setup
uv run .claude/skills/content-gdocs/scripts/setup_auth.py
# Push blog post to Google Docs
uv run .claude/skills/content-gdocs/scripts/push.py <project-dir>
uv run .claude/skills/content-gdocs/scripts/push.py <project-dir> --file content/blog.md
uv run .claude/skills/content-gdocs/scripts/push.py <project-dir> --share reviewer@example.com
# Pull feedback from Google Docs
uv run .claude/skills/content-gdocs/scripts/pull.py <project-dir>
uv run .claude/skills/content-gdocs/scripts/pull.py <project-dir> --file content/blog.mdSetup (One-Time)
1. Google Cloud Project
1. Go to Google Cloud Console 2. Create a new project (or select an existing one) 3. Enable the Google Docs API and Google Drive API:
- APIs & Services > Library > search "Google Docs API" > Enable
- APIs & Services > Library > search "Google Drive API" > Enable
2. OAuth Credentials
1. Go to APIs & Services > Credentials 2. Click "Create Credentials" > "OAuth client ID" 3. Application type: Desktop app 4. Download the JSON file 5. Save it to: ~/.content/google_credentials.json
3. System Dependency
brew install pandocRequired for markdown-to-DOCX conversion with embedded images.
4. Authenticate
uv run .claude/skills/content-gdocs/scripts/setup_auth.pyThis opens a browser for Google sign-in and saves a refresh token to ~/.content/google_token.json.
Workflow
Push
1. Reads the markdown file (default: content/blog.md) 2. Converts to DOCX via pandoc (preserves headings, formatting, and images) 3. Uploads to Google Drive as a native Google Doc 4. Shares with specified reviewers as "commenter" (can view + suggest, not directly edit) 5. Tracks the document in docs.json
Re-pushing creates a new document (v2, v3...) to preserve comments on previous versions. Previous collaborators are auto-shared with the new doc.
Pull
1. Reads docs.json to find the Google Doc ID 2. Fetches comments via Drive API (author, quoted text, replies) 3. Fetches suggestions via Docs API (tracked insertions/deletions) 4. Writes structured feedback to content/feedback.md
The feedback file is context for discussion, not auto-apply. After pulling, review feedback.md with Claude to decide what to incorporate, what to push back on, and what to ignore.
Document Tracking: docs.json
Stored in the project directory. Maps local files to Google Doc IDs/URLs.
{
"files": [
{
"local_path": "content/blog.md",
"doc_id": "1a2b3c...",
"doc_url": "https://docs.google.com/document/d/1a2b3c.../edit",
"title": "How I Made My Website",
"pushed_at": "2026-03-02T14:00:00Z",
"last_pulled_at": null,
"version": 1,
"shared_with": ["reviewer@example.com"],
"history": []
}
]
}Limitations
1. Pandoc system dependency -- brew install pandoc needed for markdown+image conversion 2. Feedback is discussion context, not auto-apply -- feedback.md is meant for reviewing with Claude 3. New doc per push -- re-pushing creates a new Google Doc to preserve comments on old versions 4. One-time GCP Console setup -- ~5 min manual process to create project and download credentials 5. Suggestion parsing is best-effort -- simple word/phrase replacements parse cleanly; complex structural suggestions may be incomplete
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "google-api-python-client",
# "google-auth-oauthlib",
# "google-auth-httplib2",
# ]
# ///
"""Pull comments and suggestions from a Google Doc into feedback.md.
Reads docs.json to find the Google Doc ID, fetches comments via
Drive API and suggestions via Docs API, and writes a structured
feedback file for discussion with Claude.
"""
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
SCOPES = [
"https://www.googleapis.com/auth/documents.readonly",
"https://www.googleapis.com/auth/drive.file",
]
TOKEN_PATH = Path.home() / ".content" / "google_token.json"
def get_credentials() -> Credentials:
"""Load and refresh OAuth credentials."""
if not TOKEN_PATH.exists():
print(f"Error: No token found at {TOKEN_PATH}")
print("Run setup_auth.py first.")
sys.exit(1)
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
TOKEN_PATH.write_text(creds.to_json())
elif not creds.valid:
print("Error: Token is invalid. Run setup_auth.py again.")
sys.exit(1)
return creds
def load_docs_json(project_dir: Path) -> dict:
"""Load docs.json from project directory."""
docs_path = project_dir / "docs.json"
if not docs_path.exists():
print(f"Error: No docs.json found in {project_dir}")
print("Push a document first with push.py.")
sys.exit(1)
return json.loads(docs_path.read_text())
def find_file_entry(data: dict, local_path: str) -> dict | None:
"""Find an existing entry for a local file in docs.json."""
for entry in data["files"]:
if entry["local_path"] == local_path:
return entry
return None
def fetch_comments(drive_service, doc_id: str) -> list[dict]:
"""Fetch all comments from a Google Doc via Drive API."""
comments = []
page_token = None
while True:
response = drive_service.comments().list(
fileId=doc_id,
fields="comments(id,author,content,quotedFileContent,resolved,createdTime,replies(author,content,createdTime)),nextPageToken",
includeDeleted=False,
pageToken=page_token,
).execute()
comments.extend(response.get("comments", []))
page_token = response.get("nextPageToken")
if not page_token:
break
return comments
def extract_suggestions(doc: dict) -> list[dict]:
"""Extract suggestions (tracked changes) from a Google Doc.
Parses the document body for text runs with suggestedInsertionIds
or suggestedDeletionIds, grouping them by suggestion ID.
"""
suggestions = {}
body = doc.get("body", {})
for element in body.get("content", []):
paragraph = element.get("paragraph")
if not paragraph:
continue
for elem in paragraph.get("elements", []):
text_run = elem.get("textRun")
if not text_run:
continue
content = text_run.get("content", "")
suggested_insertions = text_run.get("suggestedInsertionIds", [])
suggested_deletions = text_run.get("suggestedDeletionIds", [])
for sid in suggested_insertions:
suggestions.setdefault(sid, {"inserts": [], "deletes": [], "author": None})
suggestions[sid]["inserts"].append(content)
for sid in suggested_deletions:
suggestions.setdefault(sid, {"inserts": [], "deletes": [], "author": None})
suggestions[sid]["deletes"].append(content)
# Try to get author info from suggestedChanges on text style
suggested_changes = doc.get("suggestedDocumentStyleChanges", {})
for sid, change in suggested_changes.items():
if sid in suggestions:
author = change.get("suggestionsViewMode", {}).get("suggestedBy")
if author:
suggestions[sid]["author"] = author
return [
{"id": sid, **data}
for sid, data in suggestions.items()
]
def format_feedback(
doc_title: str,
doc_url: str,
comments: list[dict],
suggestions: list[dict],
) -> str:
"""Format comments and suggestions into markdown."""
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lines = [
"# Review Feedback",
f"Source: [{doc_title}]({doc_url})",
f"Pulled: {now}",
"",
]
# Comments section
open_count = sum(1 for c in comments if not c.get("resolved"))
resolved_count = sum(1 for c in comments if c.get("resolved"))
if comments:
lines.append("## Comments")
lines.append("")
for comment in comments:
author = comment.get("author", {}).get("displayName", "Unknown")
email = comment.get("author", {}).get("emailAddress", "")
author_label = email if email else author
created = comment.get("createdTime", "")
resolved = comment.get("resolved", False)
status_tag = " [RESOLVED]" if resolved else ""
lines.append(f"### {author_label} ({created}){status_tag}")
quoted = comment.get("quotedFileContent", {}).get("value", "")
if quoted:
for quoted_line in quoted.splitlines():
lines.append(f"> {quoted_line}")
lines.append("")
lines.append(comment.get("content", ""))
lines.append("")
replies = comment.get("replies", [])
if replies:
lines.append("#### Replies")
for reply in replies:
reply_author = reply.get("author", {}).get("displayName", "Unknown")
reply_email = reply.get("author", {}).get("emailAddress", "")
reply_label = reply_email if reply_email else reply_author
reply_time = reply.get("createdTime", "")
lines.append(f"- {reply_label} ({reply_time}): {reply.get('content', '')}")
lines.append("")
# Suggestions section
if suggestions:
lines.append("## Suggestions (Tracked Changes)")
lines.append("")
for suggestion in suggestions:
author = suggestion.get("author") or "Reviewer"
lines.append(f"### {author}")
deleted_text = "".join(suggestion["deletes"]).strip()
inserted_text = "".join(suggestion["inserts"]).strip()
if deleted_text:
lines.append(f'- **Delete:** "{deleted_text}"')
if inserted_text:
lines.append(f'- **Insert:** "{inserted_text}"')
if not deleted_text and not inserted_text:
lines.append("- *(empty suggestion)*")
lines.append("")
# Summary
lines.append("## Summary")
if comments:
lines.append(f"- {len(comments)} comments ({resolved_count} resolved, {open_count} open)")
else:
lines.append("- 0 comments")
if suggestions:
lines.append(f"- {len(suggestions)} suggestions pending")
else:
lines.append("- 0 suggestions")
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser(
description="Pull review feedback from Google Docs into feedback.md"
)
parser.add_argument(
"project_dir",
help="Project directory path",
)
parser.add_argument(
"--file",
default="content/blog.md",
help="Local file to pull feedback for (default: content/blog.md)",
)
parser.add_argument(
"--output",
default="content/feedback.md",
help="Output file path (relative to project dir, default: content/feedback.md)",
)
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
# Look up doc ID from docs.json
data = load_docs_json(project_dir)
entry = find_file_entry(data, args.file)
if not entry:
print(f"Error: No Google Doc found for '{args.file}' in docs.json")
print("Push the file first with push.py.")
return 1
doc_id = entry["doc_id"]
doc_url = entry["doc_url"]
doc_title = entry.get("title", "Untitled")
print(f"Pulling feedback from: {doc_title}")
print(f" Doc: {doc_url}")
creds = get_credentials()
drive_service = build("drive", "v3", credentials=creds)
docs_service = build("docs", "v1", credentials=creds)
# Fetch comments
print(" Fetching comments...")
comments = fetch_comments(drive_service, doc_id)
# Fetch suggestions
print(" Fetching suggestions...")
doc = docs_service.documents().get(
documentId=doc_id,
suggestionsViewMode="PREVIEW_SUGGESTIONS_INLINE",
).execute()
suggestions = extract_suggestions(doc)
# Format and write feedback
feedback = format_feedback(doc_title, doc_url, comments, suggestions)
output_path = project_dir / args.output
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(feedback)
# Update last_pulled_at in docs.json
entry["last_pulled_at"] = datetime.now(timezone.utc).isoformat()
docs_path = project_dir / "docs.json"
docs_path.write_text(json.dumps(data, indent=2) + "\n")
print(f"\nFeedback written to: {args.output}")
print(f" {len(comments)} comments, {len(suggestions)} suggestions")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "google-api-python-client",
# "google-auth-oauthlib",
# "google-auth-httplib2",
# "pypandoc",
# ]
# ///
"""Push a markdown file to Google Docs for review.
Converts markdown → DOCX (via pypandoc/pandoc) with embedded images,
uploads to Google Drive with conversion to native Google Docs format,
and tracks the document in docs.json.
Requires pandoc: brew install pandoc
"""
import argparse
import json
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
import pypandoc
from google.oauth2.credentials import Credentials
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
SCOPES = [
"https://www.googleapis.com/auth/documents.readonly",
"https://www.googleapis.com/auth/drive.file",
]
TOKEN_PATH = Path.home() / ".content" / "google_token.json"
def get_credentials() -> Credentials:
"""Load and refresh OAuth credentials."""
if not TOKEN_PATH.exists():
print(f"Error: No token found at {TOKEN_PATH}")
print("Run setup_auth.py first.")
sys.exit(1)
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
TOKEN_PATH.write_text(creds.to_json())
elif not creds.valid:
print("Error: Token is invalid. Run setup_auth.py again.")
sys.exit(1)
return creds
def load_docs_json(project_dir: Path) -> dict:
"""Load docs.json from project directory."""
docs_path = project_dir / "docs.json"
if docs_path.exists():
return json.loads(docs_path.read_text())
return {"files": []}
def save_docs_json(project_dir: Path, data: dict) -> None:
"""Save docs.json to project directory."""
docs_path = project_dir / "docs.json"
docs_path.write_text(json.dumps(data, indent=2) + "\n")
def find_file_entry(data: dict, local_path: str) -> dict | None:
"""Find an existing entry for a local file in docs.json."""
for entry in data["files"]:
if entry["local_path"] == local_path:
return entry
return None
def convert_md_to_docx(md_path: Path, project_dir: Path) -> Path:
"""Convert markdown to DOCX with embedded images via pandoc."""
docx_path = Path(tempfile.mktemp(suffix=".docx"))
pypandoc.convert_file(
str(md_path),
"docx",
outputfile=str(docx_path),
extra_args=["--resource-path", str(project_dir)],
)
return docx_path
def upload_docx(drive_service, docx_path: Path, title: str) -> dict:
"""Upload DOCX to Google Drive, converting to Google Docs format."""
file_metadata = {
"name": title,
"mimeType": "application/vnd.google-apps.document",
}
media = MediaFileUpload(
str(docx_path),
mimetype="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
resumable=True,
)
file = drive_service.files().create(
body=file_metadata,
media_body=media,
fields="id,webViewLink",
).execute()
return file
def share_doc(drive_service, doc_id: str, email: str) -> None:
"""Share a document with commenter access."""
permission = {
"type": "user",
"role": "commenter",
"emailAddress": email,
}
drive_service.permissions().create(
fileId=doc_id,
body=permission,
sendNotificationEmail=False,
).execute()
print(f" Shared with {email} (commenter)")
def main() -> int:
parser = argparse.ArgumentParser(
description="Push a markdown file to Google Docs for review"
)
parser.add_argument(
"project_dir",
help="Project directory path",
)
parser.add_argument(
"--file",
default="content/blog.md",
help="Markdown file to push (relative to project dir, default: content/blog.md)",
)
parser.add_argument(
"--share",
action="append",
default=[],
metavar="EMAIL",
help="Email address to share with (can be repeated)",
)
parser.add_argument(
"--title",
help="Document title (default: derived from file content or filename)",
)
args = parser.parse_args()
project_dir = Path(args.project_dir).resolve()
md_path = project_dir / args.file
if not md_path.exists():
print(f"Error: File not found: {md_path}")
return 1
# Read markdown to extract title if not provided
md_content = md_path.read_text()
title = args.title
if not title:
for line in md_content.splitlines():
if line.startswith("# "):
title = line[2:].strip()
break
if not title:
title = md_path.stem.replace("-", " ").replace("_", " ").title()
# Load existing docs.json
data = load_docs_json(project_dir)
existing = find_file_entry(data, args.file)
# Determine version number
version = 1
if existing:
version = existing.get("version", 1) + 1
title_with_version = f"{title} (v{version})"
else:
title_with_version = title
print(f"Converting {args.file} to DOCX...")
docx_path = convert_md_to_docx(md_path, project_dir)
try:
creds = get_credentials()
drive_service = build("drive", "v3", credentials=creds)
print(f"Uploading to Google Docs as '{title_with_version}'...")
file = upload_docx(drive_service, docx_path, title_with_version)
doc_id = file["id"]
doc_url = file["webViewLink"]
now = datetime.now(timezone.utc).isoformat()
# Collect emails to share with
share_emails = set(args.share)
if existing:
# Auto-share with previous collaborators
for email in existing.get("shared_with", []):
share_emails.add(email)
for email in share_emails:
share_doc(drive_service, doc_id, email)
# Update docs.json
new_entry = {
"doc_id": doc_id,
"doc_url": doc_url,
"version": version,
"pushed_at": now,
}
if existing:
# Add current to history before updating
history_entry = {
"doc_id": existing["doc_id"],
"doc_url": existing["doc_url"],
"version": existing["version"],
"pushed_at": existing["pushed_at"],
}
existing.setdefault("history", []).append(history_entry)
existing["doc_id"] = doc_id
existing["doc_url"] = doc_url
existing["title"] = title_with_version
existing["pushed_at"] = now
existing["last_pulled_at"] = None
existing["version"] = version
existing["shared_with"] = sorted(share_emails)
else:
data["files"].append({
"local_path": args.file,
"doc_id": doc_id,
"doc_url": doc_url,
"title": title_with_version,
"pushed_at": now,
"last_pulled_at": None,
"version": version,
"shared_with": sorted(share_emails),
"history": [],
})
save_docs_json(project_dir, data)
print()
print(f"Google Doc: {doc_url}")
print(f"Version: {version}")
print(f"Tracked in: docs.json")
return 0
finally:
docx_path.unlink(missing_ok=True)
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "google-api-python-client",
# "google-auth-oauthlib",
# "google-auth-httplib2",
# ]
# ///
"""One-time OAuth setup for Google Docs/Drive API access.
Reads client credentials from ~/.content/google_credentials.json,
opens a browser for Google sign-in, and saves the refresh token
to ~/.content/google_token.json.
"""
import os
import sys
from pathlib import Path
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
from googleapiclient.discovery import build
SCOPES = [
"https://www.googleapis.com/auth/documents.readonly",
"https://www.googleapis.com/auth/drive.file",
]
CREDENTIALS_PATH = Path.home() / ".content" / "google_credentials.json"
TOKEN_PATH = Path.home() / ".content" / "google_token.json"
def main() -> int:
if not CREDENTIALS_PATH.exists():
print(f"Error: Client credentials not found at {CREDENTIALS_PATH}")
print()
print("To set up:")
print("1. Go to https://console.cloud.google.com/")
print("2. Create a project (or select an existing one)")
print("3. Enable the Google Docs API and Google Drive API")
print("4. Go to APIs & Services > Credentials")
print("5. Create an OAuth 2.0 Client ID (Desktop app type)")
print("6. Download the JSON and save it to:")
print(f" {CREDENTIALS_PATH}")
return 1
# Check for existing valid token
creds = None
if TOKEN_PATH.exists():
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), SCOPES)
if creds and creds.valid:
print(f"Already authenticated. Token at {TOKEN_PATH}")
_print_user_info(creds)
return 0
if creds and creds.expired and creds.refresh_token:
print("Token expired, refreshing...")
creds.refresh(Request())
else:
print("Opening browser for Google sign-in...")
flow = InstalledAppFlow.from_client_secrets_file(
str(CREDENTIALS_PATH), SCOPES
)
creds = flow.run_local_server(port=0)
# Save token
TOKEN_PATH.parent.mkdir(parents=True, exist_ok=True)
TOKEN_PATH.write_text(creds.to_json())
print(f"Token saved to {TOKEN_PATH}")
_print_user_info(creds)
return 0
def _print_user_info(creds: Credentials) -> None:
"""Print the authenticated user's email."""
try:
service = build("oauth2", "v2", credentials=creds)
user_info = service.userinfo().get().execute()
print(f"Authenticated as: {user_info.get('email', 'unknown')}")
except Exception:
print("Authenticated successfully.")
if __name__ == "__main__":
sys.exit(main())