---
title: Google Docs Export (publish Markdown to Google Docs)
---

# Google Docs Export (publish Markdown to Google Docs)

## What it is

Omniscio's Google Docs Export takes a Markdown string from anywhere in the app and publishes it as a fully-formatted new Google Doc — headings, bold/italic, fenced code, lists, blockquotes, links, and native Google Docs **tables** are all preserved. The destination Doc lives in your Drive (a default folder, or one you configure), the URL comes back as a toast with **Open Doc**, and older versions of the same title in the same folder are auto-trashed so the destination folder stays clean.

When you trigger a publish from the UI, a persistent **"Publishing to Google Docs…"** toast with a spinner appears the instant you click, and its text advances through the real publish stages as they happen — _Preparing → Creating the document → Adding your content → Formatting → Finishing up_ — before being replaced by the **Open Doc** success toast (or a humanized error). It's honest live progress streamed from the main process, not a faked timer, so for the ~3–5 seconds a publish takes you always see that it started and roughly where it is.

The feature is **off by default** and gated by `googleDocsExportEnabled` in Settings → Connections → Google Workspace → Docs Export. Turning it on does NOT add an OAuth consent step — Google Docs Export piggybacks on the same single "Connect Google" sign-in that Calendar, Drive, Sheets, and Gmail share, because the publisher uses the `documents` + `drive` scopes you already approved at connect time.

Both entry points feed the same publisher pipeline, so a published doc looks the same regardless of where you triggered it from.

## Where to find it

### How to use it

There are two convergent entry points. Each lands in the same `publishMarkdownToGoogleDocs()` orchestrator, so the resulting Doc is byte-identical regardless of which surface you used:

1. **Right-click in the UI** on six surfaces:
   - A project doc card in the **Project Docs** section of a project dashboard ([ProjectDocsSection.tsx](/src/renderer/src/features/dashboard/ProjectDocsSection.tsx)).
   - A scratchpad row in the **Scratchpads** view ([ScratchpadsView.tsx](/src/renderer/src/features/scratchpads/ScratchpadsView.tsx)).
   - An agent message bubble in a session ([MessageBubble.tsx](/src/renderer/src/components/ui/MessageBubble/MessageBubble.tsx)).
   - **A whole session transcript** — the **Export to Google Docs** item in the session `⋯` → **Exports** submenu ([SessionOverflowMenu.tsx](/src/renderer/src/features/sessions/SessionOverflowMenu.tsx); handler `handlePublishToGoogleDocs` in [SessionPanel.tsx](/src/renderer/src/features/sessions/SessionPanel.tsx)). Publishes the same visible-only transcript the other session exports use. Shown only when this feature is enabled.
   - **A markdown file link inside chat** — right-clicking a `.md` or `.markdown` link in any agent or user message ([agent-markdown-helpers.tsx](/src/renderer/src/components/ui/agent-markdown-helpers.tsx) → `CopyableLink`). The menu item appears next to the existing **Copy path** + **Share as public artifact** entries, but only when the link's extension is `.md` or `.markdown`. The file is read off disk in the main process; the document title defaults to the file's basename (minus the extension).
   - **The File Peek Viewer** — when you click a file link to open the side-panel previewer ([FilePeekOverlay.tsx](/src/renderer/src/components/ui/FilePeekOverlay.tsx)), markdown files get a **Export to Google Docs** button in the action bar next to **Copy contents** / **Share** / **Edit**. Hidden for non-markdown files.

   Every surface shows **Export to Google Docs** in its right-click menu / overflow / action bar. Title defaults to the doc/scratchpad name, the message's first heading, or — for path-mode surfaces — the file's basename. A success toast surfaces the URL with **Open Doc** for one-click navigation. All five routes share [usePublishMarkdownToGoogleDoc.ts](/src/renderer/src/hooks/usePublishMarkdownToGoogleDoc.ts), which centralises the feature-flag gate, empty-content guard, error→toast mapping, and the `amc://settings/integrations/google-account` re-auth deeplink. The hook accepts XOR input — either `markdown` (content surfaces) or `path` (file-link surfaces) — and the IPC handler delegates path-mode reads to the main-process [read-markdown-from-path.ts](/src/main/services/google-docs/read-markdown-from-path.ts) helper so the file-read limits stay consistent with the CLI route.

2. **External AI / CLI** via `POST /gdoc/publish` on Omniscio's local control server (`127.0.0.1:19519` by default). Body shape is `{ markdown, title? }` OR `{ path, title? }` (a local `.md` / `.markdown` file). Bearer-token auth, 1 MB body cap, 2 MB markdown cap, 10/min mutation bucket. Returns the same `{ docId, url, title, trashedOlderCount }` shape as the IPC handler. Useful when you want claude.ai, the phone web UI, or a third-party CLI to publish straight into your Omniscio Drive without re-implementing the conversion pipeline. The CLI route shares the `readMarkdownFromPath()` helper with the IPC handler, so a Drive document built from a local file is byte-identical whether the file was opened from the chat right-click menu or from an external curl call.

## How it behaves

### How it works

The end-to-end pipeline is the same three-batch apply for every entry point — porting it once into [/src/main/services/google-docs/publisher.ts](/src/main/services/google-docs/publisher.ts) means a markdown blob renders identically whether the originator was a right-click menu or an external HTTP POST.

**Auth.** [google-auth-service.ts](/src/main/services/google/google-auth-service.ts) owns the shared Google OAuth2Client and the refresh token. The publisher calls `getAuthClient()` to mint a `googleapis` `docs.v1` + `drive.v3` pair. If the user isn't signed in OR the documents scope wasn't granted, `PublisherError` throws with `GDOC_NOT_AUTHENTICATED` / `GDOC_SCOPE_INSUFFICIENT` and `needsReauth: true` — every consumer surfaces this as a toast with a **Re-authorize** action that deep-links to Settings → Connections → Google Workspace.

**Markdown → request batches.** [markdown-to-doc.ts](/src/main/services/google-docs/markdown-to-doc.ts) parses Markdown via `marked` and emits four arrays:

- `insertRequests` — the raw text insertions (headings flattened to plain text, bullets prefixed with tabs).
- `listRequests` — `createParagraphBullets` ranges that promote the tab-prefixed lines into native lists (the tab characters are removed by this batch).
- `styleRequests` — `updateTextStyle` (bold/italic/code/links) + `updateParagraphStyle` (heading levels, blockquotes) requests, whose offsets are then re-adjusted by `adjustStyleIndicesForTabRemoval()` to compensate for the tabs the list batch consumed.
- `tableDescriptors` — placeholder regions that [tables.ts](/src/main/services/google-docs/tables.ts) replaces with native Docs tables in a follow-up pass.

**The three-batch apply order is LOCKED** — text → lists → styles + tables — because Google Docs' `batchUpdate` index math depends on each step having already happened. Re-ordering produces visibly broken output (style runs misaligned by one character per bullet, lists with stray tabs, tables inserted into the wrong paragraphs). The orchestrator owns the order; the parser only provides the batches.

**Orchestrator.** `publishMarkdownToGoogleDocs()` runs:

1. Copy the template doc → new file in the destination folder, renamed to the resolved title (the template's footer with page numbering is preserved on the copy).
2. `documents.get` to find the first `tabId` (templates use tabs to keep the page number footer; if the copy has zero tabs the template is misconfigured → `GDOC_NO_TAB`).
3. Clear the template body via a `deleteContentRange` on the tab (footer survives because it lives outside the body segment).
4. Three sequential `batchUpdate` calls — insert, lists, styles — each scoped to the resolved `tabId`.
5. `applyTables()` — replaces table placeholders with native Docs tables.
6. **Trash older versions** of the same title in the destination folder (`drive.files.list` with a `name = ... and ... in parents and mimeType = 'application/vnd.google-apps.document' and trashed = false` query, then `files.update {trashed: true}` for each match older than the just-published doc). Non-fatal on failure — publish has already succeeded; trash failures only log.

**Live progress.** The publisher takes an optional `onProgress(phase)` and calls it between the steps above (`preparing` → `create` → `insert` → `format` → `finalize`) — purely additive, it never reorders the locked three-batch apply. When a UI publish supplies a renderer-minted `progressId`, the IPC handler bridges `onProgress` to an `emitPush(GDOC_PUBLISH_PROGRESS, { progressId, phase })` push; the shared hook ([usePublishMarkdownToGoogleDoc.ts](/src/renderer/src/hooks/usePublishMarkdownToGoogleDoc.ts)) shows the persistent "Publishing…" toast (a `loading` spinner), advances its text per phase for its own `progressId` only (a unique `coalesceKey` keeps concurrent publishes independent), and removes it on resolve. The CLI route sends no `progressId`, so it streams nothing. Progress is an enhancement layer — the start + success/error toasts work even if no event arrives. Full invariant: I7 in [google-docs-export-contract.md](/.claude/memory/contracts/google-docs-export-contract.md).

**Defaults + overrides.** Template doc ID and destination folder ID resolve in priority order: explicit input → user setting (`googleDocsTemplateId` / `googleDocsDestFolderId`) → hard-coded defaults baked into the publisher. The hard-coded defaults match the originating standalone skill at `~/.claude/skills/google-docs-from-markdown/`, so a user with no overrides gets the same output as the skill they may have used before Omniscio shipped the integration.

**Stable error codes.** Every entry point returns the same `PublisherErrorCode` discriminator (or wraps it in an IPC envelope / HTTP status code, but the underlying code is the same string):

- `GDOC_FEATURE_DISABLED` — `googleDocsExportEnabled` is off (IPC + CLI gate; the publisher itself does not check the flag).
- `GDOC_NOT_AUTHENTICATED` — no refresh token in config OR the token was revoked.
- `GDOC_SCOPE_INSUFFICIENT` — the documents scope was never granted at connect time (user must re-auth).
- `GDOC_PARSE_FAILED` — `markdown-to-doc.ts` threw on the input (malformed markdown that the parser can't recover from).
- `GDOC_API_FAILED` — `docs.batchUpdate` / `drive.files.copy` / etc. returned a non-401 error (network, quota, throttle).
- `GDOC_NO_TAB` — the copied template has zero tabs (template misconfigured — user needs to fix the template doc).

**IPC handler.** [google-doc-handlers.ts](/src/main/ipc/google-doc-handlers.ts) wraps the publisher in a `wrapHandler('publish markdown to google doc', ...)` that always returns `{ success: true, data: GdocPublishMarkdownResult }` — the discriminated-union result carries the same error code on failure, so the renderer never sees a raw thrown string. The handler is the gate that enforces `googleDocsExportEnabled` (the publisher itself is flag-agnostic so the same code path can be reused by the CLI route without re-checking).

**Renderer recovery.** The right-click surfaces publish through [usePublishMarkdownToGoogleDoc.ts](/src/renderer/src/hooks/usePublishMarkdownToGoogleDoc.ts), which humanizes every failure before it reaches a toast — no raw `invalid_grant` / `token expired` / HTTP-status text is ever shown, via the shared `friendlyGoogleProductError` translator (the same one Drive / Sheets / Calendar use). An expired or revoked Google session (`GDOC_NOT_AUTHENTICATED` / `GDOC_SCOPE_INSUFFICIENT`) shows a **Re-authorize** toast action that opens Settings → Google Workspace to reconnect; an unenabled Docs API shows an **Enable Docs API** link. (Navigation goes through `useWizardNavigationStore.setPendingSection`, not `window.location.hash` — Omniscio has no hash router.)

**CLI route.** [cli-server.ts § registerGdocRoutes](/src/main/services/cli/cli-server.ts) wires `POST /gdoc/publish` as an apply-immediately mutation behind the standard bearer-token gate + 10/min mutation bucket. Accepts either an inline `markdown` blob or a `path` to a local `.md` / `.markdown` file (read off disk inside the route). HTTP status mapping: `200` published, `400` bad body, `401` auth missing, `403` feature disabled, `413` body too large, `422` parse failed, `502` Google API failed. **Safe to retry:** an identical repeat — same `X-Client-Request-Id` header, or the same publish inputs when no header is sent — returns the doc the first call already created (`idempotent: true`) instead of making a duplicate, so a dropped response or network retry can't double-publish.

**gog CLI fallback (resilience).** If a publish is refused because the Docs API isn't enabled on Omniscio's Google Cloud project — or a Docs scope is missing — Omniscio automatically retries the publish through the local [`gog` CLI](/src/main/services/google/google-cli-fallback.ts), on your connected Google account, so the export still succeeds. Both entry points (the IPC handler and the CLI route) publish through `publishMarkdownToGoogleDocsWithFallback`, so every surface is covered. It's best-effort and safe-by-default: it only activates when `gog` is installed and signed into the **same** account Omniscio is connected to (guarded so a doc can never land in the wrong account), it's gated by the `googleCliFallbackEnabled` setting (on by default), and if `gog` can't help it falls back closed to the normal humanized error. Wiring this also made the publisher atomic — a failed publish no longer leaves an empty template copy in your Drive. Engineering detail + invariants: [google-cli-fallback-contract.md](/.claude/memory/contracts/google-cli-fallback-contract.md).

**Settings UI.** [GoogleDocsExportSettings.tsx](/src/renderer/src/features/settings/GoogleDocsExportSettings.tsx) is the collapsible card inside [GoogleWorkspaceSettings.tsx](../../src/renderer/src/features/settings/sections/google-workspace/GoogleWorkspaceSettings.tsx) — three settings, all reachable via Settings search: `google-docs-export-enabled` (the toggle), `google-docs-template-id` (template Doc ID override, optional), `google-docs-dest-folder-id` (destination folder ID override, optional). The card sits alongside Calendar / Drive / Sheets because all four piggyback on the same Google sign-in.

**Integration registry.** Entry `google-docs-export` in [integration-registry.ts](/src/shared/integration-registry.ts) — `kind: 'channel-adapter'`, `featureFlag: 'googleDocsExportEnabled'`, `settingsSearchId: 'google-docs-export-enabled'`, `llmDocPath: 'docs/llm-library/google-docs-export.md'`. The CI completeness lint enforces that every claim on the row has real backing wiring.

**Telemetry.** Every publish attempt fires `gdoc_published` via `trackEvent()` — `attempted` on failure (with `errorCode`), `published` on success (with `markdownChars` and `trashedOlderCount`). The free-form `surface` label distinguishes the originator: `renderer:project-doc`, `renderer:scratchpad`, `renderer:message-bubble`, `renderer:chat-file-link`, `renderer:file-peek` from the five right-click surfaces, `cli` from the HTTP route, and `ipc` / `unknown` as fallbacks when no label is supplied.

## Related

- [INDEX.md](INDEX.md) — full library index
- [google-integrations.md](google-integrations.md) — umbrella doc for Calendar + Drive + Sheets + Gmail, shared OAuth; documents the **reverse** route `POST /gdoc/import` (Google Doc → markdown), a separate feature gated by `googleDocImportEnabled`
- [drive-integration.md](drive-integration.md) — sibling Google integration using the same OAuth client
- [sheets-integration.md](sheets-integration.md) — sibling Google integration with similar agent-driven UX
