An approved topic can become a LinkedIn draft and visual before anyone publishes it. This guide gives a social editor a reproducible handoff: check the owner’s facts, have Claude Code draft one post, generate a PNG with Google’s current Nano Banana 2.1 image model, then put both into a ClickUp task for a named reviewer. The task is the endpoint. No step posts to LinkedIn. The procedure follows official documentation checked on 26 October 2026; BIG CHANGE ran local syntax checks on the reference files, but did not run either model or use a ClickUp account.

The big change

  • What changed: A single reviewed fact list can drive a post draft and a visual, with their exact file hashes carried into a ClickUp review handoff.
  • Why it matters: A social editor can give a named reviewer the actual PNG, post text and source links in one task. The reviewer still has to inspect every claim and image detail before any publishing decision.
  • What to watch: The handoff is complete only after the task’s List, status, assignee, visibility, description and attachment have been checked in ClickUp. An API filename readback alone cannot establish that the reviewer saw the approved image bytes.

1. Check the facts, rights and accounts

Start with a real, owner-approved topic. For each fact you might use, record its exact wording, primary-source URL, UTC check time and permission to reuse it in both a LinkedIn post and generated visual. Confirm that the owner permits sending those facts to Anthropic and Google under the accounts’ applicable data terms. Keep confidential or unlicensed material out of the input. This article supplies templates with placeholders, not a sample campaign that should be published.

You need Python 3.10 or newer, Claude Code’s current CLI with API authentication for --bare, and the current google-genai and requests Python packages. As checked on 26 October 2026, google-genai 2.33.0 and requests 2.34.2 both require Python 3.10 or newer; check the metadata again when installing a later release. Install the packages in an owned virtual environment, using your organization’s approved installation process:

PowerShell
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install --upgrade google-genai requests

Confirm that claude --version and python --version run in that environment. Set ANTHROPIC_API_KEY, GEMINI_API_KEY and, before handoff, CLICKUP_TOKEN in the private process environment according to your organization’s secret-handling procedure. Never paste keys into the three reference files or a shared task. The ClickUp token must belong to a user with access to the intended List. ClickUp documents personal tokens for personal use and OAuth for shared integrations. Get the exact numeric List ID, reviewer user ID, reviewer name and the List’s review-status spelling from that account. Confirm that the reviewer can see the List and that its visibility is appropriate for the source links and image. If its task creation requires custom fields the script cannot provide, stop and have the List owner define an authorized creation procedure.

Check the account’s Claude model, usage and spend controls and Google’s live image price and quota before prepare. Google lists a standard Nano Banana 2.1 1K image equivalent at $0.0336 per image, plus input and text-output charges. A request can produce costs and responses that differ by account and settings. Claude Code’s total_cost_usd in JSON is an estimate; this guide makes no timed-run or savings claim. Check ClickUp plan, API and storage limits before handoff. The reference code has no explicit retry loop for ClickUp writes after a 429 or uncertain result; a failed model preparation can still incur usage, including any provider or CLI internal retries.

2. Save the three reference files

Save the next three complete blocks as input-template.json, human-checked.template.json and workflow.py in one local folder. Keep the filled input, run directory, model response and API token private. The templates are intentionally incomplete. A SHA-256 is a fingerprint of exact file bytes; matching hashes establish that the files did not change between the recorded check and handoff, but they do not establish factual accuracy or reuse rights.

input-template.json:

JSON
{
  "topic": "REPLACE_WITH_ACTUALLY_APPROVED_TOPIC",
  "reader": "REPLACE_WITH_INTENDED_LINKEDIN_READER",
  "approved_claims": [
    {
      "id": "REPLACE_WITH_UNIQUE_FACT_ID",
      "text": "REPLACE_WITH_EXACT_CHECKED_FACT",
      "source_url": "https://REPLACE_WITH_PRIMARY_SOURCE",
      "checked_at_utc": "REPLACE_WITH_ACTUAL_UTC_CHECK_TIME",
      "reuse_rights": "REPLACE_WITH_APPROVED_LINKEDIN_AND_IMAGE_USE"
    }
  ],
  "visual_style": "REPLACE_WITH_APPROVED_BRAND_PALETTE_AND_STYLE",
  "reviewer": "REPLACE_WITH_NAMED_HUMAN_REVIEWER",
  "clickup_list_id": "REPLACE_WITH_AUTHORIZED_NUMERIC_LIST_ID",
  "clickup_reviewer_user_id": "REPLACE_WITH_REVIEWER_NUMERIC_USER_ID",
  "review_status": "REPLACE_WITH_EXACT_LIST_REVIEW_STATUS",
  "owner_approved_for_linkedin": false
}

human-checked.template.json:

JSON
{
  "reviewer": "REPLACE_WITH_NAMED_PERSON",
  "reviewed_at_utc": "REPLACE_WITH_ACTUAL_TIME",
  "post_sha256": "REPLACE_WITH_SHA256_OF_POST_MD",
  "image_sha256": "REPLACE_WITH_SHA256_OF_VISUAL_PNG",
  "fact_source_rights_visual_review": "REPLACE_WITH_DOCUMENTED_DECISION",
  "approved_for_clickup_review": false
}

workflow.py:

Python
"""Reader reference: approved facts -> Claude draft + Gemini image -> ClickUp review.

No calls run when imported. `check` is local only; `prepare` incurs model charges;
`handoff` writes to ClickUp. Use a private working directory outside this packet.
"""
import argparse
import base64
import hashlib
import json
import os
from pathlib import Path
import subprocess
import sys


IMAGE_MODEL = "gemini-nano-banana-2.1"
CLAUDE_SCHEMA = {
    "type": "object",
    "properties": {
        "post": {"type": "string"},
        "claim_ids": {"type": "array", "items": {"type": "string"}},
        "review_notes": {"type": "string"},
    },
    "required": ["post", "claim_ids", "review_notes"],
    "additionalProperties": False,
}


def sha(data):
    return hashlib.sha256(data).hexdigest()


def save_new(path, data):
    with path.open("xb") as out:
        out.write(data)


def load_input(path):
    data = json.loads(path.read_text(encoding="utf-8"))
    if "REPLACE_WITH" in json.dumps(data):
        raise ValueError("Replace every template placeholder with checked owned input")
    required = ("topic", "reader", "approved_claims", "visual_style", "reviewer",
                "clickup_list_id", "clickup_reviewer_user_id", "review_status", "owner_approved_for_linkedin")
    if any(not data.get(k) for k in required):
        raise ValueError("Fill every required field in the owned input")
    if data["owner_approved_for_linkedin"] is not True:
        raise ValueError("Owner must approve use of these facts for LinkedIn")
    claims = data["approved_claims"]
    if not isinstance(claims, list) or not claims:
        raise ValueError("approved_claims must contain real checked facts")
    ids = set()
    for claim in claims:
        if not all(claim.get(k) for k in ("id", "text", "source_url", "checked_at_utc", "reuse_rights")):
            raise ValueError("Every claim needs ID, exact fact, source, check time and rights")
        if not claim["source_url"].startswith("https://") or claim["id"] in ids:
            raise ValueError("Claim source URL or unique ID invalid")
        ids.add(claim["id"])
    if not str(data["clickup_list_id"]).isdigit():
        raise ValueError("Use an authorized numeric ClickUp List ID")
    if not str(data["clickup_reviewer_user_id"]).isdigit():
        raise ValueError("Use the intended reviewer's numeric ClickUp user ID")
    return data


def prepare(source, work):
    data = load_input(source)
    if work.exists() and any(work.iterdir()):
        raise ValueError("Use a new empty private work directory for each preparation")
    work.mkdir(parents=True, exist_ok=True)
    source_bytes = source.read_bytes()
    save_new(work / "input.sha256", (sha(source_bytes) + "\n").encode())
    facts = [{k: c[k] for k in ("id", "text", "source_url")} for c in data["approved_claims"]]
    prompt = (
        "Draft ONE LinkedIn post for the named reader from ONLY the approved facts below. "
        "Make the prose useful and natural. Never invent a scene, quote, number, "
        "result, date or capability. If a necessary fact is missing, say so in "
        "review_notes. Do not post, call tools, or claim approval. Return every "
        "material fact's ID in claim_ids. Topic and reader: "
        + json.dumps({"topic": data["topic"], "reader": data["reader"], "facts": facts}, ensure_ascii=False)
    )
    if not os.environ.get("ANTHROPIC_API_KEY"):
        raise ValueError("Claude bare mode needs ANTHROPIC_API_KEY in the reader's environment")
    result = subprocess.run(
        ["claude", "--bare", "--tools", "", "--disallowedTools", "mcp__*", "-p", prompt, "--output-format", "json",
         "--json-schema", json.dumps(CLAUDE_SCHEMA, separators=(",", ":"))],
        text=True, capture_output=True, check=False, timeout=180,
    )
    save_new(work / "claude-response.json", result.stdout.encode("utf-8"))
    save_new(work / "claude-stderr.txt", result.stderr.encode("utf-8"))
    if result.returncode:
        raise RuntimeError("Claude failed; inspect authentication and stderr locally; no ClickUp write occurred")
    envelope = json.loads(result.stdout)
    draft = envelope.get("structured_output")
    if not draft or not draft.get("post") or not set(draft["claim_ids"]).issubset({x["id"] for x in facts}):
        raise ValueError("Claude returned no valid draft or an unknown claim ID")
    save_new(work / "post.md", (draft["post"].strip() + "\n").encode("utf-8"))
    save_new(work / "draft-result.json", json.dumps({
        "claim_ids": draft["claim_ids"], "review_notes": draft["review_notes"],
        "claude_estimated_cost_usd": envelope.get("total_cost_usd"),
        "source_sha256": sha(source_bytes),
    }, indent=2).encode("utf-8"))
    # Google is for the graphic only. It is not a factual source for the post.
    from google import genai
    if not os.environ.get("GEMINI_API_KEY"):
        raise ValueError("Set GEMINI_API_KEY before image generation")
    visual_facts = [c for c in facts if c["id"] in draft["claim_ids"]]
    visual_prompt = (
        "Create a single clear LinkedIn infographic or visual summary. Use only "
        "these verified facts; omit any detail you cannot render accurately. "
        "Do not depict real people, logos, charts, metrics or quotations unless "
        "explicitly present in the facts and permitted. Style: " + data["visual_style"]
        + "\nFacts: " + json.dumps(visual_facts, ensure_ascii=False)
    )
    interaction = genai.Client().interactions.create(
        model=IMAGE_MODEL, input=visual_prompt,
        response_format={"type": "image", "mime_type": "image/png", "aspect_ratio": "4:5", "image_size": "1K"},
    )
    if not interaction.output_image or not interaction.output_image.data:
        raise ValueError("Image generation returned no image; keep draft local")
    image = base64.b64decode(interaction.output_image.data, validate=True)
    if not image.startswith(b"\x89PNG\r\n\x1a\n"):
        raise ValueError("Expected PNG bytes; inspect model response")
    save_new(work / "visual.png", image)
    save_new(work / "image-result.json", json.dumps({"model": IMAGE_MODEL,
        "prompt": visual_prompt, "image_sha256": sha(image), "source_sha256": sha(source_bytes)},
        indent=2).encode("utf-8"))
    print("Prepared post.md and visual.png. Human must inspect every fact and image detail before handoff.")


def handoff(source, work):
    import requests
    data = load_input(source)
    digest = sha(source.read_bytes())
    if (work / "input.sha256").read_text().strip() != digest:
        raise ValueError("Owned facts changed; prepare a new run")
    draft = json.loads((work / "draft-result.json").read_text())
    visual = json.loads((work / "image-result.json").read_text())
    image = (work / "visual.png").read_bytes()
    if len(image) > 1_000_000_000:
        raise ValueError("Image exceeds ClickUp's documented 1 GB attachment limit")
    if draft["source_sha256"] != digest or visual["source_sha256"] != digest or sha(image) != visual["image_sha256"]:
        raise ValueError("Draft/image/source hashes differ")
    if not (work / "human-checked.json").exists():
        raise ValueError("Human reviewer must save human-checked.json before handoff")
    checked = json.loads((work / "human-checked.json").read_text())
    if checked.get("post_sha256") != sha((work / "post.md").read_bytes()) or checked.get("image_sha256") != sha(image) or checked.get("approved_for_clickup_review") is not True:
        raise ValueError("Human checked hashes or handoff decision do not match")
    if any(not checked.get(k) or "REPLACE_WITH" in str(checked[k]) for k in ("reviewer", "reviewed_at_utc", "fact_source_rights_visual_review")):
        raise ValueError("Record the actual human, check time and decision")
    token = os.environ.get("CLICKUP_TOKEN")
    if not token:
        raise ValueError("Set CLICKUP_TOKEN in the reader's environment")
    headers = {"Authorization": token}
    api = "https://api.clickup.com/api/v2"
    receipt_path = work / "clickup-receipt.json"
    if (work / "create-uncertain.txt").exists() and not receipt_path.exists():
        raise RuntimeError("Task creation uncertain; inspect the ClickUp List and reconcile before any retry")
    if receipt_path.exists():
        receipt = json.loads(receipt_path.read_text())
        if receipt.get("source_sha256") != digest:
            raise ValueError("Existing task receipt belongs to different input; reconcile manually")
        task_id = receipt["task_id"]
    else:
        # A timeout after POST is uncertain. Stop and reconcile the List before another create.
        claim_map = "\n".join(f"{c['id']}: {c['source_url']}" for c in data["approved_claims"]
                            if c["id"] in draft["claim_ids"])
        description = ("Human review required. Check the approved fact sources, the exact post, "
                       "and every word/pixel of the attached image. No LinkedIn posting is authorized.\n\n"
                       + (work / "post.md").read_text(encoding="utf-8")
                       + "\nClaim source URLs:\n" + claim_map + "\nSource SHA-256: " + digest)
        save_new(work / "create-uncertain.txt", b"Inspect ClickUp List before retrying creation.\n")
        try:
            response = requests.post(f"{api}/list/{data['clickup_list_id']}/task",
                headers={**headers, "Content-Type": "application/json"},
                json={"name": "Review LinkedIn draft: " + data["topic"], "description": description,
                      "status": data["review_status"], "assignees": [int(data["clickup_reviewer_user_id"])],
                      "check_required_custom_fields": True}, timeout=30)
            response.raise_for_status()
            task_id = response.json()["id"]
        except Exception:
            # The pre-write marker already exists; preserve the original failure.
            raise
        receipt = {"task_id": task_id, "source_sha256": digest, "attachment_verified": False}
        save_new(receipt_path, json.dumps(receipt, indent=2).encode())
        (work / "create-uncertain.txt").unlink()
    task = requests.get(f"{api}/task/{task_id}", headers=headers, timeout=30)
    task.raise_for_status()
    attachments = task.json().get("attachments") or []
    if not any(a.get("title") == "visual.png" for a in attachments):
        if (work / "attachment-uncertain.txt").exists():
            raise RuntimeError("Attachment result uncertain; inspect task and reconcile manually")
        save_new(work / "attachment-uncertain.txt", b"Inspect task attachments before retrying.\n")
        try:
            with (work / "visual.png").open("rb") as handle:
                upload = requests.post(f"{api}/task/{task_id}/attachment", headers=headers,
                    files={"attachment[0]": ("visual.png", handle, "image/png")}, timeout=120)
                upload.raise_for_status()
        except Exception:
            # Preserve the marker even if the process is interrupted during POST.
            raise
    task = requests.get(f"{api}/task/{task_id}", headers=headers, timeout=30)
    task.raise_for_status()
    body = task.json()
    if not any(a.get("title") == "visual.png" for a in (body.get("attachments") or [])):
        raise RuntimeError("ClickUp did not read back the visual attachment")
    if not any(str(a.get("id")) == str(data["clickup_reviewer_user_id"]) for a in (body.get("assignees") or [])):
        raise RuntimeError("ClickUp did not read back the intended reviewer assignment")
    receipt["attachment_name_read_back"] = True
    receipt["attachment_verified"] = False
    receipt["human_task_and_file_inspection"] = "pending"
    receipt["task_url"] = body.get("url")
    receipt_path.write_text(json.dumps(receipt, indent=2), encoding="utf-8")
    print(json.dumps(receipt))


if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=["check", "prepare", "handoff"])
    parser.add_argument("input", type=Path)
    parser.add_argument("work", type=Path)
    args = parser.parse_args()
    if args.action == "check":
        load_input(args.input)
        print("Input fields present; facts and rights still require human verification.")
    elif args.action == "prepare":
        prepare(args.input, args.work)
    else:
        handoff(args.input, args.work)

3. Prepare a post and visual for inspection

Copy input-template.json to a private linkedin-input.json and replace every placeholder. Use one unique ID per approved claim. The owner_approved_for_linkedin field must be the literal JSON value true only after the owner has approved this use. Enter a named human reviewer, that person’s numeric ClickUp user ID, the authorized numeric List ID and its exact review status. Describe the intended LinkedIn reader and an approved visual style; the input is still subject to human fact and rights review.

Use a new, empty private run directory for each preparation. On Windows PowerShell, for example:

PowerShell
python workflow.py check C:\private\linkedin-input.json C:\private\linkedin-run
python workflow.py prepare C:\private\linkedin-input.json C:\private\linkedin-run

check validates required fields, IDs and placeholders locally. It does not verify the claims or permissions. prepare sends the approved fact list to Claude Code in noninteractive bare mode, with built-in and MCP tools disabled by documented CLI flags. It rejects claim IDs outside the input, saves post.md and the private Claude response, and uses gemini-nano-banana-2.1 to save visual.png. Google’s image manual shows this model and the interactions.create image response used in the reference code. Its model lifecycle lists Imagen 3’s November 2025 shutdown; the discovered older Imagen 3 instruction is no longer a runnable step.

Open post.md and compare every factual sentence with the approved source, including context and wording. Then open visual.png at its actual resolution. Check every word, number, depiction, logo and crop against the approved facts and rights. The image is an output to review, not evidence for a claim. If either file needs a change, retain the run for diagnosis, fix the input or approved prompt, and prepare in a fresh empty directory. Repeating a preparation can incur model charges. A failed or timed-out model call is not a reason to create a ClickUp task without both files.

4. Approve the exact files for ClickUp review

When a human has inspected both files, copy human-checked.template.json into the run directory as human-checked.json. Enter the actual reviewer, UTC time and a brief fact, source, rights and visual decision. Use the exact SHA-256 values printed by:

PowerShell
(Get-FileHash C:\private\linkedin-run\post.md -Algorithm SHA256).Hash.ToLowerInvariant()
(Get-FileHash C:\private\linkedin-run\visual.png -Algorithm SHA256).Hash.ToLowerInvariant()

Set approved_for_clickup_review to the literal JSON value true only for this review handoff. It does not approve a LinkedIn publication. If private or disallowed text remains in either file, remove it through a fresh preparation and approval with new hashes.

5. Create the review task and inspect it

With the intended ClickUp token in the private environment, run:

PowerShell
python workflow.py handoff C:\private\linkedin-input.json C:\private\linkedin-run

The script creates a task in the specified List and review status, assigns the numeric reviewer, includes the exact post and claim source URLs in the description, and uploads the actual PNG bytes as multipart attachment[0]. ClickUp’s attachment guide specifies that field and a 1 GB file limit, subject to account storage. The script GETs the task and records its ID and URL, an assignee readback and an attachment name readback. Its receipt leaves attachment_verified false and human_task_and_file_inspection pending for a reason: a matching filename cannot prove byte identity or that the correct reviewer saw the file.

Open the saved task in ClickUp. Check the home List, exact review status, named assignee and actual visibility. Compare its description with post.md and the source URLs. Open the attachment and compare it with the locally approved visual.png; then record accept, revise or reject in that task and in your private receipt. A human reviewer may request a revision without approving any LinkedIn post.

If task creation times out or returns an uncertain result, keep create-uncertain.txt. Search the exact List, including closed tasks and additional pages, for the intended title and source hash; ClickUp’s List endpoint pages results. Reconcile a found task ID before further action. If the task exists but clickup-receipt.json was never saved, stop using the script for this run and complete the handoff manually in that task. Check its text, List, status, assignee and visibility; inspect existing attachments before deciding whether the approved PNG still needs uploading. Record the task ID, URL and human decision privately, and retain the uncertainty marker. Do not manufacture a script receipt or blindly create a second task. An uncertain upload similarly leaves attachment-uncertain.txt; GET the task, inspect its actual attachment and reconcile before another POST. The reference code deliberately does not clear that marker for you. A ClickUp 429 calls for waiting for its rate-limit reset window; a storage 4xx calls for resolving capacity or reducing an owned asset. Neither failure is fixed by repeated submissions.

This is a documentation-led reference workflow. The local Python syntax and template structure were checked; no Claude Code draft, Gemini image, ClickUp attachment or account readback was produced for this article. For related tasks, our three-channel Claude Code guide covers draft coordination, while the social visual calendar guide covers Adobe Express assets. This procedure’s completion point is the reviewed ClickUp task containing a post and actual PNG attachment.