A repository maintainer can use three Claude Code project skills to turn an approved business request into a GitHub issue. The skills gather bounded evidence, draft the issue and prepare a review sheet. A separate Python handler checks the file handoffs and makes the one authorized GitHub write only after a named maintainer approves the exact draft. The result to verify is one matching issue in the intended repository, not a locally polished draft.

This is a documented recipe for a reader with a real request and permission to use the relevant repository and model. BIG CHANGE checked the cited manuals and the local handler with existing synthetic fixtures. We did not run the skills in Claude Code, use a GitHub account, post an issue or measure cost or time saved.

The big change

  • What changed: Project skills can divide a business request into explicit evidence, drafting and review steps. Exact file hashes bind their handoffs to one approved brief; a separate handler controls the external issue write.
  • Why it matters: A maintainer can inspect each claim against repository records and approve the precise text before an issue can notify collaborators or trigger repository automation. A model-produced review sheet cannot stand in for that decision.
  • What to watch: The important boundary is the uncertain create response. The handler records intent before posting and reconciles the repository's full issue list against the same work key, title and body. A missing, duplicated or altered match requires human investigation before any further write.

The Claude Code skills manual documents project skill files, slash invocation and arguments; the MCP manual covers connected tools and their status. The GitHub Issues API supplies the external create and read operations, while the GitHub CLI API manual documents the pagination used below. This design uses the CLI as its explicit issue handler. It does not depend on a skill silently registering a software action.

Prepare a real request and an isolated project

Use an authorized checkout and a private working branch or directory. Confirm claude --version, gh --version, the exact GitHub account/repository, Issues permissions, Claude Code plan/data costs and the organization's right to send the brief to the configured model. Do not put tokens or private customer data into this sample. Before each /issue-* invocation, inspect project, personal and enterprise skill directories for same-name collisions; current precedence can select a nonproject skill. Compare SHA-256 bytes of the intended project SKILL.md with the installed file and verify the client's resolved command source path through its available command/skill inspection. A slash listing proves discovery only. If the client cannot expose the resolved source and any higher-precedence collision remains possible, stop; choose a unique project name, restart discovery and repeat. Record path and hash for all three commands. Default skills share conversation context; the files are authoritative checked handoffs, not the only context the model can see.

For evidence, draft and review, use a separate read-only GitHub credential scoped to the exact repository's contents/issues read permissions, with no Issues write permission or other connected write-capable GitHub tool. Check gh auth status, the token's repository permission at its issuer, the client's effective tool-permission screen/configuration and MCP connections before source access. Verify read access with an ordinary issue lookup; do not test a write. Skill text and allowed-tools cannot enforce this host boundary. Only after named human approval, switch to a separate Issues-write credential for the deterministic handler; verify its exact repository and Issues permission. Stop if effective permissions cannot be confirmed.

Create the project skill directories and the working directory before saving the files. The three SKILL.md blocks below are complete; save each block verbatim at its labelled path. Save the Python block as issue_gate.py in the same project root. Use Python 3.8 or newer. The commands below run in a POSIX shell such as Git Bash or WSL; confirm that python resolves to that Python 3 version before running the handler:

Terminal
mkdir -p .claude/skills/issue-evidence .claude/skills/issue-draft .claude/skills/issue-review work
claude --version
gh --version
gh auth status
python --version
python -c "import sys; assert sys.version_info >= (3, 8), sys.version"

For native PowerShell, create the same directories with New-Item -ItemType Directory -Force -Path .claude/skills/issue-evidence, .claude/skills/issue-draft, .claude/skills/issue-review, work | Out-Null, then run the same claude --version, gh --version, gh auth status, python --version and Python version assertion commands. The mkdir -p syntax above belongs to the POSIX shell.

Use sha256sum on a Unix-like shell or Get-FileHash -Algorithm SHA256 in PowerShell to record the exact bytes of each saved skill, brief.json, evidence.json and draft.json at its handoff. Hashes printed by the handler must agree with those saved bytes. Do not paste authentication tokens into a prompt or a receipt.

Save work/brief.json from the reader's actual approved request. Each evidence URL must identify an actual repository commit, existing issue, file or other permitted record the maintainer can inspect. Replace every placeholder. work_key is a stable owner-assigned business-task ID, never a random per-attempt value. That stability lets the handler look for an existing issue after a timeout.

JSON
{
  "repo": "<owner>/<repo>",
  "work_key": "<stable-approved-task-id>",
  "request_owner": "<real-authorized-owner>",
  "requested_outcome": "<actual-business-result-and-scope>",
  "acceptance": ["<real-check-1>", "<real-check-2>"],
  "source_urls": ["<real-permitted-repository-record-url>"],
  "data_approved_for_model": true
}

The first skill handles evidence, the second writes the issue draft, and the third prepares the human review. Their checked handoffs are the files below; default invocations may also share conversation context, so never rely on memory as a validated input. Save each complete skill in its stated path, verify resolved origin and effective permissions as above, then invoke them one at a time. Wait for evidence.json, inspect its links, then wait for draft.json and run python issue_gate.py check work. Run that check again after the review sheet. A started child/tool handle is not a completed result.

.claude/skills/issue-evidence/SKILL.md

Markdown
---
name: issue-evidence
description: Read an owner-approved brief and write a bounded evidence map for a GitHub issue.
disable-model-invocation: true
---

Read $ARGUMENTS/brief.json and only the exact source URLs it lists, using the reader's permitted GitHub read route. Stop on access denial or an unsupported fact. Record each material statement as a source-mapped fact with its exact URL and the observable record ID/revision. Write $ARGUMENTS/evidence.json with keys `brief_sha256` and `facts`, where each fact has `statement` and `source_url`. Use SHA-256 of the complete UTF-8 brief bytes. Include every claim needed for the requested outcome, keep uncertainty explicit, and do not create or edit a GitHub issue. Do not infer that a model's reading makes a source true.

.claude/skills/issue-draft/SKILL.md

Markdown
---
name: issue-draft
description: Turn approved brief and checked evidence into one reviewable GitHub issue draft.
disable-model-invocation: true
---

Read $ARGUMENTS/brief.json and $ARGUMENTS/evidence.json. Use only their owned scope and cited facts. Write $ARGUMENTS/draft.json with `brief_sha256`, `evidence_sha256`, `title`, `body`, and `source_urls`. Hash the exact complete UTF-8 input bytes. The body must state the requested outcome, current evidence with links, acceptance checks, unresolved questions and a named human decision point. Do not claim a test or result that the brief or evidence does not establish. Do not post, assign, label or notify anyone.

.claude/skills/issue-review/SKILL.md

Markdown
---
name: issue-review
description: Prepare a source-linked review sheet for a drafted business issue.
disable-model-invocation: true
---

Read $ARGUMENTS/brief.json, evidence.json and draft.json. Write $ARGUMENTS/review-sheet.md listing each material draft claim and its source URL, the requested outcome and acceptance checks, possible overlap with existing issues, permissions and any unresolved gaps. Record the complete draft file SHA-256. The named human owner must inspect the original records and draft, decide whether this exact text is fit to create, and write approval.json personally. This skill cannot approve on their behalf and must not make a GitHub write.

Invoke /issue-evidence work, check; /issue-draft work, check; /issue-review work, check. The model may write malformed or unsupported content; the deterministic handler below checks shape and provenance hashes, while the human checks meaning. A sample evidence.json shape is {"brief_sha256":"<64-hex>","facts":[{"statement":"<supported-fact>","source_url":"<URL-from-brief>"}]}. A sample draft shape is {"brief_sha256":"<64-hex>","evidence_sha256":"<64-hex>","title":"<title>","body":"<reviewable-body>","source_urls":["<URL-from-brief>"]}. These are schemas, not a fictitious report.

The human writes work/approval.json after checking the exact draft and sources:

JSON
{
  "decision": "approve",
  "reviewer": "<actual-maintainer-name>",
  "reviewed_at_utc": "<actual-ISO-UTC-time>",
  "draft_sha256": "<SHA256-of-complete-draft.json-bytes>"
}

Save the complete deterministic handler

Save this as issue_gate.py at the project root. check is local. reconcile reads all open and closed GitHub issues, filters pull requests and compares the exact approved title, normalized body and marker. submit scans first, saves an intent receipt, sends at most one create request and reads back the resulting issue. It uses the current gh api pagination/slurp contract. The marker is an application reconciliation key, not a GitHub-enforced idempotency key. Use one designated issue writer for this work key; if another machine can write the same task concurrently, coordinate ownership in the team's system first.

Python
import argparse
import hashlib
import json
import pathlib
import re
import subprocess
import uuid
from datetime import datetime, timezone

def raw(path):
    return path.read_bytes()

def sha(path):
    return hashlib.sha256(raw(path)).hexdigest()

def load(path):
    return json.loads(raw(path))

def save_new(path, data):
    with path.open("x", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
        f.write("\n")

def save_replace(path, data):
    temp = path.with_name(path.name + "." + uuid.uuid4().hex + ".new")
    with temp.open("x", encoding="utf-8") as f:
        json.dump(data, f, indent=2)
        f.write("\n")
        f.flush()
        import os
        os.fsync(f.fileno())
    temp.replace(path)

def checked(work, approval=False):
    b, e, d = (work / n for n in ("brief.json", "evidence.json", "draft.json"))
    brief, evidence, draft = load(b), load(e), load(d)
    if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", brief["repo"]):
        raise ValueError("Expected one exact owner/repo")
    if not re.fullmatch(r"[A-Za-z0-9_.-]{3,100}", brief["work_key"]):
        raise ValueError("Use one stable task key")
    if brief.get("data_approved_for_model") is not True:
        raise ValueError("Brief lacks model-data approval")
    if not brief.get("requested_outcome") or not brief.get("acceptance"):
        raise ValueError("Owned outcome and checks required")
    urls = set(brief["source_urls"])
    if not urls or any(not u.startswith("https://") for u in urls):
        raise ValueError("Exact HTTPS source URLs required")
    if evidence.get("brief_sha256") != sha(b):
        raise ValueError("Evidence bound to another brief")
    facts = evidence.get("facts")
    if not isinstance(facts, list) or not facts:
        raise ValueError("Evidence facts missing")
    if any(not x.get("statement") or x.get("source_url") not in urls for x in facts):
        raise ValueError("Unsupported evidence URL or empty fact")
    if draft.get("brief_sha256") != sha(b) or draft.get("evidence_sha256") != sha(e):
        raise ValueError("Draft handoff hash mismatch")
    if not draft.get("title") or not draft.get("body"):
        raise ValueError("Draft title/body missing")
    if not draft.get("source_urls") or not set(draft["source_urls"]).issubset(urls):
        raise ValueError("Draft source set invalid")
    if approval:
        a = load(work / "approval.json")
        if (a.get("decision") != "approve" or not a.get("reviewer")
                or not a.get("reviewed_at_utc") or a.get("draft_sha256") != sha(d)):
            raise ValueError("No exact human approval for this draft")
    return brief, draft, sha(d)

def gh(*args):
    cp = subprocess.run(["gh", *args], text=True, capture_output=True, timeout=60)
    if cp.returncode:
        raise RuntimeError("GitHub CLI failed; inspect auth/network/rate state: " + cp.stderr[:500])
    return cp.stdout

def marker(brief):
    key = brief["repo"].lower() + ":" + brief["work_key"]
    return "<!-- reviewed-work-key:" + hashlib.sha256(key.encode()).hexdigest() + " -->"

def matches(brief):
    endpoint = "repos/" + brief["repo"] + "/issues?state=all&per_page=100"
    pages = json.loads(gh("api", "--paginate", "--slurp", endpoint))
    found = [x for page in pages for x in page
             if "pull_request" not in x and marker(brief) in (x.get("body") or "")]
    return [{"number": x["number"], "url": x["html_url"],
             "title": x["title"], "body": x["body"]} for x in found]

def expected(brief, draft):
    return draft["title"], draft["body"].rstrip() + "\n\n" + marker(brief) + "\n"

def norm(value):
    return (value or "").replace("\r\n", "\n")

def settle(brief, draft, draft_hash, found, prior):
    title, body = expected(brief, draft)
    same = (prior.get("repo") == brief["repo"] and
            prior.get("work_key") == brief["work_key"] and
            prior.get("draft_sha256") == draft_hash)
    exact = (len(found) == 1 and found[0]["title"] == title and
             norm(found[0]["body"]) == norm(body) and
             norm(found[0]["body"]).count(marker(brief)) == 1)
    result = {**prior, "reconciled_at_utc": datetime.now(timezone.utc).isoformat(),
              "matching_issue_count": len(found)}
    if same and exact:
        result.update(status="reconciled_verified", number=found[0]["number"],
                      url=found[0]["url"])
    else:
        result.update(status="unknown", next="Inspect repo, approval and all writers; no blind POST")
    return result

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("action", choices=("check", "reconcile", "submit"))
    ap.add_argument("work_dir")
    args = ap.parse_args()
    work = pathlib.Path(args.work_dir).resolve()
    brief, draft, draft_hash = checked(work, args.action != "check")
    if args.action == "check":
        print(json.dumps({"status": "structurally_valid", "draft_sha256": draft_hash}, indent=2))
        return
    found = matches(brief)
    receipt_path = work / "issue-receipt.json"
    if args.action == "reconcile":
        if not receipt_path.exists():
            print(json.dumps({"status": "no_prior_intent", "matches": found}, indent=2))
            return
        outcome = settle(brief, draft, draft_hash, found, load(receipt_path))
        save_replace(receipt_path, outcome)
        print(json.dumps(outcome, indent=2))
        return
    if receipt_path.exists():
        raise ValueError("Existing intent/result receipt: reconcile it; no automatic retry")
    if len(found) != 0:
        raise ValueError("Existing matching issue or duplicates: inspect before any write")
    title, body = expected(brief, draft)
    intent = {"status": "intent", "repo": brief["repo"],
              "work_key": brief["work_key"], "draft_sha256": draft_hash,
              "marker": marker(brief), "time_utc": datetime.now(timezone.utc).isoformat()}
    save_new(receipt_path, intent)
    request_path = work / "issue-request.json"
    try:
        save_new(request_path, {"title": title, "body": body})
        created = json.loads(gh("api", "-X", "POST",
                                "repos/" + brief["repo"] + "/issues",
                                "--input", str(request_path)))
        live = json.loads(gh("api", "repos/" + brief["repo"] + "/issues/" + str(created["number"])))
        if live.get("title") != title or norm(live.get("body")) != norm(body):
            raise ValueError("Live issue differs from approved draft")
        outcome = {**intent, "status": "readback_verified", "number": live["number"],
                   "url": live["html_url"],
                   "time_utc": datetime.now(timezone.utc).isoformat()}
    except Exception as exc:
        outcome = {**intent, "status": "unknown", "error_type": type(exc).__name__,
                   "next": "Run reconcile, inspect GitHub and account state; never blindly repeat POST"}
    save_replace(receipt_path, outcome)
    print(json.dumps(outcome, indent=2))

if __name__ == "__main__":
    main()

Before submission, run python issue_gate.py check work; compare the review sheet, draft and exact source records personally. Run python issue_gate.py reconcile work to inspect prior matches, then only the designated, authorized maintainer runs python issue_gate.py submit work. A successful run returns one issue URL and exact readback. If it returns unknown, retain the intent's repository, work key, draft hash and marker, then run reconcile with the same approved draft. Reconciliation inspects that repository's open and closed issues, excludes pull requests and accepts only one matching marker with the exact approved title and normalized body; it records the existing URL. Zero, multiple or mismatched issues remain unknown: inspect GitHub visibility, writer activity and approval manually. Do not delete the receipt or rerun submit as a recovery shortcut. After an ambiguous timeout with zero matches, the owner decides whether a separately authorized new attempt is warranted after delayed visibility and all writers are checked.

Check the external result and its limits

The initial intent file is closed before the request, but it is not explicitly synced to stable storage. It supports recovery from an ordinary process failure; it does not guarantee recovery after power loss or filesystem failure. If an attempted submission leaves a missing or corrupt receipt, stop for human reconciliation of the repository and all writers. Do not treat the missing file as permission to submit again.

Keep the issue receipt and the approved files together. readback_verified means the handler read the newly created issue and compared its title and body with the approved bytes. reconciled_verified means a later all-state scan found exactly one issue with the same marker, title and normalized body under the same repository and work key. Neither status proves that the underlying business request has been completed. The acceptance checks remain work for the repository team.

The handler's structural checks verify file shape, allowed URLs and hashes. They cannot establish that the cited facts are true, that the named reviewer personally approved the file, or that no other authorized writer can create a competing issue. Review source records directly and reserve one writer for each stable work key. GitHub permissions, account plans, Claude usage charges, rate limits, issue notifications and repository automation vary by setup. Check those before the human approves posting; this guide makes no dollar or time-saving estimate.

For related work, our three-channel Claude Code skills guide ends with checked local drafts, and our GitHub-record report guide reads repository records for a report. This procedure carries one reviewed request through an external issue creation and readback.