Ardian About Directory
Claude Code
Email me

gworkspace

2026 — A Go library for each person's Gmail, Calendar, and Contacts behind the one permission they give

01 Problem

Many apps want to act on their users' Google accounts, like reading mail, checking a calendar, or finding a contact's address. Google keeps those in three products, Gmail, Calendar, and Contacts, each with its own API, the interface a program calls, its own Go library, and its own scopes, the permissions a program asks for. Before the first call, an app has to link each user's Google account on Google's consent screen, keep the refresh token Google hands back, a long-lived key for that user, and turn it into a short-lived access token before every request.

Written inside each app, that setup is repeated in every codebase, three times over when an app uses all three products, and it sits in the middle of code that is about something else.

02 Solution

go.trueardian.com/gworkspace is an open source Go library that does that setup once for all three, a library being code other programs import rather than a program that runs on its own. Each user says yes once, and one refresh token covers everything the app uses. That user is an owner, whatever ID the app already has for them, and the library never looks inside it. An app imports it, picks where credentials live, and keeps its own code about what it does.

It started on 29 June 2026 and keeps credentials the way go.trueardian.com/spotify does, one per user in a store the app chooses.

cfg := &oauth2.Config{
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: "https://app.example.com/oauth/callback",
Endpoint: google.Endpoint,
Scopes: slices.Concat(
gworkspace.CalendarRequiredScopes,
gworkspace.GmailRequiredScopes,
gworkspace.ContactsRequiredScopes,
),
}
client := gworkspace.NewClient(store, cfg)

calendar, err := gworkspace.NewCalendar(client)
gmail, err := gworkspace.NewGmail(client)
contacts, err := gworkspace.NewContacts(client)

03 Linking user accounts

The app asks for the scopes of every product it uses at once, and Google hands back one refresh token that covers them all. Calendar needs auth/calendar, Gmail needs auth/gmail.modify to read and auth/gmail.send to send, and Contacts needs auth/contacts. NewCalendar, NewGmail, and NewContacts each check that the app asked for their scopes, so an app that forgot Contacts fails when it starts and not when the first person asks about a contact.

Google gives a refresh token only the first time an account says yes, unless the consent screen is shown again. So AuthURL, which makes the link to the consent screen, always asks for offline access and forces the screen, and Connect, which trades the code from the callback for the token, fails instead of saving an empty one.

The consent screen also lets a person untick a permission. Connect compares what Google granted with what the app asked for, and when Contacts is missing it returns ErrMissingScopes and saves nothing, so no stored token ever lacks a scope the app needs and the person simply reads as not connected. Exchange does the same check without saving, for an app that would rather keep the token itself.

Connected says whether a token is stored, and Disconnect forgets it. Neither asks Google, so the permission stays on the Google Account after a Disconnect, and a permission the person revoked there still reads as connected until a call fails.

func connect(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, client.AuthURL(state), http.StatusFound)
}

func callback(w http.ResponseWriter, r *http.Request) {
err := client.Connect(r.Context(), owner, r.URL.Query().Get("code"))
if errors.Is(err, gworkspace.ErrMissingScopes) {
http.Redirect(w, r, client.AuthURL(state), http.StatusFound)
return
}
}

04 Managing user credentials

A TokenStore is any place that can get, save, and delete a refresh token by owner, and its one rule is to return ErrNotConnected when the owner has no token.

The postgres package takes anything that can run a query, so a pool, a single connection, or a transaction all fit. WithAutoMigrate creates the gworkspace_tokens table at startup, and without it the store only checks that the table is there and fails right away if it is not.

The firestore package keeps one document per owner in Firestore, Google's document database, with the owner as the document's ID, so an owner that cannot be one, like an ID with a slash, is rejected before anything is sent. In both, connecting again replaces the token and keeps the time the owner first connected.

pgStore, err := postgres.NewTokenStore(ctx, pool, postgres.WithAutoMigrate())

fsStore := firestore.NewTokenStore(fsClient)

05 API calls

Every call takes the owner. It reads that owner's token, makes one fresh access token, sends its requests to Google, and throws the connection away, so nothing is kept between calls. Inviting Abraham is three calls, GetContacts, GetEvents, and AddEvent, so it reads the token three times and refreshes it three times. That is fine for an agent that acts a few times per request, and it means a Disconnect counts on the very next call.

Answers come back as the library's own Event, Message, Label, and Contact instead of Google's types. An all-day event has a date where a timed one has a time, so it starts at midnight UTC on its date. A mail body arrives encoded as base64 and comes back as its plain text part, and a contact's name is the first of its names.

ReadMessages takes Gmail's own search syntax, like is:unread, and then fetches each match in full, one request per message. Every query has a Limit and none pages through results, because an agent asks for the top ten and not the whole mailbox. Gmail can also send plain text to one recipient and work with labels, Calendar can add an event with guests, and Contacts can add a contact.

people, err := contacts.GetContacts(ctx, owner, gworkspace.ContactQuery{})

events, err := calendar.GetEvents(ctx, owner, gworkspace.EventQuery{
TimeMin: start,
TimeMax: start.Add(time.Hour),
})

event, err := calendar.AddEvent(ctx, owner, gworkspace.EventInput{
Summary: "Swim with Abraham",
Location: "Falajawa Beach",
Start: start,
End: start.Add(time.Hour),
Guests: []string{"abraham@avagenc.com"},
})

messages, err := gmail.ReadMessages(ctx, owner, gworkspace.MessageQuery{Query: "is:unread", Limit: 5})
err = gmail.SendEmail(ctx, owner, "ishmael@avagenc.com", "Notes", "Here are the notes from today.")

06 Error handling

The library has only two errors of its own, ErrNotConnected and ErrMissingScopes, because nothing Google says can tell a caller either of them.

It had more at first. On 30 June 2026 WrapError and ErrRateLimited, which gave Google's 429 a name of its own, were removed. Google's error already comes as a *googleapi.Error with its status and message, so a name on top of it added nothing the caller could not read itself. Spotify is the opposite case, since it hides the reason for a failure, which is why go.trueardian.com/spotify names its errors.

Every other failure is Google's own error with what was being done in front of it, like get events. A revoked permission comes back as an *oauth2.RetrieveError with the code invalid_grant, which never passes as ErrNotConnected.

var gerr *googleapi.Error
if errors.As(err, &gerr) && gerr.Code == http.StatusTooManyRequests {
return backOff(err)
}

var rerr *oauth2.RetrieveError
if errors.As(err, &rerr) && rerr.ErrorCode == "invalid_grant" {
return askToReconnect(owner)
}

07 Limits

It does not encrypt tokens. Both stores keep the refresh token as plain text, and that token is a long-lived key to someone's mailbox, so encryption belongs in a TokenStore of the app's own.

It covers the essentials only, with no HTML mail, attachments, CC, or replies inside a thread. TokenSource hands over the owner's access, so any other Google library, like the one for Drive, is one line away.

It does not revoke the permission at Google, keep anything between calls, or page through results.

Google also reviews any app that asks for these scopes before the public can use it. gmail.modify, the scope that reads mail, is restricted, which adds a paid security assessment every year, and until the app is verified the sign-in expires after seven days.

ts, err := client.TokenSource(ctx, owner)
svc, err := drive.NewService(ctx, option.WithTokenSource(ts))

08 Use cases

go.trueardian.com/agentkit, an open source library that turns services into tools for Google's Agent Development Kit, gives an agent two tools per product from a module of its own, read_messages and send_email for Gmail, get_events and add_event for Calendar, and get_contacts and add_contact for Contacts. Each tool takes the owner from the agent's session and turns ErrNotConnected into a sentence telling the agent to send the user through Google's consent screen first.

Rafal, an AI agent for a person's Gmail, Calendar, and Contacts, uses all six. Inviting Abraham to swim at 5 pm is three of its tool calls, get_contacts, get_events, and add_event, and the middle column shows the library call under the last of them.

import gworkspaceadk "go.trueardian.com/agentkit/gworkspace/adk"

gmailTools, err := gworkspaceadk.GmailTools(gmail)
calendarTools, err := gworkspaceadk.CalendarTools(calendar)
contactTools, err := gworkspaceadk.ContactTools(contacts)

gworkspace

Any Go app reaches its users' Gmail, Calendar, and Contacts with one import and one permission per user