Skip to main content

Google Workspace

Gmail and Calendar: search and read mail, draft, reply and send; read calendars, find a free slot, and manage events.

npm i @forge/tools-google

Tools

Gmail

ToolEffectApprovalScope
gmail_search_messagesreadnevergmail.readonly
gmail_get_messagereadnevergmail.readonly
gmail_get_threadreadnevergmail.readonly
gmail_list_labelsreadnevergmail.readonly
gmail_create_draftinternal-writenevergmail.compose
gmail_send_messageexternal-writealwaysgmail.send
gmail_reply_messageexternal-writealwaysgmail.send, gmail.readonly
gmail_modify_labelsexternal-writealwaysgmail.modify

There is deliberately no delete or trash tool. Deleting somebody's mail is not a capability this package grants; gmail_modify_labels archives and marks read, which is the triage an agent is actually asked to do.

Calendar

ToolEffectApprovalScope
calendar_list_eventsreadnevercalendar.readonly
calendar_get_eventreadnevercalendar.readonly
calendar_find_free_timereadnevercalendar.readonly
calendar_create_eventexternal-writealwayscalendar.events
calendar_update_eventexternal-writealwayscalendar.events
calendar_delete_eventdestructivealwayscalendar.events

Drive

ToolEffectApprovalScope
drive_search_filesreadneverdrive.readonly
drive_get_filereadneverdrive.readonly
drive_create_folderexternal-writealwaysdrive.file
drive_upload_fileexternal-writealwaysdrive.file
drive_move_fileexternal-writealwaysdrive.file
drive_share_fileexternal-writealwaysdrive.file

No trash or delete tool.

Docs

ToolEffectApprovalScope
docs_get_documentreadneverdocuments.readonly
docs_create_documentexternal-writealwaysdocuments
docs_append_textexternal-writealwaysdocuments

docs_append_text is append-only. There is no arbitrary range replacement, so nothing already written can be lost by a tool that meant to add something.

Sheets

ToolEffectApprovalScope
sheets_list_sheetsreadneverspreadsheets.readonly
sheets_get_valuesreadneverspreadsheets.readonly
sheets_append_rowsexternal-writealwaysspreadsheets
sheets_add_sheetexternal-writealwaysspreadsheets
sheets_update_valuesdestructivealwaysspreadsheets

Wire it up

import { createAgent } from "@forge/agentkit/providers";
import { bearer, refreshable, withRefreshingCredentials } from "@forge/agentkit/tools";
import type { CredentialRefresher, CredentialResolver } from "@forge/agentkit/tools";
import { createGoogleToolkit } from "@forge/tools-google";

// Your own: reads the stored connection for this tenant.
declare const connectionResolver: CredentialResolver;

// Your own: exchanges the sealed refresh token for a new access token.
const googleRefresher: CredentialRefresher = {
async refresh() {
const { accessToken, expiresInSeconds } = await exchangeStoredRefreshToken();
return refreshable(bearer(accessToken), new Date(Date.now() + expiresInSeconds * 1000).toISOString());
},
};
declare function exchangeStoredRefreshToken(): Promise<{ accessToken: string; expiresInSeconds: number }>;

// A Google access token lives one hour. Without this the toolkit works until lunch.
const resolver = withRefreshingCredentials(connectionResolver, googleRefresher);

const agent = createAgent({
manifest: {
id: "assistant",
name: "Assistant",
instructions:
"Help with mail and scheduling. Draft rather than send unless explicitly asked to send. " +
"Find a free slot before proposing a meeting.",
modelPolicy: { role: "smart" },
},
tools: [
createGoogleToolkit({
credentialRef: "google",
resolver,
include: ["gmail_search_messages", "gmail_get_thread", "gmail_create_draft", "calendar_find_free_time"],
}),
],
});

Credentials and scopes

OAuth only. Google has no personal-access-token equivalent for Gmail or Calendar — every path is a consented grant, which is why this toolkit's modes has one entry where GitHub's has two.

Restricted scopes, and what that costs you

This is the part to read before planning a rollout.

ScopeGoogle's tierWhat it means for you
gmail.readonlyRestrictedApp verification plus a security assessment
gmail.sendRestrictedSame
gmail.composeRestrictedSame
gmail.modifyRestrictedSame
calendar.readonlySensitiveVerification, no assessment
calendar.eventsSensitiveVerification, no assessment
drive.readonlyRestrictedReads a user's entire Drive — assessment applies
drive.fileNot sensitiveOnly files this app created or the user picked
documents.readonly / documentsSensitiveVerification, no assessment
spreadsheets.readonly / spreadsheetsSensitiveVerification, no assessment

A restricted scope requires your Google Cloud app to pass verification and a security assessment — which takes weeks and may require a third-party audit — before anyone outside your OAuth test-user list can consent. Until then Gmail works for your listed testers and for nobody else.

Sensitive scopes need verification but no assessment. That is a materially smaller burden, and it is why a deployment that enables only Calendar is in a very different position from one that enables Gmail.

GOOGLE_SCOPES exports this table, so a host can tell an operator which situation they are in rather than letting them discover it from a consent screen.

Ask for the narrowest set that works. Each tool declares its own scopes, so enabling four Gmail tools should be sent through consent for four tools' worth — asking for gmail.modify when only gmail.readonly is needed is the difference between a consent a security team approves and one they refuse.

Behaviour worth knowing

A missing scope is refused before the call. Google answers 403 PERMISSION_DENIED with a message naming the API rather than the scope, so a model reads it as a wrong identifier and retries with different arguments. Each tool declares its scopes and the toolkit compares them against what the connection was granted, producing an error that names the scope and says to reconnect — with nothing sent to Google.

When the grant is unstated — a static token carrying no scope metadata — it proceeds rather than refusing. A check that cannot be performed must not break a working configuration, and Google's own 403 is mapped to something readable as the fallback.

The draft is the only ungated write, and that is deliberate. If drafting and sending both cost an approval, a model has no incentive to draft: the cheap path and the irreversible path are equally expensive, so it takes the one that finishes the task. Making the reversible act free is what makes it the default. The draft lands in a person's Gmail where they read it and press send — the outcome the approval gate was trying to produce.

A reply stays in its thread, and the original is fetched to make that true. A reply needs the original's Message-ID for In-Reply-To and its References for the chain. Both live only on the real message, so the toolkit fetches it. Building them from an id the caller passed produces a reply that sends perfectly and arrives as a new conversation — which looks like success from every angle except the recipient's. When the original has no Message-ID, the send is refused rather than silently starting a thread.

Non-ASCII subjects are RFC 2047 encoded. Raw UTF-8 in a header does not throw; it arrives as mojibake and nobody tells you.

A line break in a subject or address is refused. A header ends at CRLF, so Update\r\nBcc: someone@else is two headers and the second copies the mail. An agent composing a subject from an email it just read is exactly that path — retrieved content is untrusted, per docs/17.

A calendar it cannot read is not an empty one. calendar_find_free_time reports unreadable calendars separately and warns, because treating "cannot see" as "free" is how an agent books over somebody's day. Overlapping busy blocks are merged before being inverted, or two people's overlapping meetings produce a "free" slot that is free for neither.

Attendees are always notified, explicitly rather than by Google's default — which differs by endpoint.

Overwriting a spreadsheet range is classified as destruction, not as a write. It is the only tool here where a write destroys data no delete tool touched: the previous cell values are not in a trash, not in a revision this API exposes, and not recoverable by anything an agent can call. A person can use Sheets' version history in a browser; the runtime cannot. confirms() would place it in the same class as an append, and those are not the same act.

sheets_update_values also refuses an open-ended range. Sheet1!A:C is legal and means every row of three columns — harmless to read, and a whole spreadsheet replaced from three rows of input if written. That refusal exists in exactly one place, because that is the one place the same input means something catastrophic.

Appending really appends. sheets_append_rows uses Google's own append endpoint, which finds the end of the data itself. The tempting implementation — read the sheet, compute the last row, write there — is wrong twice: the sheet can change in between, and a guess one row short overwrites the last row of real data. docs_append_text uses endOfSegmentLocation for the same reason.

Sharing has no default audience. drive_share_file requires user, domain or anyone to be stated. A model that meant "share with Ana" and omitted the field would otherwise hit a default — and if that default were anyone, a document is on the open internet with the API returning success either way. The permissive case must always be something somebody typed. The role does default, to reader, which is the least powerful value.

A1 ranges are parsed before they are sent. A malformed one gets a message naming what is wrong instead of Google's Unable to parse range. More importantly, A1:C — a mix of a cell and a column — is refused: Google reads it as every row of three columns, and a caller who meant A1:C1 has just addressed a column.

Native files export, binary files say they cannot. A Doc becomes markdown, a Sheet becomes CSV (first tab only, and it says so), Slides become text. A PDF or an image returns its metadata plus a clear note — decoding bytes as UTF-8 produces a page of replacement characters that looks like content, and a model will try to read it.

Limits

Not offeredWhy
Deleting or trashing anything — mail, files, documentsNot a capability this sprint grants. A message or file can be found again; a deleted one cannot
Replacing a range of a Google DocAppend only, deliberately. Arbitrary replacement is sheets_update_values's problem in a place with no version history a tool can reach
Binary uploadsText only. A resumable upload is a different protocol, and a tool that silently truncated a file would be worse than one that refuses
Deleting or trashing mailNot a capability this sprint grants. A message can always be found again; a deleted one cannot
Attachments, sending or readingMultipart to a second host, the same deferral as Slack's upload_file
Filters, forwarding rules, vacation respondersStanding configuration that outlives the run that made it — the highest-consequence thing in a mailbox
Contacts, Directory, Admin SDKA different API and a different consent, mostly organisation-wide
Calendar sharing and ACLsAccess-granting is the one act a wrong call cannot walk back
Recurring-event rule editingChanging a rule silently changes every future instance; editing one instance is what people mean
Service accounts with domain-wide delegationImpersonating any user in a domain. A real deployment shape, and one that deserves its own design rather than an inherited one