We build custom applications 5x faster and cheaper 🚀
Book a Free Consultation
Stuck on an error? Book a 30-minute call with an engineer and get a direct fix + next steps. No pressure, no commitment.
A correct, production-ready integration between Gmail and OpenClaw is explicit and has three parts: (1) Google Cloud OAuth setup to get a client ID/secret and the right Gmail scopes, (2) an external OAuth callback + token store that exchanges codes for refresh tokens and keeps them in a secure secret store, and (3) a ClawHub-installed OpenClaw skill configured to use those secrets and to call Gmail’s REST API (or receive Pub/Sub push notifications) from the skill runtime. Do not rely on the agent runtime for durable token storage, long-running polling, or webhook receiving — run those components externally and inject credentials into the skill at runtime.
https://www.googleapis.com/auth/gmail.send, .../gmail.readonly), and request only what you need.
https://your-server.example.com/oauth2callback).https://www.googleapis.com/auth/gmail.readonly — read-only accesshttps://www.googleapis.com/auth/gmail.send — send emailhttps://www.googleapis.com/auth/gmail.modify — read and modify (labels, etc.)
Run a small external web service to do the OAuth redirect and token exchange. Store the refresh\_token in a secure secret store (ClawHub secrets, HashiCorp Vault, AWS Secrets Manager, or encrypted DB). The agent runtime should only receive the refresh token (or preferably the long-lived secret reference), not the client secret embedded in code.
const express = require('express');
const fetch = global.fetch; // Node 18+; otherwise use node-fetch or axios
const app = express();
app.use(express.urlencoded({ extended: true }));
// Step A: Redirect user to Google's authorization endpoint
app.get('/link-google', (req, res) => {
const client_id = process.env.GOOGLE_CLIENT\_ID;
const redirect_uri = process.env.OAUTH_REDIRECT\_URI; // e.g. https://your-server.example.com/oauth2callback
const scope = encodeURIComponent('https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/gmail.send');
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?client_id=${client_id}&redirect_uri=${encodeURIComponent(redirect_uri)}&response_type=code&scope=${scope}&access_type=offline&prompt=consent`;
res.redirect(authUrl);
});
// Step B: OAuth callback — exchange code for tokens
app.post('/oauth2callback', express.urlencoded({ extended: true }), async (req, res) => {
const code = req.body.code || req.query.code;
const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT\_ID,
client_secret: process.env.GOOGLE_CLIENT\_SECRET,
code,
redirect_uri: process.env.OAUTH_REDIRECT\_URI,
grant_type: 'authorization_code'
})
});
const tokens = await tokenRes.json();
// tokens includes access_token, refresh_token, expires_in, scope, token_type
// Persist tokens.refresh\_token securely (external secret store)
await saveRefreshTokenForUser(req.userId, tokens.refresh\_token); // implement securely
res.send('Google account linked');
});
app.listen(3000);
Notes:
access\_type=offline and prompt=consent when you need a refresh token for production refreshes.
The skill should not keep long-term state inside the agent runtime. On each invocation, the skill should:
https://oauth2.googleapis.com/token with grant_type=refresh_token.Authorization: Bearer <access\_token>.Example: refresh token exchange and list messages (generic HTTP):
// Refresh token request
POST https://oauth2.googleapis.com/token
Content-Type: application/x-www-form-urlencoded
client_id=...&client_secret=...&refresh_token=REFRESH_TOKEN&grant_type=refresh_token
// Successful response contains access_token and expires_in:
{
"access\_token": "ya29....",
"expires\_in": 3599,
"scope": "...",
"token\_type": "Bearer"
}
// Call Gmail to list messages
GET https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10
Authorization: Bearer ya29....
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (if necessary), and the refresh token reference or secret name.
const fetch = global.fetch; // Node 18+
async function listMessages(accessToken) {
const res = await fetch('https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=10', {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
if (!res.ok) {
const err = await res.text();
throw new Error('Gmail API error: ' + err);
}
const json = await res.json();
return json.messages || [];
}
// Usage in the skill runtime:
// // 1) Get refresh token from secret store (injected into env or retrieved via API)
// // 2) Exchange refresh token for access token at oauth2.googleapis.com/token
// // 3) Call listMessages(accessToken)
Speak one‑on‑one with a senior engineer about your no‑code app, migration goals, and budget. In just half an hour you’ll leave with clear, actionable next steps—no strings attached.
1
Direct answer: The Gmail connector fails with "invalid_grant" when Google refuses the refresh request — typically because the refresh token is revoked/expired, never issued (no offline scope), already consumed, or the client credentials/redirect settings don’t match what Google expects. Also check clock skew, consent changes, or storing the wrong token.
// Node.js fetch refresh example
const params = new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: storedRefreshToken
});
const res = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', body: params });
const body = await res.json();
// handle body.error like "invalid_grant"
2
Direct answer: A 403 or 410 from Google means Google’s push delivery (usually via Cloud Pub/Sub push) tried to POST to your OpenClaw Event Router but the router rejected it or the subscription is invalid/removed. Common root causes are authentication/validation mismatches (OIDC token audience or channel token), an unreachable or mis‑typed HTTPS endpoint, deleted/expired Pub/Sub subscription (410), or the router returning non‑200 responses or failing TLS/ALPN checks.
3
Direct answer: Read Gmail's labelIds when pulling messages (use the Gmail API with appropriate OAuth scopes), map those label IDs to a persistent labels/tags field in your OpenClaw message schema, and include that mapping when creating or updating records so MailSync never discards existing labels.
const {google} = require('googleapis');
// // fetch message with labels
const msg = await gmail.users.messages.get({userId:'me', id:messageId, format:'metadata', fields:'id,labelIds'});
const labels = msg.data.labelIds || [];
// // send to OpenClaw endpoint, preserve on update by merging server-side
await fetch(process.env.OPENCLAW_MESSAGE_ENDPOINT, {
method:'POST',
headers:{'Authorization':Bearer ${process.env.OPENCLAW_API_KEY},'Content-Type':'application/json'},
body:JSON.stringify({messageId:msg.data.id, labels})
});
4
Direct answer: Detect large Gmail attachments before downloading, stream them out of the agent runtime into external object storage (S3/GCS), return a pointer to the AttachmentService, and adjust the RetryPolicy so timeouts on large downloads are not retried blindly but trigger staged backoff or manual handling.
This prompt helps an AI assistant understand your setup and guide you through the fix step by step, without assuming technical knowledge.
From startups to enterprises and everything in between, see for yourself our incredible impact.
Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We’ll discuss your project and provide a custom quote at no cost.Â