Marginal
Let a model read a Google Doc and leave native anchored comments in it — the real yellow side comments, attached to specific sentences, not a summary pasted somewhere else. It comments live, one at a time, and answers the replies in the thread.
Two reasons to want this: it turns a strong model into a research collaborator that reviews work in the place the work already lives, and the resulting threads (model comment → your reply → model reply) are data about what a useful review comment looks like.
Install
As a Claude Code plugin, if you want the setup done for you:
/plugin marketplace add oscar-gilg/marginal
/plugin install marginal
/marginal:setup
/marginal:setup finds or fetches the CLI, brings up a dedicated Chrome
profile, checks it is signed into Google, and writes a config matching what it
found. Then /marginal:review <doc-url> reviews a document and
/marginal:respond <doc-url> answers the replies.
As a CLI, if you would rather drive it yourself:
uvx marginal setup <doc-url> # the same checks, and a config to match
Pass a document URL and the check proves the browser session by exporting that document, which is the operation every run performs. Without one it can only tell you that docs.google.com did not bounce to a sign-in page, and says so.
There is one step neither route can do for you: signing in to Google. Chrome opens with a dedicated profile, you sign in once, and the profile keeps its session. Google frequently refuses sign-in inside an automation-launched browser, which is why nothing here pretends to automate it.
What you actually need
Almost everything is optional. Two things are not: a Chrome, and a Google session inside it.
| You have | What runs | What it costs you |
|---|---|---|
| Nothing but Chrome | source = "browser", mode = "agent", critic = false |
Comments post at the length the agent wrote them, and half the commands are unavailable — see below |
| A model API key | mode = "api" — this tool writes the comments, and the shortening pass runs |
— |
| Google OAuth too | source = "api" — a faster read, a revision check before each post, and every command |
— |
What the first row cannot do. comment, review, context, submit-brief
and post-batch all work with no Google credentials: they read through the browser
export and verify through it too. read, list, post, reply, unpost and
respond mint a token whatever the source, and exit with a message naming what to
run if there is none. reply, unpost and respond write to the comment list,
which has no browser route at all; read, list and post could have one and do
not yet.
So a Chrome-only install reviews a document and leaves anchored comments, but cannot answer the replies. That is the concrete cost of skipping OAuth, and it is the reason to do it eventually.
setup writes whichever of those fits, with the reason for each setting in the
file next to it. marginal config prints what a run here would use and where
each value came from.
The middle row is the one worth understanding. In agent mode a coding agent
already reading the document writes the comments and Marginal places them, so
nothing here calls a model and no key is needed — it runs on whatever subscription
the agent has. The exception is the shortening pass, which runs inside
post-batch on this tool's side, deliberately: it used to live in a prompt, and
any caller that reached post-batch without reading that prompt posted unedited
comments. With no key there is nothing to run it with, so setup turns it off
rather than failing on every comment.
Usage
marginal setup <doc-url> # check this machine, write a config
marginal chrome # the dedicated profile, on its own
marginal read <doc-url>
marginal list <doc-url>
marginal comment <doc-url> -n 5 # leave comments; `mode` says who writes them
marginal respond <doc-url> # answer replies to our comments
marginal post <doc-url> -q "exact quote" -b "comment text"
marginal reply <doc-url> -c <comment-id> -b "reply text"
marginal unpost <doc-url> -c <comment-id>
From a checkout, uv sync once and prefix each with uv run.
mode in the config decides who writes the comments, and comment runs whichever
it names: api calls a model from here, agent prints the brief for the coding
agent that is already reading the document. The explicit names still work —
review for API mode, context + post-batch for agent mode — and they beat the
setting, with a note, because comparing the two modes on one document should not
mean editing the config between the two halves of the comparison.
mode = "api" and source = "api" are unrelated: the first is who writes the
comments, the second is where the document text is read from, and any combination
of the two works.
Both modes read the same settings, and every command that takes part in writing a comment accepts all of them as flags:
marginal review <doc-url> --max-words 40 --critic-model claude-opus-5
marginal post-batch <doc-url> --max-words 40 --critic-model claude-opus-5 --from -
marginal review <doc-url> --web-search # API mode, may look things up
marginal context <doc-url> --web-search # agent mode, same instruction
How it works
Read through the API, write through the browser, verify through the API.
| Step | Mechanism | Why |
|---|---|---|
| Read the document | Docs API documents.get |
Exact text and tab structure; no scraping |
| Decide comments | model call | Returns {quote, body} pairs |
| Recheck the revision | Docs API documents.get |
Reread changed tabs before using their offsets |
| Resolve the quote to a span | docs_ui.resolve_quote |
Exact character offsets in our own text stream |
| Post the comment | Chrome DevTools Protocol | The only way to create an anchored comment without the Developer Preview |
| Verify the anchor | Drive API comments.list |
Compare quotedFileContent to the span we intended |
| Reply in a thread | Drive API replies.create |
Replies need no anchor, so no browser |
That last row matters more than it looks: only the first comment of a thread needs the browser. The whole conversation afterwards is plain API, so the slow fragile part is a small fraction of the system.
With source = "browser" the two API reads are done through the signed-in
browser's own session instead — a txt export for the text, a docx export for
the existing comments and their anchor ranges. Slower than one JSON call, and the
reason a user with no Google Cloud project can still review a document. The last
row has no browser equivalent, so replying still needs OAuth.
Why not just use the API to comment?
Three comment paths exist, and only one anchors:
- Drive API
comments.create— GA, but the Docs editor ignores the anchor field. The comment exists and is not attached to any text. Useless here. - Docs API
InsertCommentRequest— anchors properly viarange: {startIndex, endIndex}and posts every comment in a singlebatchUpdate. Gated behind the Workspace Developer Preview Program, which requires a Google Workspace account and rejects personal Gmail. The fastest possible version of this tool, for anyone who can get into it. - Driving the Docs UI — what this repo does. Works now, no gatekeeping, and it's the only route that could post as an arbitrary account.
Two modes, and looking things up
Who writes the comments is the one axis that changes what you need. API mode
(review) has this tool call a model itself and needs a model API key; agent
mode (context then post-batch) hands the job to a coding agent already
reading the document and needs no key, so it runs on a subscription.
The two must behave identically, which is what made web search a setting rather
than an accident. An agent-mode commenter could always search, because nothing
stopped it; the API-mode commenter could not, because it was handed exactly one
tool. web_search = true closes that: both modes are given the same paragraph
about when a search is worth making, and the mechanism differs underneath —
Anthropic's server-side web_search tool in API mode, the coding agent's own
search in agent mode.
Off by default. A comment is about the document's own reasoning, and a commenter
that can search will sometimes spend one relaying what the literature says. Turn
it on for a document that leans on external facts. One caveat the run prints for
itself rather than leaving you to infer: web_search_max_uses caps searches per
turn on the direct Anthropic route only, since OpenRouter's web plugin caps
results per search instead.
Google OAuth, and what it buys
Optional. Without it, source = "browser" reads and verifies through the Chrome
session you already signed in, and no Google Cloud project is involved at all.
With it, the document is read in one JSON call and the revision is rechecked
immediately before each post, so a document edited while the reviewer was thinking
is caught rather than commented on stale.
Marginal owns its OAuth tokens; it does not discover or modify another tool's credential store. In Google Cloud, enable the Google Docs API and Google Drive API, configure the OAuth consent screen, create a Desktop app OAuth client, and download its JSON. Then authenticate a named account:
marginal auth --client ~/Downloads/client_secret.json --account bot@example.com
marginal chrome # sign this Chrome profile into the same account
Do not leave an external consent app in Testing if the token must last: Google expires refresh tokens for those apps after seven days. Move it to Production when its consent configuration is ready.
The client and tokens are stored under ~/.config/marginal/ with private file
permissions. The first account becomes the default. With several accounts, select
one per command or set the default explicitly:
marginal --account work@example.com read <doc-url>
marginal auth list
marginal auth default work@example.com
Use auth --no-browser --account NAME on a remote machine; it prints the
authorization URL and asks for the complete redirected URL. An old
credentials = "/path/to/workspace-mcp.json" setting still works as an explicit,
warning-emitting migration path, but no workspace-MCP directory is searched.
This authentication flow uses the generally available Docs and Drive APIs. It does
not enroll an account in the Workspace Developer Preview, where
InsertCommentRequest remains gated,
and does not make that request available. Personal Gmail accounts can use
Marginal's browser posting path even though they cannot join that Preview
program.
Findings that shaped the design
From a probe against the live editor:
- Docs' find does not move the editing selection. Typing a quote into Cmd+F and pressing the comment shortcut anchors the comment wherever the caret already was. In the probe it landed tens of lines from the match. This is the failure mode to fear: a well-formed comment with sensible text attached to the wrong sentence, and no error anywhere. Find is not used for selection.
- A real selection plus
Cmd+Opt+Manchors correctly — verified byte-exact againstquotedFileContent, with a genuinekix.*anchor id. - Escape does not close the find bar, and closing it discards any selection.
- Key events need
windowsVirtualKeyCode. Without itevent.keyCodeis 0 and Docs' shortcut handler ignores the key.
Across a large sample of model-authored comment proposals, the quote a model picks to anchor a comment is almost always unique within its tab; a fraction of a percent are ambiguous or not present verbatim, and essentially none span a paragraph break. So quote widening, occurrence-index navigation and cross-paragraph selection are all edge-case machinery. This repo fails loudly on the rare miss instead of guessing.
Selection strategies
Selection is keyboard navigation from a deterministic origin (select-all, then collapse left). Two strategies, so accuracy and speed can be compared:
paragraph—Option+Downper paragraph, then character steps within it. Few events; assumes Option+Down lands on paragraph starts.chars— one character step at a time. Exact by construction if one arrow press equals one character of our stream, which bullets, tables and inline objects may break.
Neither is trusted. Every post is verified against the API.
API-backed runs also retain the document's revisionId. Immediately before each
browser post, Marginal checks it again. If the document changed while the
reviewer was thinking, it rereads the named tab and resolves the quote against the
new coordinate stream. A moved unique quote can still be placed; a deleted or newly
ambiguous quote is rejected. The browser action cannot be made atomic with the API
check, so the final selection confirmation and Drive read-back remain necessary.
Still to de-risk
Ordered by how much each would change the design.
- Where the time goes. If the model takes 10–20s to decide a comment and posting takes 1–3s, the executor is not the bottleneck. Measure both halves before optimising either.
- Selection accuracy per strategy, across deliberately varied spans: prose, bulleted item, heading, table cell, span containing a hyperlink, hyphens, smart quotes. Decides which strategy is the default and whether a screenshot-based fallback is needed at all.
- Structural traversal drift. The flatten walks table cells inline; the caret may not. If they diverge, every span after the divergence lands wrong.
- Burst integrity. Post ~20 comments as fast as the UI accepts them and verify all 20 anchors. Docs' save pipeline is async, so this is where dropped or unanchored comments would appear.
- Durable signed-in Chrome profile. Google sometimes refuses sign-in in automation-launched browsers. Until this works the tool borrows a live browser session.
- Headless viability — decides whether this can run without taking over the screen, and whether several documents can go in parallel.
- Reply round trip — confirm
replies.createshows up in the Docs thread, and that the model can read human replies back. - Comment quality. The real risk isn't mechanical: an agent with the document and a repo can produce twenty plausible useless comments as easily as three good ones.
Known issues
-
macOS only. Posting sends
Cmd+Opt+Mto the Docs editor and Chrome is looked for in/Applications. Nothing else is platform-specific, but nothing else has been tried. -
Identity has two sides. The named API account and the account signed into the dedicated Chrome profile must match. A separate account for the model keeps its comments distinct, but Marginal cannot force Chrome to use the same identity.
-
Notifications. On a shared document, every comment emails the collaborators, and deleting the comment does not recall the mail. Default to documents nobody else is watching.
-
provider = "auto"falls back quietly, and that is fine. A direct Anthropic call that fails — an unfunded key returns400 Your credit balance is too low— sends every call through OpenRouter instead. Not a degraded route: caching and effort both work there for Anthropic-backed models, and a run with caching on reads the overwhelming majority of its input from cache either way.Every run prints a
tokens:line. A zerocache readacross a multi-comment run is the only symptom a dead breakpoint has — it raises no error, it just costs full price.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file marginal-0.1.0.tar.gz.
File metadata
- Download URL: marginal-0.1.0.tar.gz
- Upload date:
- Size: 169.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
168a8954b3eb3a28960be8598f83c0ec9ffc2416e79eb41908049d4be3a997a6
|
|
| MD5 |
a33b8b1285cca4487124204c1e565f9f
|
|
| BLAKE2b-256 |
0c2921d9e82c685940317b7e872458a329f2d392fb7039064b87c4a62782e97d
|
Provenance
The following attestation bundles were made for marginal-0.1.0.tar.gz:
Publisher:
publish.yml on oscar-gilg/marginal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
marginal-0.1.0.tar.gz -
Subject digest:
168a8954b3eb3a28960be8598f83c0ec9ffc2416e79eb41908049d4be3a997a6 - Sigstore transparency entry: 2476185441
- Sigstore integration time:
-
Permalink:
oscar-gilg/marginal@93cada3fe3e2db2621edad4a701421113a6e63c4 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/oscar-gilg
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@93cada3fe3e2db2621edad4a701421113a6e63c4 -
Trigger Event:
push
-
Statement type:
File details
Details for the file marginal-0.1.0-py3-none-any.whl.
File metadata
- Download URL: marginal-0.1.0-py3-none-any.whl
- Upload date:
- Size: 119.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f72f2c90d5dbe85e19632bbfd9acaacfca7fd64468ebe55258da295040110a3b
|
|
| MD5 |
2a36ee9fbe78472a5eddab12b7a69c3d
|
|
| BLAKE2b-256 |
5022f4a6d7647fa408b86083f0fd0c09be96966b24d4714ddcca76388fb1fe77
|
Provenance
The following attestation bundles were made for marginal-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on oscar-gilg/marginal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
marginal-0.1.0-py3-none-any.whl -
Subject digest:
f72f2c90d5dbe85e19632bbfd9acaacfca7fd64468ebe55258da295040110a3b - Sigstore transparency entry: 2476185482
- Sigstore integration time:
-
Permalink:
oscar-gilg/marginal@93cada3fe3e2db2621edad4a701421113a6e63c4 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/oscar-gilg
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@93cada3fe3e2db2621edad4a701421113a6e63c4 -
Trigger Event:
push
-
Statement type: