Google Workspace¶
The Google Workspace bundle currently saves Gmail drafts and previews or sends messages
through gog. Its source, tests, and setup metadata live in Clauz3's stdlib;
the separate clauz3-tools-assistant repository is no longer the maintained
source. Gmail's public imports are under tools.google_workspace.gmail.trusted.
One install provides the bundle's service domains. Each service has its own
trusted root, contracts, manifest, and conversation selection. Gmail is
google_workspace/gmail; future Sheets and Docs services will be siblings,
not separate install packages. Installing or updating the bundle does not
automatically enable services in a workspace conversation.
Google Workspace integrations belong here for now. This library implements Gmail through gog, not the separate gws CLI. Calendar, Drive, attachments, and sending an existing draft by ID are not currently supported.
Install or migrate¶
For a project with an older email copy, finish or stop active work, remove
email in Libraries (removal preserves a backup), then install Google
Workspace. The installer refuses to place the two versions side by side:
they define the same effect names. --force does not bypass this check.
For CLI migration, move .clauz3/tools/email/ outside the active tools/ tree
as a backup before installing the new bundle. Also archive the old generated
.clauz3/skills/email/ guide if present. Project-authored tools/email/ copies
must likewise be moved out of the active tools tree.
Update consumer imports from tools.email.trusted to
tools.google_workspace.gmail.trusted. Contract and effect function names
are unchanged. There is no tools.email compatibility copy. Start a new
conversation and select google_workspace/gmail; old conversation selections
and approval history are not silently rewritten.
For subsequent updates of the renamed bundle:
This updates .clauz3/tools/google_workspace/gmail/ and regenerates its API skill
under .clauz3/skills/google_workspace/gmail/. Preserve local customizations
before overwriting. In Libraries, review the update on the Google Workspace
card. Restart an existing CLI session after an
external install; saved approvals and transcripts retain their original code
and descriptions. Global copies are independent: back up and remove any old
email domain in the global store, then install with
clauz3 install --global stdlib:google_workspace --skills. Select its Gmail
service into a project with clauz3 install global:google_workspace/gmail --skills.
The library ships in the Clauz3 distribution, so installation requires no assistant-repository checkout or GitHub credentials. Installation does not install gog, authenticate Google, or grant permission to execute a program.
User setup¶
For real drafts and sends, install gog and authorize the intended account
following its quickstart. The executable
must be on the PATH used to launch Clauz3. Configure gog's OAuth client and
Gmail authorization in your own terminal/browser, and select the account using
gog's configuration or GOG_ACCOUNT. Workspace administrators may need to
permit access. Keep credentials and tokens in gog's storage; do not put them
in manifests or chat messages. Reauthorize through gog if access is revoked.
clauz3.yaml owns CLI installation and account selection requirements;
tools/google_workspace/gmail/clauz3.yaml owns Gmail-specific authorization and backend setup.
The Libraries UI and generated skills display that metadata. Installation
snapshots shared requirements beside the domain manifest; edit the source
manifests, not generated snapshots. Setup guidance is descriptive and is
never executed automatically.
Leave CLAUZ3_EMAIL_BACKEND unset, or set it to gog. The legacy pa backend
remains supported for sending only and requires its own setup. Draft creation
rejects other backends without falling back to sending. A send preview needs
neither a CLI nor Google authentication because it invokes no subprocess.
Draft, preview, or send¶
| Intent | Effect | Mailbox change |
|---|---|---|
| Save a draft | create_draft(to, subject, body) |
Creates an unsent Gmail draft |
| Preview | send_email(to, subject, body, dry_run=True) |
None; print content separately to display it |
| Deliver a new message | send_email(to, subject, body, dry_run=False) |
Sends immediately |
Both effects require to to contain @ and a nonempty subject. These checks
do not validate mailbox existence. The recipient argument is passed to the
backend unchanged; allowlists compare exact strings, not parsed addresses.
CLI errors propagate. Both effects return None; gog output, including draft
identifiers, appears in the run log. A [DRAFT] subject changes only text.
import clauz3
from tools.google_workspace.gmail.trusted import contracts as mail
from tools.google_workspace.gmail.trusted.effects import create_draft
@clauz3.guarantee(mail.drafts_only())
@clauz3.guarantee(mail.only_recipients(["alice@example.com"]))
@clauz3.guarantee(mail.emails_at_most(1))
def main() -> None:
create_draft("alice@example.com", "hello", "hello from clauz3")
Approval to create a draft does not authorize sending. Report success only after execution succeeds; do not substitute delivery if drafting fails.
Contracts¶
Recipient, count, uniqueness, and content contracts cover both draft and send calls, including previews. They cover this library only.
| Contract | Scope |
|---|---|
drafts_only() |
Forbid all send calls, including previews; allow drafts |
no_emails() |
Same send-call restriction; drafts remain allowed |
no_drafts() |
Forbid draft creation; send calls remain allowed |
dry_run_only() |
Require explicit dry_run=True on every send call |
only_recipients(allowlist) |
Restrict recipient arguments to exact strings |
unique_recipients() |
At most one call per recipient string per run |
emails_at_most(count) |
Cap combined draft and send calls per run |
subject_must_contain(marker) |
Require a case-sensitive subject substring |
subject_length_at_most(n) |
Limit each subject's character count |
body_length_at_most(n) |
Limit each body's character count |
no_body_contains(text) |
Forbid a literal body substring |
no_subject_contains(text) |
Forbid a literal subject substring |
no_guarantees() |
Impose no email restriction |
Combine dry_run_only() with no_drafts() for previews without mailbox writes.
Combine no_emails() and no_drafts() to prohibit both effects. Defaults are
not recorded in effect facts: always pass dry_run explicitly when stating
dry_run_only(). Runtime defaults still prevent delivery if omitted.
Every public function has a docstring for tool discovery and generated skills. Contract description templates supply the prose beside Python expressions in approvals; expanded details show the scope docstring.
Tests and maintenance¶
clauz3 test stdlib:google_workspace
# From the Clauz3 source checkout:
just -f src/clauz3/stdlib/google_workspace/tests/Justfile test
Proof fixtures are never executed. Runtime tests mock all subprocess calls:
they do not send mail or create real drafts. Signature regressions verify that
the selected library loads even when a program does not import email.
The repository's just stdlib runs this suite alongside the other libraries.
Edit src/clauz3/stdlib/google_workspace/ and reinstall stdlib:google_workspace to refresh a
consumer project's copy. No separate repository needs to be synchronized.
Source¶
src/clauz3/stdlib/google_workspace/tools/google_workspace/gmail/trusted/effects.py
"""Trusted email sending, previews, and Gmail draft creation through gog.
``create_draft`` saves an unsent message in Gmail. ``send_email`` is a no-op
by default and delivers a new message only with ``dry_run=False``; it does
not send an existing draft. The gog backend requires an authenticated Gmail
account. The legacy pa backend supports sending only.
``clauz3.requires`` declares argument checks, and ``clauz3.trusted`` marks
the effect boundary. Recipient, count, content, and delivery-mode guarantees
are supplied separately by this library's ``contracts`` module.
"""
import os
import subprocess
import deal
import clauz3
DEFAULT_BACKEND = "gog"
@clauz3.requires(
lambda to, subject, body, *, dry_run=True: "@" in to,
message='to must contain "@"',
)
@clauz3.requires(
lambda to, subject, body, *, dry_run=True: len(subject) > 0,
message="subject must be non-empty",
)
@deal.has(
"net",
"send",
"syscall",
"stdout",
"stderr",
"global",
"import",
)
@clauz3.trusted
def send_email(
to: str,
subject: str,
body: str,
*,
dry_run: bool = True,
) -> None:
"""Send immediately when ``dry_run=False``; never creates a saved draft.
For a message in Gmail's Drafts folder, use ``create_draft`` instead.
A ``[DRAFT]`` subject is only text, not a delivery mode.
``to`` is passed to the backend as its recipient argument and must
contain "@"; ``subject`` must be nonempty. ``body`` is passed unchanged
and may be empty. These checks do not validate that an address exists.
When ``dry_run=True``, the body returns immediately and no subprocess
is invoked. When ``dry_run=False``, the body shells out to
``gog gmail send`` (default) or another CLI selected by
``CLAUZ3_EMAIL_BACKEND``.
The contract layer's ``dry_run_only()`` demands the kwarg be passed
explicitly at every call site; relying on the default fails closed.
A preview returns None without printing the proposed message. Print its
content separately if it should be shown to the user. A real send also
returns None; CLI output goes to the run log and CLI failures propagate.
This creates a new message, rather than delivering or deleting an
existing Gmail draft.
"""
if dry_run:
return
backend = os.environ.get("CLAUZ3_EMAIL_BACKEND", DEFAULT_BACKEND)
if backend == "gog":
subprocess.run(
["gog", "gmail", "send", "--to", to, "--subject", subject, "--body", body],
check=True,
)
return
if backend == "pa":
subprocess.run(
["pa", "email", "send", "--to", to, "-s", subject, "-b", body],
check=True,
)
return
raise ValueError(
f"unsupported CLAUZ3_EMAIL_BACKEND={backend!r}; supported: 'gog', 'pa'"
)
@clauz3.requires(lambda to, subject, body: "@" in to, message='to must contain "@"')
@clauz3.requires(
lambda to, subject, body: len(subject) > 0,
message="subject must be non-empty",
)
@deal.has(
"net",
"send",
"syscall",
"stdout",
"stderr",
"global",
"import",
)
@clauz3.trusted
def create_draft(to: str, subject: str, body: str) -> None:
"""Save an unsent email in Gmail's Drafts folder; never sends the email.
Use for requests to draft an email, including "send a draft". Pair with
``drafts_only()``, ``only_recipients([...])`` and ``emails_at_most(1)``.
``to`` must contain "@" and ``subject`` must be nonempty. ``body`` is
passed unchanged and may be empty. No attachments are accepted.
This is a real mailbox write, not a dry-run preview. The gog CLI reports
the created draft's identifiers on stdout; this function returns None,
not the draft ID. Errors propagate to the run.
Only the gog backend supports drafts. Other backends fail before executing
a command, without falling back to sending.
"""
backend = os.environ.get("CLAUZ3_EMAIL_BACKEND", DEFAULT_BACKEND)
if backend != "gog":
raise ValueError(
f"create_draft requires CLAUZ3_EMAIL_BACKEND='gog', got {backend!r}"
)
subprocess.run(
[
"gog",
"gmail",
"drafts",
"create",
"--no-input",
"--to=" + to,
"--subject=" + subject,
"--body=" + body,
],
check=True,
)
src/clauz3/stdlib/google_workspace/tools/google_workspace/gmail/trusted/contracts.py
"""Email send and draft policies for the assistant domain.
Recipient, count, uniqueness and content policies cover both ``send_email``
and ``create_draft``. ``drafts_only()`` forbids send calls. ``dry_run_only()``
constrains send calls to explicit previews; combine it with ``no_drafts()``
to forbid mailbox writes too. Each policy covers only this email domain.
"""
from clauz3.spec import ContractSpec, contract, effect
from clauz3.spec import no_guarantees as core_no_guarantees
Email = effect("send_email", "create_draft")
Send = effect("send_email")
Draft = effect("create_draft")
@contract(description="No restrictions are imposed on email tools.")
def no_guarantees() -> ContractSpec:
"""Impose no restrictions on email sending, previews, or draft creation.
This is an explicit unrestricted email policy, not a read-only or preview
policy. Other contracts in the same program still apply. It does not
constrain effects from other libraries.
"""
return core_no_guarantees()
@contract(
description="Email send calls are previews only; saving drafts is still allowed."
)
def dry_run_only() -> ContractSpec:
"""Guarantee every send_email call passes ``dry_run=True`` explicitly.
Discharges only when every call site passes the kwarg explicitly; agents that
rely on the runtime default fail closed (the recorded effect fact
does not include default kwarg values, so ``e.dry_run`` is an
unknown field). This covers sending only; add ``no_drafts()`` to
forbid saving drafts as well.
"""
return Send.all(lambda e: e.dry_run == True) # noqa: E712 -- symbolic equality
@contract(
description=(
"Email tools cannot send messages or preview sends; saving drafts is allowed."
)
)
def no_emails() -> ContractSpec:
"""Forbid send_email calls, including dry runs; saved drafts are allowed.
This has the same effect restriction as ``drafts_only()``. Add
``no_drafts()`` to forbid both send and draft calls. The name does not
imply that all mailbox writes or other libraries' effects are forbidden.
"""
return Send.empty()
@contract(
description=(
"Email tools address sent messages and saved drafts only to {allowlist}."
)
)
def only_recipients(allowlist: list[str]) -> ContractSpec:
"""Restrict sent and draft recipient arguments to exact allowlisted strings.
Every ``send_email`` (including previews) and ``create_draft`` call must
pass a ``to`` value present in ``allowlist``. An empty list forbids both
kinds of call. Matching is case-sensitive and does not parse address
lists, resolve aliases, or normalize display names. Supply individual
addresses as the allowed strings when restricting individual recipients.
"""
return Email.all(lambda e: e.to in allowlist)
@contract(description="Email tools make at most one send or draft call per recipient.")
def unique_recipients() -> ContractSpec:
"""Allow at most one send or draft call per recipient argument per run.
Uniqueness is checked across both effects on every reachable execution
path, including send previews. Drafting and then sending to the same
``to`` string in one run violates this contract. Comparison uses exact
strings, not resolved mailbox identities, and does not span separate runs.
"""
return Email.distinct(lambda e: e.to)
@contract(description="Email tools make at most {count} send or draft calls in total.")
def emails_at_most(count: int) -> ContractSpec:
"""Cap the combined number of send_email and create_draft calls per run.
Send previews count too, although they do not change the mailbox. Zero
forbids both effects. This is an upper bound on calls, not a promise of
successful delivery or draft creation; it does not limit other libraries.
"""
return Email.count() <= count
@contract(description="Every sent message or saved draft has {marker} in its subject.")
def subject_must_contain(marker: str) -> ContractSpec:
"""Guarantee every subject contains ``marker``.
Applies to sent emails and saved drafts. This checks text only; a
``[DRAFT]`` subject never makes sending behave like saving a draft.
Use ``create_draft`` with ``drafts_only()`` for Gmail drafts.
Matching is case-sensitive and includes previews. An empty marker adds
no restriction. Other subject text remains unconstrained.
"""
return Email.all(lambda e: marker in e.subject)
@contract(
description=(
"Every sent message or saved draft has at "
"most {max_chars} characters in its subject."
)
)
def subject_length_at_most(max_chars: int) -> ContractSpec:
"""Limit each sent or draft subject to ``max_chars`` Python characters.
Includes send previews. The bound uses ``len(subject)``, not encoded
bytes or the combined size of all messages. It does not restrict the
number of messages, their recipients, or their bodies.
"""
return Email.all(lambda e: len(e.subject) <= max_chars)
@contract(
description=(
"Every sent message or saved draft has "
"at most {max_chars} characters in its body."
)
)
def body_length_at_most(max_chars: int) -> ContractSpec:
"""Limit each sent or draft body to ``max_chars`` Python characters.
Includes send previews. The bound uses ``len(body)``, not encoded bytes
or the combined size of all messages. It does not restrict the number
of messages, their recipients, or their subjects.
"""
return Email.all(lambda e: len(e.body) <= max_chars)
@contract(
description=(
"No sent message or saved draft contains "
"the literal text {substring} in its body."
)
)
def no_body_contains(substring: str) -> ContractSpec:
"""Forbid the literal ``substring`` in sent and draft bodies, including previews.
Matching is case-sensitive and applies to the original body argument.
This excludes only the specified text, not encoded variants or arbitrary
secrets. Subjects are unrestricted by this helper; use
``no_subject_contains`` to constrain them too.
"""
return Email.all(lambda e: substring not in e.body)
@contract(
description=(
"No sent message or saved draft contains "
"the literal text {substring} in its subject."
)
)
def no_subject_contains(substring: str) -> ContractSpec:
"""Forbid the literal ``substring`` in sent and draft subjects, including previews.
Matching is case-sensitive and applies to the original subject argument.
This excludes only the specified text, not encoded variants or arbitrary
secrets. Bodies are unrestricted by this helper; use ``no_body_contains``
to constrain them too.
"""
return Email.all(lambda e: substring not in e.subject)
@contract(
description=(
"Email tools may save drafts; sending "
"messages and previewing sends are forbidden."
)
)
def drafts_only() -> ContractSpec:
"""Allow saved drafts but forbid every send_email call, including dry runs.
This covers the email library only; other libraries need their own
guarantees. Use create_draft to save a real message in Gmail Drafts.
"""
return Send.empty()
@contract(
description="Email tools cannot save drafts; sending messages is still allowed."
)
def no_drafts() -> ContractSpec:
"""Forbid saving Gmail drafts through create_draft.
Send calls remain allowed, including real sends. Combine with
``dry_run_only()`` for send previews with no mailbox changes through
these email tools, or with ``no_emails()`` to forbid both effects.
Effects from other libraries are not covered.
"""
return Draft.empty()
All cases¶
Browse the full set on the all-cases page.