Skip to content

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

clauz3 install stdlib:google_workspace --skills

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:

clauz3 install stdlib:google_workspace --force --skills

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.