Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# google-plugin

**Google Workspace** for a [protoAgent](https://github.com/protoLabsAI/protoAgent) agent — built to grow. Ships **Gmail (read + draft)**, **Calendar (read)**, and **Drive (read)** today over a service-agnostic OAuth/REST core, so Docs / Sheets / further services are additive modules, not a rewrite.
**Google Workspace** for a [protoAgent](https://github.com/protoLabsAI/protoAgent) agent — built to grow. Ships **Gmail (read + draft + hygiene)**, **Calendar (read + free/busy + own-calendar events)**, **Contacts (search)**, **Docs (create)**, and **Drive (read)** over a service-agnostic OAuth/REST core, so further services are additive modules, not a rewrite.

Pull-mode posture: the agent lists, searches, reads, **drafts**, and does **mailbox hygiene** (mark read, label, archive) — it never sends, deletes, or auto-replies. A human reviews drafts in the Drafts folder and sends them.

Expand All @@ -13,6 +13,9 @@ Pull-mode posture: the agent lists, searches, reads, **drafts**, and does **mail
- `gmail_list_drafts(max)` · `gmail_update_draft(draft_id, body, …)` — revise drafts; **still never sends**.
- `calendar_list_upcoming(days, calendar_id)` · `calendar_event_detail(event_id, calendar_id)` — read.
- `calendar_availability(days)` — free/busy blocks. · `calendar_search(query, days_back, days_ahead)` — text search.
- `calendar_create_event(title, start, end, …)` — own calendar only; **takes no attendees** (inviting emails people).
- `contacts_search(query)` — names → email addresses (saved + auto-collected contacts). Read-only.
- `docs_create(title, text)` — a NEW private Google Doc; existing docs are never edited.
- `drive_search(query, max)` · `drive_read(file_id, max_chars)` — read; Docs export as text, Sheets as CSV, Slides as text.

## Architecture
Expand All @@ -27,7 +30,7 @@ One-time Google Cloud setup (5 minutes):
3. **Credentials ▸ Create credentials ▸ OAuth client ID ▸ Web application**, authorized redirect URIs: `http://localhost:7870/plugins/google/oauth/callback` (add `:7871` for the dev instance; the URI must exactly match the origin you open the console on).
4. Paste the client ID + secret into the plugin settings.

Default scopes requested: `gmail.modify`, `calendar`, `drive.readonly` (override via the `oauth_scopes` setting — request the set you intend to grow into so adding a service needs no re-consent).
Default scopes requested: `gmail.modify`, `calendar`, `drive.readonly`, `contacts.readonly`, `contacts.other.readonly`, `documents` (override via the `oauth_scopes` setting). Tokens minted before v0.5.0 lack the contacts/docs scopes — hit **Reconnect** once to widen; those tools return a readable insufficient-scope error until then.

Manual fallback (headless / no browser): mint a refresh token yourself and set `google.refresh_token` (or `GOOGLE_REFRESH_TOKEN`); env fallbacks `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` work too.

Expand Down
61 changes: 61 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,66 @@ def calendar_event_detail(event_id: str, calendar_id: str = "primary") -> str:
return out if isinstance(out, str) else json.dumps(out, indent=2)


@tool
def calendar_create_event(title: str, start: str, end: str, description: str = "",
location: str = "", timezone: str = "", calendar_id: str = "primary") -> str:
"""Create an event on the user's OWN calendar. Cannot invite attendees — that
would email people, and this plugin never sends. Reversible (delete in Calendar).

Args:
title: event title.
start: ISO datetime (e.g. 2026-07-04T09:00:00-07:00) or YYYY-MM-DD for all-day.
end: same format as start.
description: optional notes.
location: optional location.
timezone: IANA tz (e.g. America/Los_Angeles) if start/end lack an offset.
calendar_id: calendar id (default "primary").
"""
from . import gcal

out = _run(gcal.create_event, _creds(), title, start, end, description, location, timezone, calendar_id)
if isinstance(out, str):
return out
return f"Event created: \"{out['title']}\" {out['start']} → {out['end']} — {out['link']} (no attendees invited)."


# ── Contacts (read-only) ──────────────────────────────────────────────────────

@tool
def contacts_search(query: str, max: int = 10) -> str:
"""Find people (name, email addresses, org) in the user's Google contacts —
including auto-collected past correspondents. Read-only. Use before drafting
mail when you only know a person's name.

Args:
query: a name, email fragment, or company.
max: max people (default 10, cap 30).
"""
from . import gpeople

out = _run(gpeople.search, _creds(), query, max)
return out if isinstance(out, str) else json.dumps({"query": query, "count": len(out), "people": out}, indent=2)


# ── Docs (create-only) ────────────────────────────────────────────────────────

@tool
def docs_create(title: str, text: str = "") -> str:
"""Create a NEW Google Doc in the user's Drive with optional initial text.
Private until the user shares it; existing docs are never edited.

Args:
title: document title.
text: initial plain-text content (optional).
"""
from . import gdocs

out = _run(gdocs.create, _creds(), title, text)
if isinstance(out, str):
return out
return f"Doc created: \"{out['title']}\" — {out['link']}"


# ── Drive (read-only) ─────────────────────────────────────────────────────────

@tool
Expand Down Expand Up @@ -367,6 +427,7 @@ def drive_read(file_id: str, max_chars: int = 20000) -> str:
gmail_list_unread, gmail_search, gmail_get_thread, gmail_create_draft, gmail_mark_read,
gmail_label, gmail_get_attachment, gmail_list_drafts, gmail_update_draft,
calendar_list_upcoming, calendar_event_detail, calendar_availability, calendar_search,
calendar_create_event, contacts_search, docs_create,
drive_search, drive_read,
]

Expand Down
26 changes: 26 additions & 0 deletions gcal.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ def search_events(creds: Creds, query: str, days_back: int = 30, days_ahead: int
return [_summary(e) for e in (data.get("items") or [])]


def create_event(creds: Creds, title: str, start: str, end: str, description: str = "",
location: str = "", timezone_name: str = "", calendar_id: str = "primary",
*, client=None) -> dict:
"""Create an event on the user's OWN calendar. Deliberately takes NO attendees —
inviting people emails them, which crosses the plugin's never-send line.

``start``/``end``: ISO datetimes (offset or ``timezone_name`` required) or bare
YYYY-MM-DD dates for all-day events.
"""
def _when(value: str) -> dict:
if "T" not in value:
return {"date": value}
out = {"dateTime": value}
if timezone_name:
out["timeZone"] = timezone_name
return out

body: dict = {"summary": title, "start": _when(start), "end": _when(end)}
if description:
body["description"] = description
if location:
body["location"] = location
e = request(creds, "POST", f"{BASE}/{calendar_id}/events", json=body, client=client)
return _summary(e)


def event_detail(creds: Creds, event_id: str, calendar_id: str = "primary", *, client=None) -> dict:
e = request(creds, "GET", f"{BASE}/{calendar_id}/events/{event_id}", client=client)
s = _summary(e)
Expand Down
23 changes: 23 additions & 0 deletions gdocs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Docs service — CREATE-only. Built on the shared auth core.

Deliberately narrow: creating a NEW doc in the user's own Drive is private until
they share it (the Docs analog of draft-only email). Editing existing docs —
which may be shared with others — stays out of scope. Scope: documents.
"""

from __future__ import annotations

from .auth import Creds, request

BASE = "https://docs.googleapis.com/v1/documents"


def create(creds: Creds, title: str, text: str = "", *, client=None) -> dict:
doc = request(creds, "POST", BASE, json={"title": title}, client=client)
doc_id = doc.get("documentId", "")
if text:
request(creds, "POST", f"{BASE}/{doc_id}:batchUpdate", json={
"requests": [{"insertText": {"location": {"index": 1}, "text": text}}],
}, client=client)
return {"documentId": doc_id, "title": doc.get("title", title),
"link": f"https://docs.google.com/document/d/{doc_id}/edit"}
53 changes: 53 additions & 0 deletions gpeople.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Contacts service (People API) — read-only search. Built on the shared auth core.

Searches BOTH saved contacts and "other contacts" (addresses Google auto-collects
from past correspondence — usually where most of them live), merged and deduped.
Scopes: contacts.readonly + contacts.other.readonly.
"""

from __future__ import annotations

from .auth import Creds, request

BASE = "https://people.googleapis.com/v1"

# (endpoint, readMask) — otherContacts supports a narrower mask.
_SOURCES = (
("people:searchContacts", "names,emailAddresses,organizations"),
("otherContacts:search", "names,emailAddresses"),
)

# The People search cache wants a warmup request (empty query) before real
# queries return fresh results — Google's own recommendation. Once per token.
_WARMED: set[str] = set()


def _person(p: dict) -> dict:
names = p.get("names") or [{}]
orgs = p.get("organizations") or [{}]
return {
"name": names[0].get("displayName", ""),
"emails": [e.get("value", "") for e in (p.get("emailAddresses") or []) if e.get("value")],
"org": orgs[0].get("name", ""),
}


def search(creds: Creds, query: str, max_results: int = 10, *, client=None) -> list[dict]:
if creds.refresh_token not in _WARMED:
for path, mask in _SOURCES:
request(creds, "GET", f"{BASE}/{path}",
params={"query": "", "readMask": mask, "pageSize": 1}, client=client)
_WARMED.add(creds.refresh_token)
out, seen = [], set()
for path, mask in _SOURCES:
data = request(creds, "GET", f"{BASE}/{path}",
params={"query": query, "readMask": mask,
"pageSize": min(int(max_results), 30)}, client=client)
for r in data.get("results") or []:
person = _person(r.get("person") or {})
key = (person["name"].lower(), tuple(sorted(person["emails"])))
if key in seen or not (person["emails"] or person["name"]):
continue
seen.add(key)
out.append(person)
return out[: int(max_results)]
8 changes: 6 additions & 2 deletions oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,16 @@

AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"

# The "grow into" scope set (see README): Gmail read+draft, Calendar, Drive read.
# Override with the `oauth_scopes` config key (space-separated) before connecting.
# The "grow into" scope set (see README): Gmail read+draft+hygiene, Calendar rw,
# Drive read, Contacts read, Docs create. Override with the `oauth_scopes` config
# key (space-separated) before connecting.
DEFAULT_SCOPES = (
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/drive.readonly",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/contacts.other.readonly",
"https://www.googleapis.com/auth/documents",
)

_STATE_TTL_S = 600
Expand Down
10 changes: 5 additions & 5 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
id: google
name: Google Workspace
version: 0.4.0
version: 0.5.0
description: >-
Google Workspace for the agent, built to grow. Gmail (read, draft-only — never
sends — plus mailbox hygiene: mark read, label, archive, attachments, draft
revision), Calendar (read + free/busy + search), Drive (read) — over a
service-agnostic OAuth/REST core (no SDK) so Docs and Sheets are additive
modules, not a rewrite. Pull-mode: the agent reads
revision), Calendar (read + free/busy + search + own-calendar events, no
attendee invites), Contacts (search), Docs (create-only), Drive (read) — over a
service-agnostic OAuth/REST core (no SDK). Pull-mode: the agent reads
and drafts; a human reviews and sends. Needs a Google OAuth client + refresh token;
mint it with the full scope set you intend to grow into (e.g. gmail.modify,
calendar, drive, documents, spreadsheets). One-click connect: paste the OAuth
Expand All @@ -15,7 +15,7 @@ description: >-
enabled: false
min_protoagent_version: "0.71.0" # manifest public_paths (#1345)
capabilities:
network: [oauth2.googleapis.com, gmail.googleapis.com, www.googleapis.com]
network: [oauth2.googleapis.com, gmail.googleapis.com, www.googleapis.com, people.googleapis.com, docs.googleapis.com]
filesystem: workspace (attachment downloads)

# Console view (ADR 0026) — connection status + unread mail + upcoming events.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "google-plugin"
version = "0.4.0"
version = "0.5.0"
description = "Google Workspace plugin for protoAgent — Gmail (read + draft) + Calendar (read) over a service-agnostic OAuth/REST core, built to grow."
requires-python = ">=3.11"

Expand Down
3 changes: 3 additions & 0 deletions tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ def test_register_wires_tools_and_routers(registry):
"calendar_event_detail",
"calendar_availability",
"calendar_search",
"calendar_create_event",
"contacts_search",
"docs_create",
"drive_search",
"drive_read",
}
Expand Down
Loading
Loading