Ardian About Directory
Claude Code
Email me

gworkspace

2026 — A Go library for each user's Gmail, Calendar and Contacts behind the one token they granted

01 Problem

An app that reads someone's mail, checks their calendar or looks up a friend's email address has to talk to three Google APIs, each with its own SDK and its own list of scopes. It also has to keep a refresh token for every person, renew access from it on every call, and notice when it stops working.

gworkspace keeps one token per person for the whole of Workspace. That person is an owner, whatever ID your app already has for them, and the library never looks inside it.

02 One token

Everything a person says yes to on the consent screen comes down to one refresh token. NewClient takes an oauth2.Config with the scopes of the services you use and a TokenStore to keep tokens in.

Calendar needs auth/calendar, Gmail needs auth/gmail.modify and auth/gmail.send, and Contacts needs auth/contacts. Each service checks the client carries its scopes when you build it, so a config that forgot Contacts fails at startup and not on the first person who asks about a contact.

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 Connecting

AuthURL sends the person to the consent screen and Connect trades the code on the callback for their token. Google only hands out a refresh token the first time an account says yes unless the consent screen is forced, so AuthURL always asks for offline access and forces it, and Connect fails instead of saving an empty token.

If the person unticked a permission, Connect returns ErrMissingScopes and saves nothing. Exchange does the same check without the save, for when you would rather keep the token yourself.

Connected tells you whether a token is stored, and Disconnect forgets it and returns ErrNotConnected when there was nothing to forget. Neither asks Google, so the grant stays on the Google Account after a Disconnect, and a token revoked there 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 Calling Workspace

Every call takes the owner. It reads that owner's token from the store, refreshes an access token once, makes its requests to Google and throws the service away after, so nothing is cached between calls.

Answers come back as the package's own Event, Message, Label and Contact. An all-day event has a date where a timed one has a timestamp, so it starts at midnight UTC on its date, a mail body arrives 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 and then fetches each match in full, one request per message. Every query has a Limit, and there is no paging, 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.

events, err := calendar.GetEvents(ctx, owner, gworkspace.EventQuery{Query: "standup", Limit: 10})

event, err := calendar.AddEvent(ctx, owner, gworkspace.EventInput{
Summary: "Standup",
Start: start,
End: start.Add(15 * time.Minute),
Guests: []string{"bob@example.com"},
})

messages, err := gmail.ReadMessages(ctx, owner, gworkspace.MessageQuery{Query: "is:unread", Limit: 5})
err = gmail.SendEmail(ctx, owner, "bob@example.com", "Hi", "See you at standup.")

people, err := contacts.GetContacts(ctx, owner, gworkspace.ContactQuery{Limit: 20})
contact, err := contacts.AddContact(ctx, owner, gworkspace.ContactInput{
Name: "Bob",
Emails: []string{"bob@example.com"},
})

05 Errors

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

Everything else is Google's own error wrapped with what was being done. errors.As finds the *googleapi.Error and its HTTP status, and a revoked token comes back as an *oauth2.RetrieveError with the code invalid_grant.

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)
}

06 Token stores

A TokenStore is three methods, and the one rule is to return ErrNotConnected from Get and Delete when the owner has no token.

The postgres package takes anything that can Exec and QueryRow, so a pool, a single connection or a transaction all fit. WithAutoMigrate creates the gworkspace_tokens table at startup from migrations embedded in the binary, and without it the constructor only checks the table is there and fails right away if it isn't.

The firestore package keeps one document per owner with the owner as its ID, so an owner that cannot be a document ID, like one 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)

07 What it doesn't do

It doesn't 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 your own.

It covers the essentials only, with no HTML mail, attachments, CC or replies in a thread. TokenSource hands you the owner's token source, so any other Google SDK, like Drive, is one line away.

It doesn't revoke the grant at Google, cache anything, or page through results.

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

08 With ADK

The ADK tools live in agentkit, a separate module, so this one stays free of any agent framework. Each service has its own call that hands the agent two tools, so Gmail brings read_messages and send_email, Calendar brings get_events and add_event, and Contacts brings get_contacts and add_contact.

Each tool takes the owner from the ADK user ID, and turns ErrNotConnected into a sentence telling the agent to send the human through the OAuth flow first.

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

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

gworkspace

Gives Rafal in Avagenc Chat the human's Gmail, Calendar and Contacts, from one stored token per human