THE WORLD IS NOT STANDING STILL.RSS
BIG CHANGE.
BIG CHANGE, one tap away
BIG CHANGE, one tap away

Make room for the big picture.

Add BIG CHANGE to your home screen or desktop. It opens in its own window, with the latest stories when you’re online.

iPhone & iPad
Open bigchange.ai in Safari. Tap Share, then Add to Home Screen. Turn on Open as Web App if shown, then tap Add.
Android
Open bigchange.ai in Chrome. In the browser menu, choose Install app or Add to Home screen.
Desktop
In Chrome or Edge, use the install icon in the address bar or the browser’s app menu. In Safari on Mac, choose File → Add to Dock.

Markdown edition

# Draft and check three outreach emails from one company page

> Prepare three unsent emails from one company page and approved offer; check claims and the recipient before saving.

By BIGCHANGE.AI Editorial

Published: 2026-10-26T19:19:13.754Z
Updated: 2026-10-26T19:19:13.754Z
Canonical: https://bigchange.ai/blog/gemini-company-page-three-outreach-drafts-guide

![Conceptual charcoal illustration of one source page behind three separate unsent draft sheets on a supported reading stand.](https://bigchange.ai/api/media/file/company-outreach-review-hero-v2.png)
AI-generated conceptual illustration by BIG CHANGE.

This guide prepares three unsent outreach emails from one company page. You supply the actual prospect, an approved offer and a goal. A local Python script prepares an introduction, follow-up and final check-in. It asks the Gemini API to read the specified URL, then saves retrieval status, citations and a blank human review record. It does not save the entire company page. You open the page and check every claim before keeping any draft. The procedure follows documentation checked 26 October 2026; BIG CHANGE did not run a paid request or test a real prospect.

## The big change

- **What changed:** Gemini URL context can retrieve a supplied public page for a drafting request.
- **Why it matters:** The script ties source citations to the exact message field and saves three ordered, unsent drafts.
- **What to watch:** A citation is a review lead, not proof that a claim is true. Keep or hold each message after checking the page, offer and recipient record.

## Before you start

This setup uses **Windows, PowerShell 7.3 or later, and Python 3.11 with the Windows `py` launcher**. You also need an exact public HTTPS company page you have opened, a prospect name you are authorized to use, an approved outreach goal and a written approved offer. Use only information you may send to Google. Do not put private pages, secret URLs or unapproved prospect details in the request. The script checks the URL's basic public shape; you must confirm its company identity and sharing rights yourself.

For a named prospect, use an **authorized Gemini API Paid Services project** with an active Cloud Billing account linked to the exact project that owns your API key. Confirm the project and billing status in [Google AI Studio and Cloud Billing](https://ai.google.dev/gemini-api/docs/api-key) before you use `--paid-project-confirmed`. That switch records your check; it does not inspect your account. The actual API user must be at least 18, use the API for a professional or business purpose, and be in a [supported region](https://ai.google.dev/gemini-api/docs/available-regions). If access fails, check [Google's access guide](https://ai.google.dev/gemini-api/docs/troubleshoot-ai-studio). Google's [terms](https://ai.google.dev/gemini-api/terms) bar personal or confidential information in Unpaid Services.

Check the [current model and URL-context pricing](https://ai.google.dev/gemini-api/docs/pricing), your project's budget and usage before a run. Retrieved page text counts as input tokens. Google may use its index or a live fetch; nested links are not followed. Dynamic-page completeness and recipient consent remain unverified. Hold on paywall, unsafe or failed retrieval, or unsupported claims.

## 1. Prepare a private folder and offer brief

Open PowerShell 7.3+ on Windows in a new private folder. Save the complete `sequence.py` program in Step 2 there. Create `approved-offer.txt` with only your authorized sender identity, exact product or service, substantiated benefits, permitted call to action and excluded claims. Keep it out of source control. Do not use a made-up offer or treat a generic template as approval. The script reads this file, sends its contents in the request and saves it in the local review record; the file must contain 1–3000 characters.

```plaintext
Sender identity: ____
Exact approved offer: ____
Evidence for benefits: ____
Permitted call to action: ____
Claims to exclude: ____
Approved by / date: ____
```

## 2. Save the full program

Copy this entire block into `sequence.py`, preserving indentation and every character. It makes one URL-context-only request. It has no email connection or send operation.

```python
"""One URL-grounded, three-message UN-SENT outreach draft; documentation example.

Python 3.11+; google-genai==2.33.0; GEMINI_API_KEY. No send operation.
"""

import argparse
from datetime import datetime, timezone
import ipaddress
import json
from json.decoder import scanstring
import os
from pathlib import Path
from urllib.parse import urlparse
from uuid import uuid4


MODEL = "gemini-3.8-flash"
STAGES = ("introduction", "follow_up", "final_check_in")


def check_public_url(value):
    url = urlparse(value)
    host = (url.hostname or "").lower()
    if url.scheme != "https" or not host or url.username or url.password or url.fragment:
        raise SystemExit("Supply one exact public HTTPS company page, without credentials or fragment.")
    if host == "localhost" or host.endswith((".localhost", ".local", ".internal")):
        raise SystemExit("Local or internal company URL is unsupported.")
    try:
        if not ipaddress.ip_address(host).is_global:
            raise SystemExit("Private or reserved IP address is unsupported.")
    except ValueError:
        pass
    return value


def cite_blocks(response):
    blocks = []
    for si, step in enumerate(response.steps or []):
        if getattr(step, "type", None) != "model_output":
            continue
        for bi, block in enumerate(getattr(step, "content", None) or []):
            if getattr(block, "type", None) != "text":
                continue
            body = getattr(block, "text", None)
            if not isinstance(body, str):
                raise ValueError("Model text block is missing.")
            encoded = body.encode("utf-8")
            citations = []
            for citation in getattr(block, "annotations", None) or []:
                if getattr(citation, "type", None) != "url_citation":
                    continue
                start = getattr(citation, "start_index", None)
                end = getattr(citation, "end_index", None)
                url = getattr(citation, "url", None)
                if (type(start) is not int or type(end) is not int
                        or not 0 <= start < end <= len(encoded)
                        or not isinstance(url, str)):
                    raise ValueError("Citation lacks an exact URL and valid UTF-8 byte span.")
                try:
                    segment = encoded[start:end].decode("utf-8")
                except UnicodeDecodeError as exc:
                    raise ValueError("Citation splits a UTF-8 character.") from exc
                citations.append({"url": url, "title": getattr(citation, "title", None),
                                  "startByte": start, "endByte": end, "segment": segment})
            blocks.append({"stepIndex": si, "blockIndex": bi,
                           "text": body, "citations": citations})
    return blocks


def retrieve(response):
    records = []
    for step in response.steps or []:
        if getattr(step, "type", None) == "url_context_result":
            records.append({
                "callId": getattr(step, "call_id", None),
                "isError": getattr(step, "is_error", None),
                "results": [{"url": getattr(item, "url", None),
                             "status": getattr(item, "status", None)}
                            for item in getattr(step, "result", None) or []],
            })
    return records


def validate_messages(raw):
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise ValueError("Model output is not a JSON object.") from exc
    if not isinstance(data, dict) or set(data) != {"messages"}:
        raise ValueError("Expected only a messages array.")
    messages = data["messages"]
    if not isinstance(messages, list) or len(messages) != 3:
        raise ValueError("Expected exactly three messages.")
    for i, message in enumerate(messages):
        if (not isinstance(message, dict) or set(message) != {"stage", "subject", "body"}
                or message["stage"] != STAGES[i]
                or any(not isinstance(message[k], str) or not message[k].strip()
                       for k in ("subject", "body"))):
            raise ValueError("Each ordered stage needs a nonempty subject and body.")
    return messages


def json_string_byte_ranges(raw):
    """Parse the original JSON and locate value-string bytes by structural path.

    These are positions in the *raw provider block*, including any escaped JSON
    spelling. A repeated decoded phrase elsewhere has no bearing on attribution.
    """
    spans = {}
    size = len(raw)
    decoder = json.JSONDecoder()

    def space(pos):
        while pos < size and raw[pos].isspace():
            pos += 1
        return pos

    def string(pos):
        if pos >= size or raw[pos] != '"':
            raise ValueError("Expected a JSON string.")
        try:
            value, end = scanstring(raw, pos + 1, True)
        except ValueError as exc:
            raise ValueError("Invalid JSON string escape.") from exc
        return value, end

    def value(pos, path):
        pos = space(pos)
        if pos >= size:
            raise ValueError("Incomplete JSON value.")
        if raw[pos] == '"':
            item, end = string(pos)
            spans[path] = (
                len(raw[:pos + 1].encode("utf-8")),
                len(raw[:end - 1].encode("utf-8")),
            )
            return item, end
        if raw[pos] == '{':
            obj = {}
            pos = space(pos + 1)
            if pos < size and raw[pos] == '}':
                return obj, pos + 1
            while True:
                key, pos = string(pos)
                pos = space(pos)
                if pos >= size or raw[pos] != ':':
                    raise ValueError("Expected JSON colon.")
                item, pos = value(pos + 1, path + (key,))
                if key in obj:
                    raise ValueError("Duplicate JSON key cannot be attributed safely.")
                obj[key] = item
                pos = space(pos)
                if pos < size and raw[pos] == '}':
                    return obj, pos + 1
                if pos >= size or raw[pos] != ',':
                    raise ValueError("Expected JSON object separator.")
                pos = space(pos + 1)
        if raw[pos] == '[':
            items = []
            pos = space(pos + 1)
            if pos < size and raw[pos] == ']':
                return items, pos + 1
            while True:
                item, pos = value(pos, path + (len(items),))
                items.append(item)
                pos = space(pos)
                if pos < size and raw[pos] == ']':
                    return items, pos + 1
                if pos >= size or raw[pos] != ',':
                    raise ValueError("Expected JSON array separator.")
                pos = space(pos + 1)
        try:
            return decoder.raw_decode(raw, pos)
        except json.JSONDecodeError as exc:
            raise ValueError("Invalid JSON scalar.") from exc

    parsed, end = value(0, ())
    if space(end) != size:
        raise ValueError("Trailing JSON text cannot be attributed safely.")
    return parsed, spans


def analyze(response, company_url):
    """Keep a hold artifact on any evidence or structure failure."""
    result = {
        "interactionId": getattr(response, "id", None),
        "interactionStatus": getattr(response, "status", None),
        "usage": (response.usage.model_dump(exclude_none=True)
                  if hasattr(getattr(response, "usage", None), "model_dump") else None),
        "retrieval": retrieve(response),
        "textBlocks": [],
        "rawDraft": getattr(response, "output_text", None),
        "messages": None,
        "review": {stage: {"companyClaimsChecked": [], "offerClaimsChecked": [],
                           "recipientRoleChecked": False, "approved": False} for stage in STAGES},
        "ownerReviewed": False,
        "state": "held",
        "holdReasons": [],
    }
    reasons = result["holdReasons"]
    if result["interactionStatus"] != "completed" or not result["interactionId"]:
        reasons.append("Interaction did not complete with an ID.")
    if result["usage"] is None:
        reasons.append("Usage information is absent; inspect project usage.")
    retrieval = result["retrieval"]
    if (len(retrieval) != 1 or retrieval[0]["isError"] is True
            or len(retrieval[0]["results"]) != 1
            or retrieval[0]["results"][0] != {"url": company_url, "status": "success"}):
        reasons.append("Selected company URL was not uniquely retrieved with exact URL and success status.")
    try:
        blocks = cite_blocks(response)
        result["textBlocks"] = blocks
        if len(blocks) != 1 or not blocks[0]["text"].strip():
            raise ValueError("Expected one complete, nonempty text block for the JSON draft.")
        if not blocks[0]["citations"]:
            raise ValueError("Draft has no URL citations.")
        if any(c["url"] != company_url for c in blocks[0]["citations"]):
            raise ValueError("Citation is for a URL other than the selected company page.")
        result["messages"] = validate_messages(blocks[0]["text"])
        parsed, field_ranges = json_string_byte_ranges(blocks[0]["text"])
        if parsed != {"messages": result["messages"]}:
            raise ValueError("Parsed message fields differ from the original JSON text.")
        result["messageFieldByteRanges"] = {
            STAGES[index]: {
                field: {"startByte": field_ranges[("messages", index, field)][0],
                        "endByte": field_ranges[("messages", index, field)][1]}
                for field in ("subject", "body")
            } for index in range(3)
        }
        result["messageCitations"] = {stage: [] for stage in STAGES}
        for citation in blocks[0]["citations"]:
            locations = [(stage, field) for stage, fields in result["messageFieldByteRanges"].items()
                         for field, span in fields.items()
                         if span["startByte"] <= citation["startByte"]
                         and citation["endByte"] <= span["endByte"]]
            if len(locations) != 1:
                raise ValueError("Citation is not bound to exactly one original message field.")
            stage, field = locations[0]
            result["messageCitations"][stage].append({**citation, "field": field})
        # This mapping is a review aid; the human checks every claim and source.
    except ValueError as exc:
        reasons.append(str(exc))
    if not reasons:
        result["state"] = "draft_for_human_review"
    return result


def save_exclusive(path, payload):
    path.parent.mkdir(parents=True, exist_ok=True)
    body = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
    try:
        with path.open("x", encoding="utf-8") as out:
            out.write(body)
        return path
    except FileExistsError:
        # Preserve the paid result without replacing a concurrently created file.
        recovery = path.with_name(path.name + ".recovered-" + uuid4().hex + ".json")
        with recovery.open("x", encoding="utf-8") as out:
            out.write(body)
        return recovery


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--prospect", required=True)
    parser.add_argument("--company-url", required=True)
    parser.add_argument("--goal", required=True)
    parser.add_argument("--approved-offer-file", required=True, type=Path)
    parser.add_argument("--paid-project-confirmed", action="store_true")
    parser.add_argument("--output", type=Path, default=Path("sequence-review.json"))
    args = parser.parse_args()
    company_url = check_public_url(args.company_url)
    if not args.prospect.strip() or not args.goal.strip():
        raise SystemExit("Prospect and approved goal are required.")
    if not args.paid_project_confirmed:
        raise SystemExit("Confirm this key belongs to an authorized project with active Cloud Billing before using a named prospect.")
    offer = args.approved_offer_file.read_text(encoding="utf-8").strip()
    if not offer or len(offer) > 3000:
        raise SystemExit("Approved offer brief must contain 1–3000 characters.")
    if args.output.exists():
        raise SystemExit("Output exists; choose a new name before any paid request.")
    key = os.environ.get("GEMINI_API_KEY", "").strip()
    if not key:
        raise SystemExit("Set GEMINI_API_KEY first.")
    from google import genai

    response = genai.Client(api_key=key).interactions.create(
        model=MODEL,
        input=(
            "Read only the exact public company page: " + company_url + "\n"
            "Draft exactly three unsent email messages for prospect " + args.prospect + ".\n"
            "Approved goal: " + args.goal + "\n"
            "Approved sender offer/claims (do not add facts): " + offer + "\n"
            "Return one JSON object: {\"messages\":[{\"stage\":\"introduction\",\"subject\":\"...\",\"body\":\"...\"},"
            "{\"stage\":\"follow_up\",\"subject\":\"...\",\"body\":\"...\"},"
            "{\"stage\":\"final_check_in\",\"subject\":\"...\",\"body\":\"...\"}]}. "
            "Cite source-specific company claims. Use only verified page facts and approved offer claims; "
            "leave unknown personal details as placeholders. Do not invent role, relationship, consent or results."
        ),
        tools=[{"type": "url_context"}],
        store=False,
    )
    result = analyze(response, company_url)
    result.update({"createdAtUtc": datetime.now(timezone.utc).isoformat(),
                   "model": MODEL, "companyUrl": company_url,
                   "prospect": args.prospect, "goal": args.goal,
                   "approvedOfferBrief": offer, "paidProjectConfirmedByOperator": True,
                   "sent": False})
    saved = save_exclusive(args.output, result)
    print(f"Saved {result['state']} to {saved}. Review each claim, recipient detail and message; nothing was sent.")


if __name__ == "__main__":
    main()
```

## 3. Install, select the key and run once

The example pins the officially released `google-genai==2.33.0` used for this reviewed code. Check the [release](https://github.com/googleapis/python-genai/releases/tag/v2.33.0) and current documentation before adapting the program. On Windows, in your private PowerShell folder, run these commands. [Python's virtual-environment guide](https://docs.python.org/3.11/library/venv.html) supports calling the environment's interpreter directly; activation is unnecessary.

```powershell
py -3.11 -m venv .venv
.\.venv\Scripts\python.exe -m pip install "google-genai==2.33.0"
.\.venv\Scripts\python.exe -c "import importlib.metadata as m; print(m.version('google-genai'))"
$env:GEMINI_API_KEY = Read-Host 'Gemini API key' -MaskInput
notepad approved-offer.txt
.\.venv\Scripts\python.exe sequence.py --prospect "KNOWN NAME" --company-url "https://COMPANY.example/about" --goal "YOUR APPROVED OUTREACH GOAL" --approved-offer-file approved-offer.txt --paid-project-confirmed --output sequence-review.json
```

Replace every placeholder with your real, permitted input. Open the exact company page first. The explicit `api_key` argument makes the script use the `GEMINI_API_KEY` you just chose, even if a separate `GOOGLE_API_KEY` exists. The key is not printed or written to the output. Keep the offer file and review JSON private because they contain your input and model output.

Choose a fresh output name for each request. If the named file already exists, the script stops before the call. If another process creates it during the call, the script preserves that file and writes the paid result to a unique `.recovered-...json` file. Read the printed path. If the call fails or its outcome is uncertain, inspect project usage and any saved file before trying again; a failed request can still incur usage. An output write can also fail for ordinary disk or permission reasons.

The request sets `store=False`, which opts out of stored Interaction objects. Google's [Interactions documentation](https://ai.google.dev/gemini-api/docs/interactions-overview) describes a default storage period of one day on free and 55 days on paid, with paid controls. Separately, Google's Paid Services terms say prompts and responses are not used for product improvement while limited safety and security logging and applicable disclosures remain. `store=False` is not a zero-processing or zero-retention promise. This call uses URL context, not Google Search grounding.

## 4. Decide whether the output is usable

Open the printed JSON file. If `state` is `held`, read `holdReasons` and stop. Provider evidence must show one `success` retrieval for the exact URL and a cited text block. The program also requires a completed ID and usage, plus three ordered nonempty subject/body pairs: `introduction`, `follow_up` and `final_check_in`. Its original-JSON parser rejects duplicate keys and binds each citation's UTF-8 byte span to one field; repeated text receives no borrowed attribution. Malformed output stays held. No live success is promised.

`state: draft_for_human_review` means only that these structural checks passed. It does not approve a fact, a prospect's role, contact eligibility or sending. The file retains the raw text, retrieval status, citation segments, interaction ID and usage so you can inspect what happened. A citation attached to a field still needs an open-page check.

## 5. Check each message and keep or hold it

Open the chosen company page again. For **each** subject and body, compare every company-specific statement with both its saved `messageCitations` segment and the live page. Write the checked claim and URL in that stage's `companyClaimsChecked`. Compare every sender and offer claim with the actual approved offer, then fill `offerClaimsChecked`. Check the named person's role, interests, relationship and contact eligibility in your own authorized records; set `recipientRoleChecked` only after that check. Remove invented personalization, results, consent or past contact. If a fact cannot be checked, hold that message and record why.

The three `approved` flags and `ownerReviewed` begin false. Set a stage's approval true only after reviewing the entire message; set `ownerReviewed` true only when the whole record has been checked. Save a separate `sequence-approved.json` with the checked text, flags, reviewer, date and any hold reasons. Keep the original result as evidence. A held stage stays unapproved. This produces **unsent drafts**. Any later sending decision belongs to a separate authorized workflow.

```plaintext
Company page opened / date: ____
Project usage checked: ____
Introduction: approved / held
Company claims + page lines: ____
Offer claims + brief lines: ____
Recipient record check: ____
Reviewer / date / reason: ____
Follow-up: approved / held
Company claims + page lines: ____
Offer claims + brief lines: ____
Recipient record check: ____
Reviewer / date / reason: ____
Final check-in: approved / held
Company claims + page lines: ____
Offer claims + brief lines: ____
Recipient record check: ____
Reviewer / date / reason: ____
Saved unsent text path: ____
```

The related [Notion, HubSpot and Gmail guide](https://bigchange.ai/blog/notion-hubspot-gmail-checked-follow-up-draft-guide) checks one existing CRM thread and draft. This procedure starts from one company page and prepares three separate unsent messages.

## Sources

- [Google, Gemini API URL context](https://ai.google.dev/gemini-api/docs/url-context) — Documents retrieval, citations, limits and token cost; readers check company facts.
- [Google, Gemini API Additional Terms](https://ai.google.dev/gemini-api/terms) — Defines user and regional conditions, Paid Services project linkage, personal-data restrictions and paid data treatment.
- [Google, Interactions API overview](https://ai.google.dev/gemini-api/docs/interactions-overview) — Explains interaction storage defaults and the store=false control, distinct from residual safety logging.
- [Google, Gemini API key guide](https://ai.google.dev/gemini-api/docs/api-key) — Documents project-linked keys and explicit key selection when multiple environment variables exist.
- [Google, available regions](https://ai.google.dev/gemini-api/docs/available-regions) — Check whether the actual operator's location can use the service.
- [Google, AI Studio access troubleshooting](https://ai.google.dev/gemini-api/docs/troubleshoot-ai-studio) — Explains age, region, terms and project access failures.
- [Google, Gemini Developer API pricing](https://ai.google.dev/gemini-api/docs/pricing) — Inspect current model charges and project usage before a paid request; no fixed run cost is promised.
- [Google, python-genai v2.33.0 release](https://github.com/googleapis/python-genai/releases/tag/v2.33.0) — Official release for the pinned example; actual call results were not observed.
- [Python, virtual environments](https://docs.python.org/3.11/library/venv.html) — Documents direct invocation of a Windows environment's Python interpreter without activating it.
- [Google, tagged Python SDK Interaction types](https://raw.githubusercontent.com/googleapis/python-genai/v2.33.0/google/genai/_gaos/types/interactions/interaction.py) — Defines the Interaction response contract; companion tagged citation, text-output and URL-result types are grouped as one SDK work.