Make a QR code with as few questions as possible. The only required input is the web address (or the details of a type the user explicitly asked for); every look-and-feel choice is optional and never blocks.
When NOT to use
| Request | Do instead |
|---|---|
| Read, scan or decode an existing QR code | Out of scope (this skill only makes codes) |
| Other barcodes (Code 128, EAN/UPC, Data Matrix, PDF417, Aztec) | Out of scope |
| General illustrations, logos or artwork with no QR code | Use an image-generation tool instead |
| A flyer, poster, page or slide that contains a QR code | Make the QR code here, then use a document or presentation tool for the layout |
| Rebranding documents or decks | Out of scope — "brand colors" here means the QR code's colors only |
| An interactive QR-generator app | Out of scope (this skill delivers files) |
Engine rules
| Rule | Detail |
|---|---|
| Skill folder | <skill-dir> = the folder this SKILL.md was loaded from. Engine: scripts/make_qr.py; style guide: assets/qr-style-guide.png (both relative to <skill-dir>). If unknown, search **/qr-code-builder/scripts/make_qr.py and use its grandparent. Use absolute paths in commands |
| Libraries | Pillow + one QR encoder (ReportLab, qrcode or segno, first found). Never pip install; never write your own encoder. The engine reads CSV only for bulk (XLSX: see Bulk) |
| No separate check | The generation run reports encoder and returns missing_encoder / missing_pillow itself. Run python '<skill-dir>/scripts/make_qr.py' --check ONLY after one of those errors (or another encoder/Pillow error), or on the Customize path to read vector_pdf before answering a PDF question |
| One command | python '<skill-dir>/scripts/make_qr.py' … --scratch '<scratch>' --file-name '<slug>-qr' in ONE call (the engine creates <scratch>; no mkdir). No extra verification calls — the JSON is the check |
| Safe commands (security) | Never put a user-derived value — URL, caption, frame text, color, logo path, file name, Wi-Fi / contact / event fields, CSV column names — raw or inside double quotes into a shell string: $(…), backticks and $VAR still run inside "…". Prefer an argument list when the host runs Python: subprocess.run([sys.executable, script, "--url", url, …]), one list item per value, never shell=True. In a shell, wrap every user value in single quotes and write each ' inside it as '\'' (e.g. Bob's → 'Bob'\''s'). Quote <skill-dir>, <scratch> and file paths the same way. Fixed option names (png, fluid, small, logo) are safe as-is |
| Fresh folder every run | <scratch> = <qr-root>/<slug>-run<N> (Platform notes). Use a NEW folder for EVERY run and rerun — N = 1, 2, 3 …; never reuse one, never write straight into the delivery location. The engine creates it, refuses a non-empty one (scratch_not_empty → next N) and refuses anything outside the working folder (bad_scratch). Never delete files or folders to make room |
| Safe file names | --file-name '<slug>-qr', where <slug> is the web address's host (contoso.com), the code type (wifi, contact, event, text), or for bulk links (bulk file names come from the name column + row number). Never pass a path, folder or a file name the user typed. The engine reduces the name to letters, digits, ., -, _ (it reports the final file_name), and checks every file stays inside <scratch> — quoting alone doesn't stop ../ tricks |
| Images | Standard: never view the PNG. Customize: view the final PNG ONCE, only if the look changed (shape, eyes, frame, logo, colors, caption); after a rerun view only the rerun. The style guide is for the user — don't inspect it |
Platform notes
| Topic | Copilot Cowork | Copilot Studio / other code-interpreter hosts |
|---|---|---|
| Questions | core-AskUserQuestion — one card per step; multi-select for Step 3b |
ONE chat message per question, numbered options, recommended first; wait for the reply |
| Finding uploads (logo, CSV) | Glob input/**/* and Glob grounding/**/* |
Files attached in this conversation / the host's upload folder |
| Scratch folder | <qr-root> = working/qr; <scratch> = working/qr/<slug>-run<N> |
<qr-root> = qr_out (inside the working folder); <scratch> = qr_out/<slug>-run<N> |
| Showing the style guide | mkdir -p working/qr/guide && cp '<skill-dir>/assets/qr-style-guide.png' working/qr/guide/, then ONE host-CopyArtifact(surface="output", source="working/qr/guide/qr-style-guide.png", destination="qr-style-guide.png", overwrite=true) |
Show it inline, or attach it |
| Delivering files | Deliver EXACTLY the JSON file_names (batch: every result's file_names), from <scratch>. One file: ONE host-CopyArtifact(surface="output", source="<scratch>/<file>", destination="<file>"). Several: ONE host-CopyArtifact(surface="output", source="<scratch>", destination="<slug>-qr", recursive=true) — only when the JSON says folder_clean: true; otherwise copy the listed files one by one. Check copied equals the number of listed files. Retry the same call on "source not visible yet"; confirm with ONE Glob output/**/* |
Return each listed file as an attachment. Only ONE file per response and several made → zip exactly the listed files into <scratch>.zip (beside <scratch>, paths single-quoted) in the generating command |
| Seeing images | The image viewer shows the agent only; the user sees delivered files | The delivered attachment is the preview |
| Typical libraries | ReportLab: vector PDF, no scan verifier | Varies; PDF may be 300-dpi raster; may have a scan verifier |
Walkthrough
A standard code takes exactly two questions; Customize adds the menu, ticked follow-ups and a confirm.
Step 1 — Web address (REQUIRED)
- Always ask, even when the request contains one: "Use contoso.com" / "Enter a different address"; none
given → ask. Never invent an address; the script adds
https://. - Empty or cancelled answer → stop: "No problem — I've stopped; nothing was created." Generate nothing.
- Other code types only when explicitly requested: collect that type's real details instead (see Code types). Never invent a password, phone number, email or contact detail.
Step 2 — Standard or Customize (ONE gate question)
- Standard QR code for https://contoso.com: black squares, PNG (recommended)
- Customize the look
Standard or empty → Step 4 immediately with defaults (PNG, 1000 px, black on white, square); no extra confirm. If the request already names a customization ("with my logo", "as a PDF", "in brown"), skip this question: go straight to Customize with those items pre-ticked and still show them in 3b.
Step 3 — Customize (only if chosen)
- 3a. Show the style guide (see Platform notes) and say: "Here's the style guide — sections A-E, ★ marks the standard choice." Sections: A Body shape (8) · B Eye frame (5) · C Eye center (5) · D Outer frame (4) · E Brand color, caption & logo size. Never rebuild or hand-draw samples.
- 3b. ONE multi-select — "What would you like to change?" Shape & corners · Brand colors · Logo in the middle · Caption · Outer frame · File format & size. (No multi-select tool: numbered list, reply with numbers.) Empty → Standard.
- 3c. Follow-ups ONLY for ticked items — one question per item (one card per group is fine), standard option first marked "(recommended)". Empty → the standard choice; continue. Exception: a value the request already states goes first as the recommended option instead — e.g. "brown" + a logo → "Match my logo's color (recommended)", then "Brown #5C3A21", then "Black on white"; "as a PDF" → PDF first.
- 3d. Confirm a one-line summary, e.g. "contoso.com · Fluid body, Leaf eyes · brown #5C3A21 · small logo · caption 'Visit Contoso' · PNG 1000 px" → "Create it (recommended)" / "Change something". Empty → create.
Customize options and flags
| Item | Options (standard first) | Flags |
|---|---|---|
| Body shape (A) | 1 Square (recommended — most reliable), 2 Rounded, 3 Dots, 4 Diamond, 5 Fluid, 6 Vertical bars, 7 Horizontal bars, 8 Mosaic | --style square|rounded|dots|diamond|fluid|vertical-bars|horizontal-bars|small-squares (Mosaic = small-squares) |
| Eye frame (B) | 1 Square (recommended), 2 Rounded, 3 Extra rounded, 4 Circle, 5 Leaf | --eye-frame square|rounded|extra-rounded|circle|leaf |
| Eye center (C) | 1 Square (recommended), 2 Rounded, 3 Circle, 4 Diamond, 5 Leaf | --eye-center square|rounded|circle|diamond|leaf |
| Brand colors | Black on white (recommended) / Match my logo's color (only when a logo is chosen) / Enter a hex (e.g. #5C3A21). Optional separate eye color. Always a dark code on a light background |
--fg HEX|logo --bg HEX · --eye-color HEX|logo · --bg transparent (PNG/WEBP/SVG only) |
| Logo | "Use [filename]" per uploaded image / "I'll attach one" (nothing arrives or skipped → no logo). Size: Medium (recommended) / Small (subtle) / Large. Say it switches on error correction H automatically. PNG/JPG best; SVG logos only if self-contained (no links to web addresses or other files — those are refused) | --logo 'PATH' --logo-size small|medium|large (0.15 / 0.22 / 0.28 of width; overrides --logo-scale) |
| Caption | Free text under the code, e.g. "Visit Contoso" (plain Latin text is safest) | --caption '...' (single-quoted — see Safe commands) |
| Outer frame (D) | None (recommended) / Box / Rounded box / Banner. Banner text: "SCAN ME" (default) / caption text / other. Optional frame color (default = code color) | --frame none|box|rounded-box|banner · --frame-text '...' · --frame-color HEX|logo |
| Format & size | PNG (recommended) / SVG / PDF / JPG / WEBP, several allowed; SVG/PDF stay plain square. Size 1000 px (recommended) or custom (exact; frame/caption add space). PDF 216 pt (3 in). Too small for the content → size_too_small with the minimum |
--format png,svg,pdf · --size 1000 · --pdf-size 216 |
logo as a color value needs --logo (else error no_logo).
Code types (only when explicitly requested)
| Type | Ask for | Flags |
|---|---|---|
| Web link (default) | Web address | --type url --url |
| Plain text | The text | --type text --text |
| Wi-Fi | Network name, password, security: WPA (recommended — covers WPA/WPA2/WPA3) / WEP / open (no password), hidden? Work/school (enterprise) Wi-Fi isn't supported | --type wifi --ssid --password --auth WPA|WEP|nopass [--hidden] (WPA2/WPA3 are accepted as WPA) |
| Contact card | Name; optional org, title, phone, email, website, address | --type vcard --name --org --title --phone --email --url --address |
| Address; optional subject, body | --type email --email --subject --body |
|
| Text message | Phone; optional message | --type sms --phone --body |
| Phone call | Phone number | --type phone --phone |
| Map location | Latitude, longitude (geocode only a user-given address; confirm) | --type geo --lat --lon |
| Calendar event | Title, start, end (20261015T190000, or 20261015 for all-day — then the end is the day AFTER the last day), optional location. End must be after start |
--type event --summary --start --end --location |
| Bulk | Uploaded CSV (or XLSX, converted first — below); which column holds the data and which the file name | --batch FILE.csv --data-column COL --name-column COL --scratch DIR |
Bulk: find the upload (ask if none); show the headers, ask for the two columns. Bare domains get https://, and every web link (bare or http(s)://) gets the same
checks as a single link; cells starting with mailto:, tel:, smsto:, geo:, WIFI: or BEGIN: are encoded as given; a bad
or empty row doesn't stop the batch and is reported (never skipped). Every file name ends with its row number
(e.g. Alpha_001.png), so names never collide. Non-UTF-8 CSVs (Excel's classic "CSV (Comma delimited)") are read
as Windows text with a warning.
XLSX uploads — the engine is CSV-only, so convert first, and ONLY if a reader is importable. Run this ONE command (paths passed as arguments, single-quoted); it never installs anything:
mkdir -p '<qr-root>' && python - '<upload>.xlsx' '<qr-root>/<slug>-sheet-run<N>.csv' <<'PY'
import sys, csv
src, dst = sys.argv[1], sys.argv[2]
try:
import openpyxl
rows = list(openpyxl.load_workbook(src, read_only=True, data_only=True).worksheets[0].iter_rows(values_only=True))
except ImportError:
try:
import pandas as pd
rows = pd.read_excel(src, sheet_name=0, header=None, dtype=str).fillna("").values.tolist()
except ImportError:
print("NO_XLSX_READER"); sys.exit(0)
except Exception:
print("NO_XLSX_READER"); sys.exit(0) # pandas present but no Excel engine
except Exception:
print("XLSX_UNREADABLE"); sys.exit(0)
with open(dst, "w", newline="", encoding="utf-8") as f:
csv.writer(f).writerows([["" if v is None else str(v) for v in r] for r in rows])
print("CONVERTED")
PY
| Result | Do |
|---|---|
CONVERTED |
Continue with <qr-root>/<slug>-sheet-run<N>.csv — kept OUTSIDE <scratch>, which must stay empty for the engine (first sheet only — say so if the workbook has several) |
NO_XLSX_READER |
Stop the bulk flow; don't guess or hand-parse. Say: "I can't open Excel files here. In Excel choose File › Save As › CSV UTF-8 (Comma delimited) and upload that file." |
XLSX_UNREADABLE |
Same message, noting the file couldn't be opened (it may be damaged or password-protected) |
Older .xls, .ods or .numbers files: ask for a CSV UTF-8 export directly (the engine returns not_csv for them).
Step 4 — Generate, read JSON, deliver
-
Run in ONE command, into a NEW
<scratch>(Engine rules → Fresh folder every run; a rerun uses the next-run<N>).--file-nameis a plain base name without extension (dots likecontoso.com-qrare kept). Pass every user value safely (Engine rules → Safe commands): single quotes in a shell,'→'\'':python '<skill-dir>/scripts/make_qr.py' --type url --url 'contoso.com' --format png \ --scratch 'working/qr/contoso.com-run1' --file-name 'contoso.com-qr' python '<skill-dir>/scripts/make_qr.py' --url 'contoso.com' --style fluid \ --eye-frame leaf --eye-center leaf --frame banner --frame-text 'SCAN ME' --logo '<logo>.png' \ --logo-size small --fg logo --caption 'Visit Contoso'\''s open house' --format png,svg,pdf \ --scratch 'working/qr/contoso.com-run2' --file-name 'contoso.com-qr'Where the host runs Python directly, use an argument list instead (no shell, no quoting needed):
import subprocess, sys r = subprocess.run([sys.executable, "<skill-dir>/scripts/make_qr.py", "--url", url, "--caption", caption, "--format", "png", "--scratch", scratch, "--file-name", slug + "-qr"], capture_output=True, text=True) -
Read the JSON printed on stdout:
Field Meaning / what to do files/file_namesPaths written / their plain names — deliver EXACTLY these, nothing else file_name,folder_cleanFinal (safe) base name; true=<scratch>holds only this run's files.false→ deliver the listed files one by one (never the folder)payloadWhat the code contains; show it for Wi-Fi/vCard. A Wi-Fi password is already masked ( P:********) — show as-iswarningsExplain each in plain words (list below) readback_checkmodules_obscured_pctvserror_correction_budget_pct→verdictgood / risky / likely to fail (corner eyes excluded). Risky or worse → offer--logo-size smalland/or--style square, rerunscan_verifiednull= no QR reader installed (estimate only). Else{engine, decoded, matches_content};false= failure → offer square / smaller logo / more contrast, rerunBatch {"rows", "count", "failed", "results", "folder_clean"}(rows= every data row;count+failed=rows); failed rows carryrow,name,error,message(missing_data= empty cell) → report, offer to fix those rows -
Errors (exit code 2,
{"error": code, "message": "..."}): relay the message in plain words, re-ask ONLY that item, rerun.Code Action invalid_url/missing_urlRe-ask the web address missing_field/invalid_fieldRe-ask ONLY the field the message names (e.g. Wi-Fi password unless the network is open; a supported Wi-Fi security type; a real event date/time like 20261015T190000with the end after the start; latitude/longitude numbers)bad_column/batch_not_foundShow the spreadsheet's columns (listed in the message) and re-ask / ask for the file again not_csv/bad_csvAsk for a "CSV UTF-8 (Comma delimited)" export (for .xlsx, try the Bulk conversion first)bad_colorRe-ask that color (offer black or a hex) logo_not_found/logo_unreadableRe-ask the logo, or continue without one (a fully transparent logo can't supply a color — offer a hex) logo_svgAsk for a PNG or JPG logo bad_formatRe-ask the format (png, svg, pdf, jpg, webp) no_logoUse black, say so too_longAsk for a shorter link or less text size_too_smallThe chosen size can't fit this much content — rerun with the minimum size the message gives (or shorter content); say so bad_optionAn option is out of range or invalid (the message names it) — fix that value; if it came from the user, re-ask it cannot_writeThe scratch folder isn't writable — rerun ONCE with the next -run<N>; if it fails again, say soscratch_not_emptyThe folder was already used — rerun with the next -run<N>(never empty or delete the old one)bad_scratchThe folder path is unsafe or outside the working folder — rerun with <qr-root>/<slug>-run<N>exactly as in Platform notestoo_largeRerun with a smaller --sizeinternal_errorSay something went wrong; rerun ONCE with the standard look. If it fails again, stop and tell the user (never show the detailfield)missing_encoder/missing_pillow"This environment can't make QR codes (a required image or QR library is missing)." Stop; ask nothing more (optionally one --checkto confirm)Warnings to explain (batch: a top-level
warningslist may also note the CSV encoding): low contrast (code, eyes, frame); inverted light-on-dark; margin under 4 squares; Wi-Fi password length looks wrong / password ignored for an open network; emoji/CJK text may show as boxes; caption shown smaller on several lines / banner text too long (offer to shorten); error correction raised for the logo; "fg taken from logo: #…" (name it); "The SVG/PDF leaves out: …" (name each listed option; offer PNG for the styled version); PDF is a 300-dpi image (fine for print); failed scan test. Tiny caption text in the preview = no TrueType font here; say so. -
Preview (Customize only): follow the Images rule. If only SVG/PDF was chosen, add a PNG preview run in the SAME command into its own new folder
<scratch>-preview(never inside<scratch>) and view that. -
Deliver every final file (not previews) per Platform notes. If a name is already taken in the delivery location, pick a new name and say so.
-
Test-scan reminder: always say "Scan it with your phone camera before you print or share it."
Output
- Chat: 3-6 bullets — what the code contains, exact file names, the readability verdict in plain words, warnings explained, and the test-scan reminder.
- Files:
<file_name>.<ext>per chosen format (bulk: one per row per format) — exactly the JSONfile_names.
Guardrails
- Never generate before Step 1 is answered; always show what will be made before generating (Step 2 option / Step 3d).
- Never invent a URL, Wi-Fi password, phone number, email or contact detail — ask.
- Keep a dark code on a light background; if the user insists on light-on-dark, warn that many scanners fail.
- Never claim a code "scans" or is "verified" unless
scan_verified.matches_contentis true — otherwise call the readability result an estimate. - The payload already masks Wi-Fi passwords; never echo the
--passwordvalue in chat, and never put passwords or other secrets in captions, frame text or file names. - Never silently overwrite a user's file in the delivery location.
- Files go ONLY into a fresh
<qr-root>/<slug>-run<N>folder — never a path or file name taken from the user, an upload or a spreadsheet. Deliver only the files the JSON lists. Never delete files or folders. If the user asks for a particular location or file name, use the safe name anyway and say: "I saved it as<file>in its own folder — I can't write to locations you type, but you can rename it after downloading." - Never interpolate user values raw or in double quotes into a shell command — use an argument list, or single
quotes with
'written as'\''(Engine rules → Safe commands). This applies to every value and every command. - Never
pip install. On a script error, relay the message in plain words — no tracebacks, and never hand-draw a QR code.