
Integrations
Giving an AI Agent Access to Your Users' Google Drive Without Over-Scoping
Why the Google Picker plus drive.file should be your default instead of drive.readonly, how files.list, files.export and changes.list actually behave, and why a shared vector index leaks across users.
TL;DR
Most agents that read a user's Google Drive ask for a restricted scope they don't need. drive.readonly
gets you every file that user can see, indefinitely. drive.file gets you the ones they picked, and Google
classifies it as non-sensitive.
That classification is the whole argument. Restricted scopes drag you into OAuth verification with a security
assessment attached. Google's own scope guide says drive.file gets "a more streamlined verification process",
and that it "works with all Drive API REST Resources".
The usual objection is that drive.file can't search a user's whole Drive. Correct, it can't. If your product
genuinely needs corpus-wide retrieval, request the restricted scope and budget for the review. We think most
products don't need it. They need the six documents the user already had in mind.
The failure that actually costs you is downstream. Index several users' Drives into one vector store and you have built a cross-user leak, because a chunk carries no permissions of its own.
Start with the Picker. Carry the file id on every chunk. Filter every query by live access.
Overview
Take a support copilot at a mid-size SaaS company. Each support engineer connects their own Google account, and the agent answers questions like "what did we actually commit to in this customer's onboarding doc?" The engineer has access to a few hundred relevant files across My Drive and two shared drives. The agent needs to find the right three and read them.
There are two ways to build that, and they differ enormously in what you take on. One asks Google for a read-only view of everything the engineer can see and crawls it into an index you own. The other asks the engineer to point at the folders and files that matter, and reads only those. Both work. Only one of them makes you the custodian of a copy of someone's entire document corpus.
We've written separately about the per-user token pattern for Gmail and about
why a Slack user token and a bot token are different products. Drive
is the case where the scope choice has the sharpest consequences, because Drive is where the sensitive
documents live and because Google prices the two options very differently. The scope tiers themselves, and what
the CASA assessment involves, are covered in
our post on Google OAuth verification. This one is about the Drive API:
which scope to pick, how the Picker flow really goes, what files.list and files.export do and don't do, and
what any of it means for an index you build on top.
The core rule: an agent must never surface a file its user cannot open. Everything about scope choice, index design and query filtering below is a consequence of that one sentence, and it is the sentence teams discover they violated after shipping.
The Scope Decision
Google's scope guide sorts Drive scopes into three buckets. The split is not cosmetic. It determines your verification path.
| Scope | What it grants | Tier |
|---|---|---|
drive.file | "Create new Drive files, or modify existing files, that you open with an app or that the user shares with an app while using the Google Picker API or the app's file picker." | Non-sensitive |
drive.readonly | "View and download all your Drive files." | Restricted |
drive.metadata.readonly | "View metadata for files in your Drive." | Restricted |
drive | "View and manage all your Drive files." | Restricted |
Table 1 — Drive scope classifications, quoted from Google's scope guide.
Note where drive.metadata.readonly lands. Teams reach for it thinking metadata is the safe middle ground, a
way to enumerate a Drive without reading contents. Google disagrees: it is restricted, same tier as full
read-only, same verification burden. There is no cheap way to enumerate someone's whole Drive.
drive.file is the only one in the table that isn't restricted, and it is not a crippled scope. Google's guide
states it "works with all Drive API REST Resources", so files.list, files.export, changes.list and
permissions calls all behave normally. The difference is the corpus they operate over. Under drive.file your
app sees the set of files the user granted, and nothing else. A fullText contains query runs against that set,
not against their Drive.
So the real question isn't which scope is more powerful. It's whether your product's value depends on finding files the user couldn't name. For a support copilot, it doesn't. The engineer knows which customer folder they mean. For a company-wide "ask anything about our documents" product, it does, and then you should request the restricted scope honestly and pay for the review rather than trying to approximate it.
Picker + drive.file | --> non-sensitive, streamlined verification, per-file grants +-- no --> is corpus-wide search the product? |-- no --> Picker + drive.file |-- yes --> drive.readonly (restricted): verification + security assessment -->
Figure 1 — The scope decision reduces to one question about your product, not about your architecture.
The Picker in Practice
The Google Picker is the mechanism that makes drive.file usable. It renders Google's own file-open dialog
inside your page, and whatever the user selects becomes accessible to your OAuth client. Google's overview
describes it as "a polished, 'File Open' dialog for information stored in Google Drive", and the scope guide
points out the practical benefit: the Picker "provides a similar interface to the Drive UI", so users recognize
what they're doing.
Four things it needs, and the third trips people up. An OAuth access token via setOAuthToken, an API key via
setDeveloperKey, your Cloud project number via setAppId, and a view.
gapi.load('picker', () => {
const picker = new google.picker.PickerBuilder()
.addView(google.picker.ViewId.DOCS)
.setOAuthToken(accessToken) // minted for this end user
.setDeveloperKey(API_KEY)
.setAppId(CLOUD_PROJECT_NUMBER) // number, not project id
.setCallback(onPicked)
.build();
picker.setVisible(true);
});
function onPicked(data) {
if (data[google.picker.Response.ACTION] !== google.picker.Action.PICKED) return;
const ids = data[google.picker.Response.DOCUMENTS].map(d => d.id);
postFileIdsToYourBackend(ids); // these ids are now readable under drive.file
}One constraint worth knowing before you design the UX. Google's Picker overview says web apps are "flexible" on
scopes, while desktop and mobile apps are "strict; only drive.file is permitted and cannot be combined with
other scopes." If your agent ships as a desktop client, the decision has already been made for you.
Folder selection is the part that makes this viable for a retrieval product, and it's also the part Google's
documentation is least explicit about. The Picker can be configured to offer folders as a selectable item type.
What the scope guide does not state plainly is how far a folder grant reaches down the tree, so test it against
your own client before you design a UX around it. If it covers descendants for you, the Picker stops being a
one-file-at-a-time annoyance and becomes a consent gesture with real coverage. If it doesn't, you're asking
users to pick documents individually, and drive.file gets a lot less attractive.
Drive API (user token) --> granted files only Gateway --files.export--> text --> answer with file ids as citations The app never holds a scope broader than what the user picked. -->
Figure 2 — The Picker converts a consent gesture into a bounded corpus, and the file ids it returns are the same ids the retrieval path carries all the way to the citation.
Searching and Reading
files.list takes a q parameter built from query_term operator values. The operators you'll actually use
are contains, =, !=, the comparisons, and in:
GET /drive/v3/files
?q=fullText contains 'renewal' and mimeType != 'application/vnd.google-apps.folder' and trashed = false
&fields=nextPageToken,incompleteSearch,files(id,name,mimeType,modifiedTime,owners,webViewLink)
&pageSize=100Two response fields matter more than they look. nextPageToken is obvious. incompleteSearch is not: Google
defines it as "Whether the search process was incomplete. If true, then some search results might be missing,
since all documents were not searched." An agent that ignores that flag will confidently tell a user no such
document exists. Ours treats a true incompleteSearch as a hard signal to narrow the query and retry, not as an
empty result. pageSize maxes out at 1000 and requests above it are coerced down.
Always send an explicit fields projection. The default response is fat, you pay list quota either way, and the
model does not need thirty metadata fields to decide which of five documents to open.
Reading splits by file type. Blobs (PDFs, images, uploaded .docx) come back through files.get with
alt=media. Native Google Docs, Sheets and Slides have no bytes to download and must go through files.export
with a target MIME type:
NATIVE = {
"application/vnd.google-apps.document": "text/plain",
"application/vnd.google-apps.spreadsheet": "text/csv",
"application/vnd.google-apps.presentation": "text/plain",
}
def fetch_text(drive, file_id, mime_type):
if mime_type in NATIVE:
return drive.files().export(
fileId=file_id, mimeType=NATIVE[mime_type]).execute()
return drive.files().get_media(fileId=file_id).execute()Two documented limits to design around. "Exported content is limited to 10 MB", and "Partial downloads are not supported while exporting Google Workspace documents." So a large Sheet cannot be ranged over in slices. For spreadsheets past that ceiling the Sheets API is the right tool, not Drive. And Drive's export-format table marks CSV as "first-sheet only", which is a fine default and a bad surprise if you don't mention it in your UI.
Shared Drives
Shared drives are not folders. They are a separate ownership model, and Drive's own overview is blunt about the consequence: "Any user with access to a shared drive has access to all files it contains."
By default your queries won't see them at all. includeItemsFromAllDrives is documented as: "Whether both My
Drive and shared drive items should be included in results. If not present or set to false, then shared drive
items are not returned." You need both flags:
GET /drive/v3/files
?q=fullText contains 'renewal'
&supportsAllDrives=true
&includeItemsFromAllDrives=true
&corpora=allDrivessupportsAllDrives=true tells Drive your app is built to handle shared-drive semantics. corpora picks the
collection: user, domain, drive (with a driveId), or allDrives. Google recommends preferring user or
drive over allDrives for performance, and warns that searching multiple corpora at once "might return
incomplete results" if the combined corpus is too large, which is exactly when incompleteSearch fires.
The support-copilot case usually wants per-drive queries, because the shared drives are known: one for customer
contracts, one for runbooks. Fan out across them with corpora=drive&driveId=… and merge. You get better
recall than a single allDrives sweep and you know which drive each hit came from, which you'll want in the
citation anyway.
Permissions and Your Index
Here's the part that gets shipped wrong. Drive's permission model is per-file, with roles from reader up
through owner, granted to users, groups, domains or anyone, and largely inherited from parent folders and
shared drives. Access changes constantly: someone leaves a group, a contract folder gets locked down, an
engineer moves teams.
A vector store knows none of this. A chunk of text is a chunk of text. It has an embedding and whatever metadata you attached, and if you attached nothing, it is readable by any query that reaches that index. Put three users' Drives in one collection with no per-chunk provenance and you have built a machine that will, eventually, answer one user's question with another user's contract.
The fix is not subtle and it is not optional. Every chunk carries the Drive fileId it came from, plus the
external user id whose grant produced it. Every query filters on that user's currently-granted file ids. And the
grant list is refreshed from Drive rather than trusted from your database, because your database records what
was true when you crawled. Google's own retrieval product works this way: Vertex AI Search attaches an
acl_info field to documents and "uses your identity provider to identify the end user performing a search and
determine if they have access". If Google needs ACLs on every document, so do you.
An index is a permission cache, and you did not design its invalidation. Whatever your retrieval layer returns is an assertion about who may read what, made at crawl time, answering a question asked now. Either re-check access at query time or accept that your agent's answers lag your permission model by however long your sync interval is.
There's a cheaper version that we think is usually right: don't build the shared index at all. Under
drive.file the granted set per user is small, often tens of documents rather than thousands. Search Drive
live with files.list, export the two or three hits, and let the model read them in context. You lose some
latency and you gain the property that Drive's own permission check runs on every single request, with no cache
to invalidate. Retrieval-augmented generation has become a reflex; for a bounded per-user corpus it's often
just an expensive way to reimplement search that Google already runs for you.
Figure 3 — Without a file id on the chunk there is no access check to run, which is why provenance has to be written at ingest rather than added later.
Incremental Sync
If you do keep an index, re-crawling is the wrong way to maintain it. It burns quota, it's slow, and it still misses revocations between runs. Drive's changes feed exists for this.
Call changes.getStartPageToken once to get a marker for the current state, store it, then poll changes.list
with it. The response returns nextPageToken while more pages exist and newStartPageToken on the last page,
which you persist for the next interval. Changes come back in chronological order, oldest first.
token = store.get(user_id) or drive.changes().getStartPageToken().execute()["startPageToken"]
while True:
r = drive.changes().list(
pageToken=token, includeRemoved=True,
supportsAllDrives=True, includeItemsFromAllDrives=True,
pageSize=1000, fields="changes(fileId,removed,file(id,name,mimeType,modifiedTime)),"
"nextPageToken,newStartPageToken").execute()
for c in r.get("changes", []):
reindex(user_id, c["file"]) if not c.get("removed") else purge(user_id, c["fileId"])
if "nextPageToken" not in r:
store.put(user_id, r["newStartPageToken"]); break
token = r["nextPageToken"]includeRemoved=True is the flag that keeps you honest. It surfaces items that dropped off the list through
deletion or through the user losing access, and the second of those is the one that matters. A file the user can no longer open produces a
change event, and your handler's job is to delete every chunk carrying that fileId. Skip this and your index
quietly becomes a set of documents your user used to be allowed to read.
The page token "doesn't expire", per the API reference, so a sync that's been down for a week resumes from where it stopped rather than starting over. Store it per user, alongside the granted file ids, and treat losing it as a full re-crawl.
Quotas and Backoff
Drive's quota model is denominated in units, not calls, and the per-method costs are uneven enough to change how
you write the code. Google's usage limits
page gives 1,000,000 quota units per minute per project and 325,000 per minute per user per project, against
per-method costs of 5 units for a read like files.get, 100 for a list, 200 for a download, and 50 for an edit.
That arithmetic is worth doing. At 100 units a call, one user can issue roughly 3,250 files.list requests a
minute before hitting the per-user ceiling, and a downloading crawler burns through the same budget four times
faster than a metadata scan. A per-user index build is nowhere near the limit. A hundred concurrent index builds
under one Cloud project can be. There's also a 1 TB per day egress cap per project, which sounds generous until
you re-crawl everyone's Drive nightly.
When you do hit a wall, Drive returns 403 userRateLimitExceeded for the per-user limit, 403 rateLimitExceeded
for the project limit, or 429 for too many requests in a short window. Google's guidance for all of them is the
same: "Use exponential backoff to retry the request." Add jitter, cap the retries, and make sure the agent's
tool layer distinguishes rate limited, try again from no such file. Those look identical to a model and mean
opposite things.
Rate limits are a correctness problem, not just a latency one. A retrieval call that fails on quota and returns an empty list has told the agent the document doesn't exist. Your tool contract needs three outcomes, not two.
Conclusion
Our position, stated plainly. Default to the Google Picker and drive.file. Let users grant folders rather than
individual files so the scope is still useful. Treat drive.readonly and drive.metadata.readonly as things
you request when corpus-wide search is the product, not as a convenience, and go into that with the verification
timeline planned. Send explicit fields projections, handle incompleteSearch, set both shared-drive flags,
and use the changes feed instead of re-crawling.
And be honest about the index. If you're keeping one, every chunk carries a file id and every query is filtered
by that user's live access, with removals driven off changes.list. If you can't commit to that, don't build
the index. For a drive.file-bounded corpus, searching Drive live on each request is a legitimate design and
it inherits Google's permission check for free.
Agentic Fabriq sits at this join for teams who don't want to own the token plumbing. Drive is the
google_drive provider: you name your own end users by an external user id, they complete Google's consent at
Google, and credentials land in a vault rather than on your servers. Your agent mints a short-lived user-scoped
token per unit of work, 900 seconds, and calls tools over /mcp/external with it.
POST /api/v1/apps/{app_id}/external-users/{uid}/token
-> {"access_token": "...", "expires_in": 900, "mcp_url": "https://.../mcp/external"}Which providers a deployment can actually connect is deployment state rather than a property of your code, so the capabilities endpoint answers that at page load instead of a hardcoded list. The effective tool list for any call is the intersection of the user's connection scopes and the agent's grants, which means an agent cannot reach a Drive surface its user never consented to.
The test we'd apply before shipping: revoke one user's access to one document in Drive, then ask the agent about it. If it answers, you don't have a Drive integration. You have a copy of their Drive with a search box on it.
Sources
- Choose Google Drive API scopes — the non-sensitive / sensitive / restricted classification and the
drive.filebenefits list. - Google Picker API overview — what the Picker is, and the web-versus-desktop scope constraint.
- Integrate the Google Picker into web apps —
PickerBuilder,setAppId,setOAuthToken,setDeveloperKey, callback shape. - Search for files and folders —
qgrammar, operators, and thecorporaguidance. - Method: files.list —
incompleteSearch,pageSizeceiling, parameter reference. - Download and export files —
files.export, the 10 MB export cap, no partial downloads on export. - Implement shared drive support —
supportsAllDrives,includeItemsFromAllDrives,corpora,driveId. - Google Drive API overview — My Drive versus shared drive ownership.
- Track changes for users and shared drives —
getStartPageToken,newStartPageToken, chronological ordering. - Method: changes.list — page tokens don't expire,
includeRemoved,pageSizeceiling. - Export MIME types for Google Workspace documents — the "first-sheet only" note on CSV and TSV exports of Sheets.
- Roles and permissions — the role ladder and where inheritance comes from.
- Usage limits — quota units per minute per project and per user, per-method costs, daily egress cap.
- Resolve errors —
userRateLimitExceeded,rateLimitExceeded, 429, exponential backoff. - Set up data source access control (Vertex AI Search) —
acl_infoon documents and identity-filtered retrieval.