diff --git a/marketing/podcast/KUNAKI.md b/marketing/podcast/KUNAKI.md
new file mode 100644
--- /dev/null
+++ b/marketing/podcast/KUNAKI.md
@@ -0,0 +1,37 @@
+# Kunaki cassette production
+
+Kunaki separates product creation from fulfillment:
+
+1. Generate a cassette kit locally.
+2. Create the cassette in Kunaki's browser uploader and inspect its virtual proof.
+3. Record the resulting 10-character product ID.
+4. Use the API client for shipping quotes, test orders, live orders, and tracking.
+
+```sh
+npm run podcast:cassette-kit -- --slug physical-mail --title "A Record in the Mail"
+npm run podcast:kunaki -- specs
+npm run podcast:kunaki -- shipping shipping.json
+KUNAKI_USER_ID=... KUNAKI_PASSWORD=... npm run podcast:kunaki -- order order.json
+```
+
+An order defaults to Kunaki's `Test` mode. Live order construction additionally
+requires `KUNAKI_ALLOW_LIVE=1`; this prevents an accidental manufacturing order.
+Do not commit credentials or recipients' addresses.
+
+Example shipping input:
+
+```json
+{
+ "country": "United States",
+ "stateProvince": "CA",
+ "postalCode": "90012",
+ "items": [{ "productId": "PX0012345", "quantity": 1 }]
+}
+```
+
+Example order input adds `recipient` and the exact `shippingDescription` returned
+by the quote. Product creation cannot be automated by Kunaki's fulfillment API.
+
+Current cassette artwork requirements are encoded in `lib/kunaki.mjs`: JPEG at
+300 DPI, 1200×1110 for the J-card, and 1062×496 for each side label. Kunaki says
+bleed is unnecessary but recommends keeping text and lines clear of the edges.
diff --git a/marketing/podcast/bin/kunaki-cassette-kit.mjs b/marketing/podcast/bin/kunaki-cassette-kit.mjs
new file mode 100644
--- /dev/null
+++ b/marketing/podcast/bin/kunaki-cassette-kit.mjs
@@ -0,0 +1,43 @@
+#!/usr/bin/env node
+import { existsSync, mkdirSync, writeFileSync } from "node:fs";
+import { basename, resolve } from "node:path";
+import { execFileSync } from "node:child_process";
+import { CASSETTE_SPECS } from "../lib/kunaki.mjs";
+
+const args = Object.fromEntries(process.argv.slice(2).map((arg, i, all) => arg.startsWith("--") ? [arg.slice(2), all[i + 1]?.startsWith("--") ? true : all[i + 1]] : null).filter(Boolean));
+const slug = args.slug || "physical-mail";
+const title = args.title || "A Record in the Mail";
+const subtitle = args.subtitle || "Aesthetic Computer · read by @jeffrey";
+const cover = resolve(args.cover || `marketing/podcast/out/${slug}-cover.png`);
+const audio = resolve(args.audio || `marketing/podcast/out/${slug}.mp3`);
+const out = resolve(args.out || `marketing/podcast/out/${slug}-cassette`);
+if (!existsSync(cover)) throw new Error(`Missing cover: ${cover}`);
+if (!existsSync(audio)) throw new Error(`Missing audio: ${audio}`);
+mkdirSync(out, { recursive: true });
+
+const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
+const svg = (width, height, side, extra = "") => ``;
+
+const render = (name, spec, side, extra = "") => {
+ const svgPath = resolve(out, `.${name}.svg`);
+ const pngPath = resolve(out, `.${name}.png`);
+ const jpgPath = resolve(out, `${name}.jpg`);
+ writeFileSync(svgPath, svg(spec.width, spec.height, side, extra));
+ execFileSync("rsvg-convert", ["--width", String(spec.width), "--height", String(spec.height), "--output", pngPath, svgPath]);
+ execFileSync("magick", [pngPath, "-units", "PixelsPerInch", "-density", "300", "-quality", "95", jpgPath]);
+ return jpgPath;
+};
+
+const jCard = resolve(out, "j-card.jpg");
+execFileSync("magick", [cover, "-resize", "1200x1110^", "-gravity", "center", "-extent", "1200x1110", "-units", "PixelsPerInch", "-density", "300", "-quality", "95", jCard]);
+const labelA = render("label-a", CASSETTE_SPECS.artwork.labelA, "SIDE A", `THE ESSAY + LISTENER MAIL INVITATION`);
+const labelB = render("label-b", CASSETTE_SPECS.artwork.labelB, "SIDE B", `LETTERS · mail@aesthetic.computer`);
+
+const duration = Number(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "default=nw=1:nk=1", audio], { encoding: "utf8" }).trim());
+if (duration > CASSETTE_SPECS.audio.maxMinutesPerSide * 60) throw new Error(`Audio is ${(duration / 60).toFixed(1)} minutes; maximum is 40 minutes per side`);
+const sideA = resolve(out, "side-a.wav");
+execFileSync("ffmpeg", ["-y", "-i", audio, "-ar", "44100", "-ac", "2", sideA], { stdio: "ignore" });
+const manifest = { vendor: "kunaki", productType: "cassette", title, sourceAudio: basename(audio), durationSeconds: duration, files: { sideA: basename(sideA), jCard: basename(jCard), labelA: basename(labelA), labelB: basename(labelB) }, specs: CASSETTE_SPECS, note: "Kunaki product creation remains a manual browser upload; the API begins after a product ID exists." };
+writeFileSync(resolve(out, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
+writeFileSync(resolve(out, "README.md"), `# ${title} — Kunaki cassette kit\n\nUpload \`side-a.wav\` to side A. Leave side B silent until its program is chosen. Upload \`j-card.jpg\`, \`label-a.jpg\`, and \`label-b.jpg\` in Kunaki's browser product creator. After publishing, record the 10-character product ID in \`manifest.json\`; fulfillment can then use \`bin/kunaki.mjs\`.\n\nArtwork: JPEG, 300 DPI, no bleed. J-card 1200×1110; labels 1062×496. Keep important text clear of edges.\n`);
+console.log(out);
diff --git a/marketing/podcast/bin/kunaki.mjs b/marketing/podcast/bin/kunaki.mjs
new file mode 100644
--- /dev/null
+++ b/marketing/podcast/bin/kunaki.mjs
@@ -0,0 +1,16 @@
+#!/usr/bin/env node
+import { readFileSync } from "node:fs";
+import { CASSETTE_SPECS, orderUrl, request, shippingOptionsUrl, statusUrl } from "../lib/kunaki.mjs";
+
+const [command = "specs", file] = process.argv.slice(2);
+const input = file ? JSON.parse(readFileSync(file, "utf8")) : {};
+const credentials = { userId: process.env.KUNAKI_USER_ID, password: process.env.KUNAKI_PASSWORD };
+
+let result;
+if (command === "specs") result = CASSETTE_SPECS;
+else if (command === "shipping") result = await request(shippingOptionsUrl(input));
+else if (command === "order") result = await request(orderUrl({ ...input, credentials, mode: input.mode || "Test" }));
+else if (command === "status") result = await request(statusUrl({ credentials, orderId: input.orderId }));
+else throw new Error("Usage: kunaki.mjs specs | shipping input.json | order input.json | status input.json");
+
+console.log(JSON.stringify(result, null, 2));
diff --git a/marketing/podcast/lib/kunaki.mjs b/marketing/podcast/lib/kunaki.mjs
new file mode 100644
--- /dev/null
+++ b/marketing/podcast/lib/kunaki.mjs
@@ -0,0 +1,104 @@
+const ENDPOINT = "https://Kunaki.com/HTTPService.ASP";
+
+export const CASSETTE_SPECS = Object.freeze({
+ source: "https://kunaki.com/product-cassette.html",
+ checked: "2026-07-18",
+ priceUsd: 5,
+ manufactureHours: 24,
+ audio: {
+ maxMinutesPerSide: 40,
+ accepted: ["wav", "mp3", "aac", "wmv", "m4a"],
+ },
+ artwork: {
+ format: "jpeg",
+ dpi: 300,
+ bleedRequired: false,
+ jCard: { width: 1200, height: 1110 },
+ labelA: { width: 1062, height: 496 },
+ labelB: { width: 1062, height: 496 },
+ },
+});
+
+const required = (value, name) => {
+ if (value === undefined || value === null || value === "") throw new Error(`Missing ${name}`);
+ return String(value);
+};
+
+function products(params, items) {
+ if (!Array.isArray(items) || items.length === 0) throw new Error("At least one product is required");
+ for (const [index, item] of items.entries()) {
+ params.append("ProductId", required(item.productId, `items[${index}].productId`));
+ params.append("Quantity", required(item.quantity ?? 1, `items[${index}].quantity`));
+ }
+}
+
+export function shippingOptionsUrl({ country, stateProvince = "", postalCode, items }) {
+ const params = new URLSearchParams({
+ RequestType: "ShippingOptions",
+ State_Province: stateProvince,
+ PostalCode: required(postalCode, "postalCode"),
+ Country: required(country, "country"),
+ ResponseType: "xml",
+ });
+ products(params, items);
+ return `${ENDPOINT}?${params}`;
+}
+
+export function orderUrl({ credentials, recipient, shippingDescription, items, mode = "Test" }) {
+ if (String(mode).toLowerCase() === "live" && process.env.KUNAKI_ALLOW_LIVE !== "1") {
+ throw new Error("Live Kunaki orders require KUNAKI_ALLOW_LIVE=1");
+ }
+ const params = new URLSearchParams({
+ RequestType: "Order",
+ UserId: required(credentials?.userId, "credentials.userId"),
+ Password: required(credentials?.password, "credentials.password"),
+ Mode: mode,
+ Name: required(recipient?.name, "recipient.name"),
+ Company: recipient?.company || "",
+ Address1: required(recipient?.address1, "recipient.address1"),
+ Address2: recipient?.address2 || "",
+ City: required(recipient?.city, "recipient.city"),
+ State_Province: recipient?.stateProvince || "",
+ PostalCode: required(recipient?.postalCode, "recipient.postalCode"),
+ Country: required(recipient?.country, "recipient.country"),
+ ShippingDescription: required(shippingDescription, "shippingDescription"),
+ ResponseType: "xml",
+ });
+ products(params, items);
+ return `${ENDPOINT}?${params}`;
+}
+
+export function statusUrl({ credentials, orderId }) {
+ return `${ENDPOINT}?${new URLSearchParams({
+ RequestType: "OrderStatus",
+ UserId: required(credentials?.userId, "credentials.userId"),
+ Password: required(credentials?.password, "credentials.password"),
+ OrderId: required(orderId, "orderId"),
+ ResponseType: "xml",
+ })}`;
+}
+
+const entity = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'");
+const tags = (xml, name) => [...String(xml).matchAll(new RegExp(`<\\s*${name}\\s*>([\\s\\S]*?)<\\s*\\/\\s*${name}\\s*>`, "gi"))].map((m) => entity(m[1].trim()));
+
+export function parseResponse(xml) {
+ const errorCode = tags(xml, "ErrorCode")[0];
+ const result = { errorCode: Number(errorCode), errorText: tags(xml, "ErrorText")[0] || "" };
+ if (!Number.isFinite(result.errorCode)) throw new Error("Kunaki returned an unreadable response");
+ if (result.errorCode !== 0) throw new Error(`Kunaki ${result.errorCode}: ${result.errorText}`);
+ const descriptions = tags(xml, "Description");
+ const times = tags(xml, "DeliveryTime");
+ const prices = tags(xml, "Price");
+ if (descriptions.length) result.options = descriptions.map((description, i) => ({ description, deliveryTime: times[i], priceUsd: Number(prices[i]) }));
+ for (const name of ["OrderId", "OrderStatus", "TrackingType", "TrackingId"]) {
+ const value = tags(xml, name)[0];
+ if (value !== undefined) result[name[0].toLowerCase() + name.slice(1)] = value;
+ }
+ return result;
+}
+
+export async function request(url, fetchImpl = fetch) {
+ const response = await fetchImpl(url, { headers: { accept: "application/xml,text/xml" } });
+ if (!response.ok) throw new Error(`Kunaki HTTP ${response.status}`);
+ return parseResponse(await response.text());
+}
diff --git a/package.json b/package.json
--- a/package.json
+++ b/package.json
@@ -99,6 +99,8 @@ "pop:assets:up": "aws s3 sync system/public/assets/pop s3://assets-aesthetic-computer/pop --endpoint-url https://sfo3.digitaloceanspaces.com --exclude '*.DS_Store' --acl public-read",
"podcast:feed": "node marketing/podcast/bin/feed.mjs",
"podcast:publish": "node marketing/podcast/bin/publish.mjs",
"podcast:publish:push": "node marketing/podcast/bin/publish.mjs --push",
+ "podcast:kunaki": "node marketing/podcast/bin/kunaki.mjs",
+ "podcast:cassette-kit": "node marketing/podcast/bin/kunaki-cassette-kit.mjs",
"essay:reel": "node marketing/essay-reels/bin/render.mjs",
"thespianjas:generate": "node thespianjas/bin/generate.mjs",
"thespianjas:studio": "node thespianjas/bin/serve.mjs",
diff --git a/spec/kunaki-spec.mjs b/spec/kunaki-spec.mjs
new file mode 100644
--- /dev/null
+++ b/spec/kunaki-spec.mjs
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import { CASSETTE_SPECS, orderUrl, parseResponse, shippingOptionsUrl } from "../marketing/podcast/lib/kunaki.mjs";
+
+describe("Kunaki integration", () => {
+ it("encodes duplicate product fields in shipping quotes", () => {
+ const url = new URL(shippingOptionsUrl({ country: "United States", stateProvince: "CA", postalCode: "90012", items: [{ productId: "PX0012345", quantity: 2 }] }));
+ assert.equal(url.searchParams.get("RequestType"), "ShippingOptions");
+ assert.deepEqual(url.searchParams.getAll("ProductId"), ["PX0012345"]);
+ });
+ it("parses shipping XML", () => {
+ const parsed = parseResponse("0success");
+ assert.deepEqual(parsed.options, [{ description: "USPS", deliveryTime: "2-5 days", priceUsd: 5.25 }]);
+ });
+ it("guards live orders", () => {
+ assert.throws(() => orderUrl({ mode: "Live", credentials: { userId: "x", password: "y" }, recipient: {}, shippingDescription: "USPS", items: [{ productId: "PX0012345" }] }), /KUNAKI_ALLOW_LIVE/);
+ });
+ it("tracks current cassette artwork dimensions", () => {
+ assert.deepEqual(CASSETTE_SPECS.artwork.jCard, { width: 1200, height: 1110 });
+ assert.deepEqual(CASSETTE_SPECS.artwork.labelA, { width: 1062, height: 496 });
+ });
+});