All integrations

Gmail

GOOGLE WORKSPACE · EMAIL

Connect Gmail to your AI app with per-user OAuth.

Let users connect Gmail and grant the access your app needs. Fabriq manages the provider credentials and exposes tools according to the connected user’s grant and your agent’s scopes.

Connect Gmail with Fabriq Developer

Your user consents at Gmail. The credential lands in a vault your agent cannot read, and the tool list it gets back is the intersection of that user’s grant and the agent’s own scopes.

provider “gmail”
from af_sdk import AgenticFabriq

af = AgenticFabriq()                      # reads AF_APP_ID / AF_APP_SECRET

url = await af.initiate_connection(request, provider="gmail")   # consent at Gmail
return redirect(url)

async with await af.for_user(request) as session:
    tools = await session.get_tools()     # only what this user granted

Provider names are surfaces, not vendors, and the authoritative list is the capabilities endpoint rather than any page including this one — an unknown name returns 400 with the valid list. Fabriq Developer is free to start and needs no provider paperwork. Start free or read the quickstart.

Ship the Gmail integration

Every route, header, field name and error string below is the gateway’s own, and every tool id and scope comes from the generated schema behind the action list further down this page.

Prerequisites

Before the redirect above can do anything, four things have to be in place on the Fabriq side and one or two on the Gmail side.

  • A Fabriq Developer app, and its AF_APP_ID and AF_APP_SECRET. They ride on every app-scoped call as X-App-Id and X-App-Secret.
  • A Redirect URL saved on the agent’s Connect & webhooks page. It is checked before anything else, so leaving it empty fails every connect with “B2B2C OAuth callback URL not configured” and hides whatever else is wrong.
  • An external user id of your own, one per real person, created with POST /api/v1/apps/{app_id}/external-users. The call is idempotent and a 409 means the id already exists.
  • Grants on the agent itself. The tool list a user gets back is the intersection of that user’s grant and the agent’s own scopes, so an agent with no overlapping grant sees nothing even after a clean consent.
  • A Google account your user can consent with, and a verification posture that matches the scopes you ask for. Google classifies gmail.readonly, gmail.compose and gmail.modify as restricted scopes, which require restricted-scope OAuth app verification; gmail.send is only sensitive, so it needs verification but not the restricted review.
  • In a Google Workspace tenant, the app has to be allowed through Admin console API controls, where an admin sets each app to Trusted, Limited, specific Google data, or Blocked and can separately restrict high-risk Gmail scopes.
Create the user
# AF_BASE is your gateway base URL, from the Fabriq Developer console.
export AF_APP_ID=...        # sent as X-App-Id
export AF_APP_SECRET=...    # sent as X-App-Secret

# One external user id per real person. Idempotent; 409 means it exists.
curl -X POST "$AF_BASE/api/v1/apps/$AF_APP_ID/external-users" \
  -H "X-App-Id: $AF_APP_ID" -H "X-App-Secret: $AF_APP_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"external_user_id": "user_8412"}'

Connect an account

The gateway provider id is gmail, and this page is /integrations/gmail. Post the initiate, redirect the user to oauth_url, and let them consent at Google. The credential lands in a vault your agent cannot read; your code never sees a Google token.

Both the OAuth flow and the hosted-credential flow return to the same Redirect URL with the same query parameters, so one callback handler covers everything you connect next.

Start the grant
# 1. Ask the gateway whether this deployment can connect gmail at all.
#    This endpoint is the authoritative list, not any page including this one.
curl "$AF_BASE/api/v1/apps/$AF_APP_ID/external-users/providers/capabilities" \
  -H "X-App-Id: $AF_APP_ID" -H "X-App-Secret: $AF_APP_SECRET"
#    Route on oauth || key_connect. The other flags describe or explain.

# 2. Start the grant, then redirect the user to oauth_url.
curl -X POST \
  "$AF_BASE/api/v1/apps/$AF_APP_ID/external-users/user_8412/oauth/gmail/initiate" \
  -H "X-App-Id: $AF_APP_ID" -H "X-App-Secret: $AF_APP_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{"connection_id": "default"}'
#    Response fields: oauth_url, state, connection_id, credential_source.

Make one tool call

google_gmail_list_messages is the cheapest honest first call: it is a GET, it takes no required arguments, and it needs only the gmail.readonly scope. The gateway serves it at /api/v1/tools/google/gmail/messages and the tool returns message ids with subject, from, to, date, snippet and body.

If you would rather not ask for a restricted scope at all, send instead: gmail_send takes to, subject and body and needs only gmail.send, the one Gmail scope on this connector that Google treats as sensitive rather than restricted. Note that it is a real send — use an address you own.

Call one tool
# A user-scoped token, good for 900 seconds. Cache it per user; re-mint
# roughly 60s before expiry, because these tokens cannot be refreshed in place.
TOKEN=$(curl -s -X POST \
  "$AF_BASE/api/v1/apps/$AF_APP_ID/external-users/user_8412/token" \
  -H "X-App-Id: $AF_APP_ID" -H "X-App-Secret: $AF_APP_SECRET" \
  -H 'Content-Type: application/json' -d '{}' | jq -r .access_token)

# External-user tokens go to /mcp/external. Never /mcp: both routes answer 200
# for an external token, so the wrong one fails silently.
curl -X POST "$AF_BASE/mcp/external" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
       "params": {"name": "google_gmail_list_messages", "arguments": {}}}'

Inspect the result

Scope to action, as the connector declares it: gmail.readonly covers google_gmail_list_messages, google_gmail_read_message, google_gmail_list_threads and google_gmail_get_thread. gmail.send covers gmail_send and gmail_reply. gmail.compose covers the draft actions, including google_gmail_send_draft. gmail.modify covers the label, archive, trash and read-state actions.

So a user who granted only gmail.send has a connection in status "connected" and exactly two tools. That is working, not broken.

  • tools/list has two gates: the connection has to exist, and the agent’s grants have to intersect the connection’s scopes. Connected with zero tools is a real state, so say “connected but no actions live” rather than showing the integration as on.
  • A listed connection is not a working one. The initiate writes the row before the user ever reaches the provider: status “configured” means the row exists and nothing else does, and it yields zero tools. Key every user-facing state off status == "connected".
  • Do not infer readiness from granted_scopes being non-empty. Some providers legitimately grant none.
  • The callback query parameters — status, tool, connection_id, external_user_id — are not signed. Treat them as a UI hint, resolve the user from your own session, and confirm by re-reading the connections list. The signed channel is the connection.completed webhook.
Read the connection back
# What this user can actually do right now.
curl -X POST "$AF_BASE/mcp/external" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}'

# And the connection row behind it. Only status "connected" is usable.
curl "$AF_BASE/api/v1/apps/$AF_APP_ID/external-users/user_8412/connections" \
  -H "X-App-Id: $AF_APP_ID" -H "X-App-Secret: $AF_APP_SECRET"

# Shape of a connection row, using the field names the gateway documents:
# [
#   {
#     "tool": "gmail",
#     "connection_id": "default",
#     "status": "connected",
#     "granted_scopes": ["https://www.googleapis.com/auth/gmail.readonly"]
#   }
# ]

The payload above shows the shape of a response using the field names the gateway documents. It is illustrative: it was not captured from a live Gmail account, so treat the values as placeholders and read your own response.

Disconnect

Deleting the connection is one app-scoped call, and it is the only way to take Gmail away from an agent without taking the agent down.

  • The delete removes the stored credential and the connection row on the Fabriq side, so the user has to authorize again to reconnect.
  • Whether the authorization is still listed in the provider’s own connected-apps screen is the provider’s state, not Fabriq’s. If your flow needs the grant gone there too, send the user to the provider and check.
  • Drop any cached user token when you disconnect, and on a connection.needs_reauth webhook.
Delete the connection
# ?tool= is required and a 422 is what you get without it: connection_id
# defaults to "default" for every provider, so a user with two providers
# connected has two rows sharing that id and the delete would be ambiguous.
curl -X DELETE \
  "$AF_BASE/api/v1/apps/$AF_APP_ID/external-users/user_8412/connections/default?tool=gmail" \
  -H "X-App-Id: $AF_APP_ID" -H "X-App-Secret: $AF_APP_SECRET"

Troubleshooting

  • A read-only agent still triggers Google’s restricted-scope review: gmail.readonly is restricted, and so are gmail.compose and gmail.modify. gmail.send is the only scope on this connector that is merely sensitive, so a send-only agent is the one shape that avoids the restricted review. Google says that review uses the App Defense Alliance CASA framework, has to be repeated at least every 12 months, and can take several weeks.
  • A user in a managed Google Workspace tenant can be blocked no matter how your app is verified, because the admin controls which apps reach Workspace data and can restrict high-risk Gmail scopes for the whole organizational unit.
  • While the Google consent screen is External and its publishing status is still Testing, refresh tokens expire after seven days, which reads in production as an integration that works for a week and then stops.
  • Tool ids are vendor-prefixed here. google_gmail_archive_message belongs to gmail, not to a provider called google, so match the first two name segments when you attribute a tool to a provider.
  • Consent completed but tools/list comes back empty: diff the authorize URL’s scope= parameter against the connection row’s granted_scopes before blaming the console. The URL tells you what was actually requested, and a scope never requested can never be granted.
  • Every connect fails with “B2B2C OAuth callback URL not configured”: the Redirect URL on the agent’s Connect & webhooks page is empty. It is checked first, so it masks every other misconfiguration.
  • Calls return 200 and nothing happens: an external-user token was posted to /mcp instead of /mcp/external. Both answer 200, so this one fails silently — pin the route with a test.
  • An unknown provider name returns 400 with the list of valid names. Read that error; it is accurate. Provider names are surfaces rather than vendors.

Gmail-side requirements checked against Gmail API OAuth scopes (restricted vs sensitive) (2026-10-29), Restricted scope verification (2026-10-29), Refresh token expiration (2026-10-29), Control which apps access Google Workspace data (2026-10-29).

Connecting Gmail in practice

Almost every Gmail scope an agent needs is on Google’s restricted list, so a third-party server holding those tokens has to pass an annual App Defense Alliance security assessment before general users can connect, and Google says that can take several weeks. Quota is charged in units rather than requests, and messages.send costs 100 against 6,000 units per minute per user, so the practical ceiling is about sixty sends a minute per mailbox.

Scopes that draw scrutiny

  • https://mail.google.com/ Restricted. "Read, compose, send, and permanently delete all your email from Gmail", which is the widest grant Gmail has.
  • https://www.googleapis.com/auth/gmail.readonly Restricted. Reads all mail and settings, so it pulls in the security assessment even for a read-only agent.
  • https://www.googleapis.com/auth/gmail.modify Restricted. Read, compose and send without permanent delete, the usual choice for an assistant, and still assessed.
  • https://www.googleapis.com/auth/gmail.settings.sharing Restricted. Manages sensitive mail-sharing settings such as delegation.
  • https://www.googleapis.com/auth/gmail.send Sensitive rather than restricted, so it needs verification but not the annual assessment.

Rate limits

  • 6,000 quota units per minute per user per project.
  • 1,200,000 quota units per minute per project.
  • Per-method cost: messages.send 100, threads.get 40, messages.get 20, drafts.create 10, messages.list 5, labels.list 1.
  • 80,000,000 quota units per day per project is the billing threshold.
  • 500 recipients per message.

Who has to approve

In Google Workspace an admin sets each app in Admin console API controls to Trusted, Limited, specific Google data, or Blocked, and can restrict a predefined list of high-risk OAuth scopes for Gmail in particular. An app that has not been configured shows up as an "Apps pending review" request, which the admin resolves per organizational unit rather than per user.

Worth knowing

  • While the OAuth consent screen is External and its publishing status is still Testing, refresh tokens expire after 7 days, unless the only scopes requested are a subset of name, email and profile.
  • Refresh tokens also stop working after six months of non-use, and for Gmail scopes specifically they are revoked when the user changes their account password.
  • Brand verification has to be completed before you can even submit the scopes for data-access verification, so the two reviews are sequential.
  • The current quota page publishes only per-minute and per-day figures. There is no per-user-per-second number to design against.

Checked against Gmail API scopes (2026-09-29), Gmail API usage limits (2026-09-29), Restricted scope verification (2026-09-29), Refresh token expiration (2026-09-29), Workspace app access control (2026-09-29)

Acts as the person, not as itself

Each user connects their own account. Every call carries both identities — the agent and the person it is acting for — so the agent can never reach past what that individual can already do.

Credentials never touch the agent

Tokens live in the vault and attach server-side at call time. The agent holds a session, not a secret, and revoking access does not mean rotating a key.

Every call on the record

Who asked, which agent acted, which action ran, and the verdict that let it through — one audit trail across every integration, not one per vendor.

What an agent can do

Each action is granted on its own. An agent allowed to read is not thereby allowed to write, and the scope beside each row is what the acting user must have connected for it to run at all.

gmail_replyWRITE

Reply to an existing Gmail message. Maintains threading. Requires gmail.send scope.

gmail.send
gmail_sendWRITE

Send an email via Gmail. Requires gmail.send scope.

gmail.send
google_gmail_add_labelWRITE

Add labels to a Gmail message. IMPORTANT: You must first call google_gmail_list_messages to get the actual message_id. Do NOT use placeholder values.

gmail.modify
google_gmail_archive_messageWRITE

Archive a Gmail message by removing the INBOX label. IMPORTANT: You must first call google_gmail_list_messages to get the actual message_id. Do NOT use placeholder values.

gmail.modify
google_gmail_create_draftWRITE

Create a new Gmail draft. The draft can be edited or sent later.

gmail.compose
google_gmail_delete_draftWRITE

Delete a Gmail draft. IMPORTANT: You must first call google_gmail_list_drafts to get the actual draft_id. Do NOT use placeholder values.

gmail.compose
google_gmail_get_threadREAD

Get a complete Gmail thread with all messages. Returns the full conversation including all replies. IMPORTANT: You must first call google_gmail_list_threads to get the thread_id.

gmail.readonly
google_gmail_list_draftsREAD

List Gmail drafts. Returns draft IDs, subjects, and recipients.

gmail.compose
google_gmail_list_messagesREAD

List Gmail messages with full content. Returns message IDs, subject, from, to, date, snippet, body_text, and body_html for each message.

gmail.readonly
google_gmail_list_threadsREAD

List Gmail threads (conversations). Returns thread IDs and snippets. A thread groups related messages together.

gmail.readonly
google_gmail_mark_readWRITE

Mark a Gmail message as read. IMPORTANT: You must first call google_gmail_list_messages to get the actual message_id. Do NOT use placeholder values.

gmail.modify
google_gmail_mark_unreadWRITE

Mark a Gmail message as unread. IMPORTANT: You must first call google_gmail_list_messages to get the actual message_id. Do NOT use placeholder values.

gmail.modify
google_gmail_read_messageREAD

Read a specific Gmail message by ID. Returns full message content including subject, from, to, and body. IMPORTANT: You must first call google_gmail_list_messages to get the actual message_id. Do NOT use placeholder values.

gmail.readonly
google_gmail_remove_labelWRITE

Remove labels from a Gmail message. IMPORTANT: You must first call google_gmail_list_messages to get the actual message_id. Do NOT use placeholder values.

gmail.modify
google_gmail_send_draftWRITE

Send an existing Gmail draft. IMPORTANT: You must first call google_gmail_list_drafts to get the actual draft_id. Do NOT use placeholder values.

gmail.compose
google_gmail_trash_messageWRITE

Move a Gmail message to trash. IMPORTANT: You must first call google_gmail_list_messages to get the actual message_id. Do NOT use placeholder values.

gmail.modify
google_gmail_update_draftWRITE

Update an existing Gmail draft. IMPORTANT: You must first call google_gmail_list_drafts to get the actual draft_id (a long alphanumeric string like 'r3948572938475293847'). Do NOT use placeholder values.

gmail.compose

Put Gmail behind one governed endpoint.

Same permissions, same audit trail, whatever else you connect next.