Classic painting used as the article cover
← Back to blog

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.

•Sep 24, 2026•Updated Sep 25, 2026•13 min
OAuthConnectorsEngineering

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.

ScopeWhat it grantsTier
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.

yes

no

no

yes

Agent must read user's Drive

Can the user
name the files
or folder?

Google Picker
+ drive.file

Is corpus-wide
search the product?

drive.readonly
(restricted)

non-sensitive
streamlined verification
per-file grants

OAuth verification
+ security assessment
whole-corpus custody

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.

javascript
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 APIGatewayGoogle PickerYour web appEnd userDrive APIGatewayGoogle PickerYour web appEnd userconnect DrivePickerBuilder(appId, apiKey, oauthToken)selects folder / filesdocs[].idstore granted file ids for this user"what did we promise Acme?"files.list q=... (user's token)granted files onlyfiles.export fileId, mimeTypetext/plainanswer + file ids as citations

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:

http
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=100

Two 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:

python
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:

http
GET /drive/v3/files
  ?q=fullText contains 'renewal'
  &supportsAllDrives=true
  &includeItemsFromAllDrives=true
  &corpora=allDrives

supportsAllDrives=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.

fileId ∈ live grants

no filter

403 / 404

200

user query

per-user filter

vector store
chunk: text + fileId + userId

cross-user leak

re-check access
files.get(fileId)

drop chunk
+ schedule delete

answer with citations

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.

python
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.

http
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