Announcing CIMD support for MCP Client registration
Learn more

Develop a Sales Enablement Agent for Google Drive with LangChain

TL;DR

  • A retrieval agent that reads Drive through a batch-synced vector index has two structural failure modes: the index staleness gap (old and new versions of a file co-exist during re-index, and similarity search returns whichever scores higher, not the newer one; deleted files leave orphan vectors that keep getting served), and permission flattening (Drive ACLs do not survive vectorization, so a shared index can surface a file the rep was never granted).
  • Both failures point the same way: Drive must be the live source of truth for freshness and authorization, reached through a per-user connected account. The index, if you keep one, is a recall aid, never the authority.
  • "Latest" is not modifiedTime desc on its own. Editing an old draft bumps its modifiedTime above the approved file, and Drive returns trashed files by default. Canonical status has to be encoded (approved folder, appProperties, or a Drive label) and enforced on every query.
  • Per-user OAuth and Google Workspace domain-wide delegation are not interchangeable here. A per-user connected account exposes exactly what the rep can see; a domain-wide-delegation service account can read every user's Drive, so one leaked key is a whole-domain blast radius. What the rep can't see, the agent can't see.
  • Scalekit's Google Drive connector runs the per-user OAuth flow, stores each rep's token in the token vault, and refreshes it, so the freshness and scoping work you do in LangChain sits on per-rep credentials you did not have to build or hold.

A rep types "get me the latest enterprise pricing sheet" into your assistant. The agent returns a file named Enterprise Pricing.pdf, the rep pastes the number into a customer proposal, and the number is from last quarter. Nobody edited a wrong field; the agent retrieved a file that looked current and was not. For a sales enablement agent on Google Drive, "find a file" is the easy part. Returning the one current, approved file that this specific rep is allowed to see is the part that breaks in production, and it breaks for reasons that live in your retrieval and auth architecture, not your prompt.

What "the latest pricing sheet" actually demands

The request is one sentence. The correct answer satisfies three independent constraints, and a naive retrieval layer usually gets at most one of them right.

The rep means
What breaks if you skip it
Where it has to be enforced
Current
Last quarter's pricing quoted as live
Canonical-file query plus a metadata check before quoting
Approved
A personal draft or an in-review copy quoted as final
An approved-folder or appProperties predicate on every search
Authorized for this rep
Another team's or another customer's collateral surfaces
The rep's own per-user connected account, so Drive ACL-filters results

The rest of this build is these three gates, in order, wired into a LangChain agent. Nothing else earns a place in the pipeline.

Why the index-backed answer is wrong

The default architecture for "chat with your Drive" is a sync job that embeds Drive files into a vector store, then answers from similarity search. It is the wrong authority for a sales enablement agent, for two distinct reasons that are worth separating because they have different fixes.

Failure
Root cause
Who it burns
Stale version returned with high confidence
The index staleness gap: during any re-index window old and new versions of a file co-exist, and the retriever returns whichever has higher similarity, not the newer one; deleted files leave orphan vectors that keep resolving
The rep quotes dead pricing to a live customer
A file the rep should never see is returned
Vectorization drops Drive's per-file ACLs; a shared index or a shared service credential flattens per-user access into one surface
The rep sees another account's or another team's collateral

The first failure is a freshness problem. The second is an authorization problem, and it is the one that turns a demo into an incident. Both dissolve when Drive itself is the authority: a live query against Drive returns Drive's current state, and a query issued through the rep's own grant returns only what the rep is allowed to see. Keep a vector index if you want semantic recall ("the deck that covers SOC 2 for fintech"), but treat it as a way to surface candidates, then confirm each candidate against Drive before the agent quotes it. The index proposes; Drive decides.

The pipeline

This is a deterministic pipeline, not an open-ended reasoning loop. Every rep request flows through the same four gates, and each gate maps to a concrete Scalekit Google Drive tool or parameter.

Gate
Purpose
Drive mechanism
Recall (optional)
Surface candidate files semantically
Your index, or googledrive_search_content full-text over file bodies
Authorization
Return only files this rep can see
The rep's per-user connected account; googledrive_search_files results are ACL-filtered by the rep's own OAuth grant
Canonical
Current and approved, never trashed
googledrive_search_files query: '<APPROVED_FOLDER_ID>' in parents and trashed = false, with order_by = "modifiedTime desc"
Verification
Confirm before quoting
googledrive_get_file_metadata with fields including modifiedTime, trashed, appProperties

The authorization gate is not a step you write; it is a property of the credential. Because the connected account is the rep's OAuth grant, Drive applies the rep's sharing rules to every result. The canonical and verification gates are the code you do write, and they are where "latest" stops being a guess.

Connect the rep's Google Drive

The Scalekit Google Drive connector uses OAuth 2.0. Scalekit is the OAuth client: it runs the redirect, obtains the rep's access token, stores it, and refreshes it. Your agent passes a connection_name and a per-rep identifier; it never handles a token.

import os import scalekit.client # One client per process. Credentials come from # app.scalekit.com -> Developers -> API Credentials. scalekit_client = scalekit.client.ScalekitClient( client_id=os.environ["SCALEKIT_CLIENT_ID"], client_secret=os.environ["SCALEKIT_CLIENT_SECRET"], env_url=os.environ["SCALEKIT_ENV_URL"], ) actions = scalekit_client.actions # Connection names are workspace-specific. Never hard-code them; they differ # across your dev and production environments. Read them from the environment # and copy the exact value from Agent Auth > Connections in the dashboard. DRIVE_CONNECTION = os.environ["GOOGLE_DRIVE_CONNECTION_NAME"] # e.g. "google_drive" def ensure_drive_connected(rep_id: str) -> bool: """Return True when this rep's Google Drive connected account is ACTIVE. Otherwise print the consent link and return False. In production you would surface this link in your UI rather than the console.""" account = actions.get_or_create_connected_account( connection_name=DRIVE_CONNECTION, identifier=rep_id, # your stable per-rep identifier ) if account.connected_account.status == "ACTIVE": return True link = actions.get_authorization_link( connection_name=DRIVE_CONNECTION, identifier=rep_id, ) print(f"Ask {rep_id} to authorize Google Drive: {link.link}") return False

Each rep who connects gets their own consent flow and their own grant. Forty reps means forty connected accounts, each scoped to what that rep can see in Drive, with no shared credential in the middle.

Scope tools to the rep, not the catalog

Before the agent runs, retrieve the tools this rep's connected account authorizes. This is not tool discovery over an unknown surface; it is the fixed, per-rep set that the rep's grant permits. For Google Drive it is exactly three tools.

def inspect_scoped_surface(rep_id: str) -> list: """The tools authorized for this rep's connected account. This is the surface you would pass to an LLM directly.""" scoped, _ = actions.tools.list_scoped_tools( identifier=rep_id, filter={"connection_names": [DRIVE_CONNECTION]}, ) return scoped.tools # -> googledrive_search_files, googledrive_search_content, # googledrive_get_file_metadata
Tool
What it does
Params that matter here
googledrive_search_files
Find files and folders by query filters
query (the Drive q string), order_by, supports_all_drives
googledrive_search_content
Full-text search inside file bodies
search_term, mime_type
googledrive_get_file_metadata
Return one file's metadata
file_id, fields

The scoping that matters for a Drive agent is authorization scoping, not catalog reduction. Scope here is a function of the rep's identity, not of connector configuration: what the rep can't do, the agent can't do. The contrast is concrete inside Scalekit's own catalog. There is a Google Drive connector (per-user OAuth 2.0) and a separate Google Workspace connector that uses a service account with domain-wide delegation. The domain-wide-delegation path authenticates once as an application that can impersonate any user in the workspace; Google's own guidance is to prefer per-user consent precisely because a single delegated key can read every user's Drive. For a multi-rep, multi-account sales agent, per-user connected accounts keep each rep's blast radius to their own grant.

If your sales enablement agent also reaches CRM and messaging (Salesforce, HubSpot, Gong, Attio, Slack are all connectors in the same catalog), the scoped surface also keeps each run down to the tools that user authorized, rather than a combined catalog the model has to select from. With three Drive tools that effect is small; across five connectors it is the difference between a clean decision space and a bloated one. This per-rep scoping approach connects directly to the broader challenge of tool calling auth in multi-tenant environments, where shared credentials compound across every connected system.

Enforce current and approved, not most recently touched

Drive gives you no native "approved" flag, and two of its defaults work against you. files.list returns trashed files unless you exclude them, and modifiedTime is the last edit by anyone, so a colleague who opens a superseded draft and saves it pushes that draft above the real, approved file. So encode canonical status somewhere durable (a dedicated approved-collateral folder, an appProperties key such as status=approved, or a Drive label) and force every search onto that contract.

Wrap googledrive_search_files so the contract cannot be bypassed by the model. The wrapper keeps the tool's name and schema, so the LLM sees the same tool; it only rewrites the arguments on the way through.

from langchain_core.tools import StructuredTool APPROVED_FOLDER_ID = os.environ["APPROVED_COLLATERAL_FOLDER_ID"] def _with_canonical_contract( search_tool: StructuredTool, approved_folder_id: str ) -> StructuredTool: """Force every googledrive_search_files call onto the canonical contract: approved folder only, no trashed files, newest-approved first. The model cannot opt out; the wrapper rewrites the query before it reaches Drive.""" def guarded(**kwargs): q = (kwargs.get("query") or "").strip() # 1. Never return trashed or deleted revisions. # Drive includes them by default, so exclude them explicitly. if "trashed" not in q: q = f"({q}) and trashed = false" if q else "trashed = false" # 2. Restrict to the approved-collateral folder: the canonical source. # Swap this predicate for appProperties (status='approved') if you # mark approval on the file instead of by folder placement. if approved_folder_id not in q: q = f"({q}) and '{approved_folder_id}' in parents" kwargs["query"] = q # 3. Newest approved revision first. modifiedTime is optimized for # time-ordered queries at scale; createdTime is not. kwargs["order_by"] = "modifiedTime desc" return search_tool.invoke(kwargs) return StructuredTool.from_function( func=guarded, name=search_tool.name, # keep the original tool name description=search_tool.description, # keep the LLM-ready description args_schema=search_tool.args_schema, # keep the original schema )

The canonical query narrows the field to approved, non-trashed files, newest first. The last gate confirms the top candidate is genuinely current before the agent quotes it. Ask only for the fields that decide "current and approved," which keeps the payload small and the check explicit.

def verify_is_current(rep_id: str, file_id: str) -> dict: """Confirm a candidate is the current, approved, non-trashed revision before the agent cites it. Runs as the rep, so it also re-checks that the rep still has access.""" result = actions.execute_tool( tool_name="googledrive_get_file_metadata", identifier=rep_id, connection_name=DRIVE_CONNECTION, tool_input={ "file_id": file_id, # Only the fields that decide current + approved. "fields": "id,name,modifiedTime,trashed,appProperties,owners", }, ) return result.data

The agent

Assemble the per-rep, guarded tools and hand them to LangChain's create_agent. The system prompt states the contract; the guarded tool enforces it. Build the tool list per rep so the agent always runs against the right connected account.

from langchain_openai import ChatOpenAI from langchain.agents import create_agent def build_drive_tools(rep_id: str) -> list: """Return only the tools this rep's connected account authorizes, with googledrive_search_files wrapped in the canonical-file contract. Every result is already ACL-filtered to the rep by their own OAuth grant.""" tools = actions.langchain.get_tools( identifier=rep_id, connection_names=[DRIVE_CONNECTION], page_size=100, # page through so no tool is silently dropped ) guarded = [] for t in tools: if t.name == "googledrive_search_files": guarded.append(_with_canonical_contract(t, APPROVED_FOLDER_ID)) else: guarded.append(t) return guarded SYSTEM_PROMPT = ( "You are a sales enablement assistant. When a rep asks for a document, " "resolve to the single current, approved file. Use googledrive_search_files " "to find candidates, then call googledrive_get_file_metadata on the top hit " "and confirm its modifiedTime before you cite it. Never quote a file you " "have not verified. If no approved file matches, say so; do not fall back " "to an unapproved copy." ) def answer_for_rep(rep_id: str, question: str) -> str: # Gate 0: the rep must have an ACTIVE Google Drive connected account. if not ensure_drive_connected(rep_id): return "Google Drive is not connected for this rep yet." # temperature=0 keeps the retrieval behavior deterministic. llm = ChatOpenAI(model="gpt-4o", temperature=0) agent = create_agent( model=llm, tools=build_drive_tools(rep_id), # per-rep, guarded tools system_prompt=SYSTEM_PROMPT, ) result = agent.invoke( {"messages": [{"role": "user", "content": question}]} ) # create_agent returns the run state; the answer is the last message. return result["messages"][-1].content # Two reps, two Drive identities, zero shared credentials. Each call runs # against that rep's own connected account and returns only what that rep # is allowed to see, current and approved. print(answer_for_rep("rep_amelia", "Get me the latest enterprise pricing sheet")) print(answer_for_rep("rep_diego", "Find the current SOC 2 one-pager for fintech"))

amelia and diego hit the same code and the same connection, but their results come through different grants. If a file lives outside diego's sharing, diego's connected account never returns it, and no prompt can talk the agent past that boundary.

This pattern mirrors the architecture described in LangChain tool calling: how it works, where it stops, and how Scalekit completes it — the framework gives you the loop and the schema binding, but credential lifecycle and per-user scoping require the layer on top.

The credential problem that exists whichever path you pick

Live per-user tool calls fix freshness and authorization, but they hand you a credential-lifecycle problem that neither a vector-sync job nor hand-rolled OAuth solves for you. Forty reps is forty Drive grants to store encrypted, refresh, and revoke. Google access tokens are short-lived (on the order of an hour), so a real agent refreshes constantly; miss a refresh and searches fail silently mid-conversation rather than erroring. And offboarding is where this bites: a rep leaves, their SSO is disabled, but a Drive grant issued months ago is still valid until something explicitly revokes it. The agent does not decide to keep using it; it just does.

The alternative that appears to sidestep per-rep tokens — a domain-wide-delegation service account — trades forty small grants for one key that can read every rep's Drive. That is a larger blast radius, not a smaller operational surface. Handling token refresh for AI agents at scale requires proactive rotation and a vault that lives outside the agent runtime — exactly what Scalekit's Google Drive connector provides: it runs each rep's OAuth flow, holds each token in the token vault outside your agent runtime, refreshes proactively, and gives you a revocation point per connected account. The credentials never touch the agent; the agent calls a tool and gets a result.

The offboarding gap is a real operational risk. As covered in when an employee leaves, who revokes their AI agent's access, a disabled SSO account does not automatically invalidate OAuth grants issued to autonomous agents — that revocation has to be explicit and tied to a real identity lifecycle event.

For a deeper look at how credential ownership across agent tool-calling patterns shapes your risk surface, the ownership question — does the agent hold the token, or does an intermediary — determines whether a single key failure cascades or stays contained.

Next steps to start building

  • Create the Google Drive connection in Agent Auth > Connections, supply your Google OAuth client credentials, and read the connection name from an environment variable in your code.
  • Pick your canonical marker: a dedicated approved-collateral folder is the simplest; appProperties (status=approved) or a Drive label travels with the file if collateral moves between folders.
  • Drop in the _with_canonical_contract wrapper and the verify_is_current check, then point build_drive_tools at your connection.
  • Add the connectors your reps actually need next to Drive (Salesforce, HubSpot, Gong, Attio, Slack are in the same catalog) and scope each run to the tools that rep authorized.
  • Browse the Google Drive connector reference: https://docs.scalekit.com/agentkit/connectors/googledrive/

FAQ

Why not use a domain-wide-delegation service account for the whole sales team?

It works, and it is one connection to manage, but the access model is wrong for a multi-rep agent. A domain-wide-delegation key can impersonate any user in the workspace, so it can read every rep's and every account's Drive; Google's own guidance is to prefer per-user consent for exactly this reason. Per-user connected accounts keep each rep's reach to their own grant, which is also what makes the authorization gate free: the agent inherits the rep's sharing rules instead of an admin-level view.

Isn't order_by = "modifiedTime desc" enough to get the latest file?

No, and this is the most common way these agents ship a wrong answer. modifiedTime is the last edit by anyone, so someone opening and saving a superseded draft ranks it above the approved file. Recency ordering only means "newest edit," not "current approved version." That is why the contract pairs the ordering with an approved-folder or appProperties predicate and a googledrive_get_file_metadata check before the agent quotes anything.

Do I still need a vector store?

Only for semantic recall, and never as the authority. If reps ask by meaning ("the deck that handles security objections") rather than by name, an index earns its place as a candidate finder. Resolve those candidates to live file_ids and run them through the canonical and verification gates so the answer reflects Drive's current, ACL-filtered state, not the index's last sync. The index proposes; Drive decides.

Will the agent see files in shared drives, not just My Drive?

googledrive_search_files and googledrive_get_file_metadata both accept supports_all_drives. Set it when your approved collateral lives in a shared drive, and keep the same approved-folder or appProperties predicate so the canonical contract still holds across drives.

How do I stop the model from quoting an in-review draft it found?

Two layers. The guarded search restricts results to approved, non-trashed files, so a draft outside the approved folder never enters the candidate set. The system prompt then requires a googledrive_get_file_metadata verification before the agent cites anything, and instructs it to say no approved file matches rather than fall back to an unapproved copy.

A rep's access token expires mid-conversation. What happens?

Nothing you have to handle in the agent. Scalekit refreshes the rep's Drive token behind the connected account, so the next googledrive_search_files or googledrive_get_file_metadata call runs on a valid credential. Your code passes a connection_name and identifier; it never sees or refreshes the token itself. This is the same secure token management model described in secure token management for AI agents at scale — tokens live in a vault, the agent never holds them, and refresh is invisible to your code.

No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.