Skip to main content
23 toolsOAuth bearer
gmail_example.py
Twenty-three tools over one mailbox, against Gmail’s own REST API. Sending takes a recipient, a subject and a body: the RFC-822 assembly and base64url encoding Gmail’s raw field demands happen on the way out, and the MIME tree it returns never reaches your model.

Authenticating

This pack takes a Google OAuth bearer token, and configure() is optional when $GOOGLE_ACCESS_TOKEN is set. Which credential provider you hand it depends on whose account the calls run as.
Refer to Google’s provider page for the “GOOGLE” constant the snippets below name, the scopes these 23 tools ask for, and this server’s refresh behaviour.

A token you hold

For a script, or a notebook. EnvTokenProvider re-reads the variable on every call, so a token rotated beside the process is picked up without a restart; StaticTokenProvider takes one you already hold as a string. Neither renews anything, so the calls stop when the token expires.
gmail_script.py

One account, refreshed

For an agent or a server acting as you. OAuth2Client turns a client registration and a stored refresh token into an access token, and renews it before it lapses.
gmail_agent.py
No refresh token yet? Your own account is the one-time consent flow that hands you one.

Many end users

For a product whose users each connect their own account. SubjectProvider builds one credential per user through a factory you write, and use_subject names the user a call acts for. Your users’ accounts is the consent route inside your app; serving many users is the per-subject cache and its eviction.
gmail_server.py

The client

oauth_tool_factory is the whole client: a thin wrapper over httpx that attaches your token and these endpoint constants to each request. google-api-python-client and google-auth do not enter your dependency tree.
credentials is whichever of the three you built in Authenticating. A pack takes it through configure(); a client you build takes the same object as credential_provider, and has no configure() of its own.

Paging through a list

A cursor belongs to the tool that returns it, so it is declared on that tool’s builder call:
gmail_pagination.py
Pagination is declared on messages_list, threads_list and drafts_list. The other 20 take no cursor.

Tools

Each is a Tool, called with ainvoke as in the snippet above. The name links to its parameters, its response and what it costs.

What crosses the boundary

On the way out

messages_send, drafts_create and drafts_update take an EmailContent (to, subject, body, mimeType, cc, bcc), and Format("rfc822_base64") turns it into the base64url RFC-822 blob Gmail wants in raw. Transforms is how the marker works, and the built-in list is what else it could have said.

On the way back

The return direction is a Mode declaration. Message.payload, the whole MIME tree with attachment bytes included, is marked response_only, so it is absent from the type the model fills in and cannot be constructed by anything in the pipeline. messages_send shows four visible fields against eight withheld; print it with format_egress_map, which egress control reads line by line.

Trimming a message

Six tools go further and run a ResponseHandler: extract_thread_text on threads_get, threads_modify, threads_trash and threads_untrash, extract_message_text on messages_get, and extract_draft_text on drafts_get. All three project a message the same way. They walk the MIME part tree, base64-decode text/plain and text/calendar, convert text/html to text, and keep attachment metadata without the bytes. All three are exported, so a tool of your own can hand Gmail’s MIME tree to the same walk:
thread_reader.py
Even with no attachment, a thread comes back as kilobytes of base64 JSON per conversation, enough for one tool call to blow out a context window. The response handler is what stops it, and the call log tells you exactly how many bytes never reached your model.
The format a read asks for decides which representation arrives, and the handler follows it. full and metadata fill payload; raw fills raw instead, and gets parsed to the same shape; minimal fills neither, and comes back with no bodyText key rather than an empty one. The other 17 tools return Gmail’s payload as it arrived.

Gotchas

The model fills in userId, maxResults and includeSpamTrash here, but calendar_id and max_results on Google Calendar. Both reach Google as camelCase — the key case cascade converts Calendar’s, and Gmail’s are already there — but the names a model sees differ between the two packs, and a prompt that hard-codes one will not transfer to the other.
next_page_args reads nextPageToken off the page you just got and returns the arguments for the next one, or None when Gmail stops sending a token:
gmail_paginate.py
Every tool takes a userId, and on an ordinary OAuth token the value is "me" — Gmail’s special value for whichever mailbox the token belongs to. It is a parameter of the API rather than of the pack, which is why it is on the schemas the model fills in rather than hidden behind configure().
messages_modify and messages_batch_modify take add_label_ids and remove_label_ids. System labels are their own ids (INBOX, UNREAD, SPAM); a user label’s id is the opaque Label_5 string that labels_list returns, not its display name. Resolve the name first.
Both take the same four writable fields, and they differ in what leaving one out means. labels_update is a PUT, so the label becomes exactly what you send and name is required. labels_patch keeps every field you omit, so it requires nothing: recolouring a label is a color and no name. The other six fields on a Label are Gmail’s own, and neither endpoint offers them.
Gmail documents textColor and backgroundColor as both required to set a label’s colour, and answers a half-set one with a 400 that reads like any other bad request. Color carries that as a validator, so the model is told which field is missing before anything is sent. Both take hex strings from a fixed palette, listed on each field.
Every other tool here runs on https://www.googleapis.com/auth/gmail.modify. Google requires https://mail.google.com/, full mailbox access, for threads_delete alone, because the delete cannot be undone. The pack exports it as gmail.FULL_MAILBOX_SCOPE. It is declared on that tool rather than on the pack, so scopes_for adds it to a consent screen only when you hand that tool out. Drop threads_delete from your tool list and your users are asked for gmail.modify and nothing more.
threads_trash moves a conversation and every message in it to TRASH, and threads_untrash brings it back. threads_delete removes the thread and its messages outright, and Google’s own reference says to prefer trash. The same split exists for drafts, except there is no untrash for a draft: drafts_delete is permanent and has no reversible counterpart.
It takes the same add_label_ids and remove_label_ids as messages_modify and applies them thread-wide, so marking a conversation read is one call rather than one per message. Gmail returns the whole thread back, which is why the trimming handler runs on it.
INBOX, SENT, DRAFT, SPAM and the rest come back from labels_list with type: "system", and labels_update, labels_patch and labels_delete all refuse them. Deleting a user label is permanent, and it is removed from every message and thread it was on.
Gmail’s list endpoints return {"id": ..., "threadId": ...} per message and nothing else: no subject, no sender, no snippet. Reading content is a second call, messages_get per message or threads_get for the whole conversation. threads_get costs 40 quota units against 20 for a single messages_get, so a conversation of three messages or more is cheaper read as a thread.
drafts_list and drafts_get return {"id": ..., "message": {"id": ...}}, and the two are different strings. drafts_update, drafts_send and drafts_delete all take the draft id, the outer one. The message id belongs to messages_get and messages_modify.
drafts_update writes a whole message over the saved one, so send the draft you want to end up with rather than the part that changed. drafts_delete removes the draft permanently: it does not go to TRASH, and there is no untrash for it.