diff --git a/README.md b/README.md
index 538ac40..a82997b 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ The site of the [missing-elements](https://github.com/missing-elements) web comp
| `/` | The project list, the tools (the build page, which links the skill's page), and a footer of links | [src/index.njk](src/index.njk); the lists are in [src/_data/site.js](src/_data/site.js) |
| `/pdfjs-viewer/` | The docs of ``, with a live preview | [src/pdfjs-viewer/](src/pdfjs-viewer/) |
| `/h5p/build/` | The build page, for people: what it makes, a sample that plays, and the build of a chat's source into a `.h5p`, in the visitor's browser | [src/h5p/build/](src/h5p/build/) |
-| `/h5p/skill/` | The skill's files, and a page that lists each at its full address, for a chat | [src/h5p/skill/](src/h5p/skill/), copied by [eleventy.config.js](eleventy.config.js) |
+| `/h5p/skill/` | The skill's files, and a page that lists each at its full address, for a chat; and the skill as zips to install: `h5p-author.zip` for Claude, `h5p-author-chatgpt.zip` for ChatGPT's Plugins | [src/h5p/skill/](src/h5p/skill/), copied by [eleventy.config.js](eleventy.config.js); the zips written by [vite.config.ts](vite.config.ts) |
| `/h5p/bundles/` | The skill's build tool and a library bundle per content type, for a chat that runs Node but cannot reach npm | written by [vite.config.ts](vite.config.ts) |
| `/h5p/build/types/` | The skill's type files, which the build page copies for the chat | written by [vite.config.ts](vite.config.ts) |
diff --git a/package.json b/package.json
index d4e8d8f..0f92b2c 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"@missing-elements/h5p-libraries": "1.1.0",
"@missing-elements/h5p-offline-player": "1.6.0",
"@missing-elements/h5p-runtime": "1.0.1",
- "@missing-elements/pdfjs-viewer": "4.5.0"
+ "@missing-elements/pdfjs-viewer": "4.5.0",
+ "@zip.js/zip.js": "2.24.0"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e92594c..ca65990 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -47,6 +47,9 @@ importers:
'@missing-elements/pdfjs-viewer':
specifier: 4.5.0
version: 4.5.0
+ '@zip.js/zip.js':
+ specifier: 2.24.0
+ version: 2.24.0
devDependencies:
'@11ty/eleventy':
specifier: 3.1.8
diff --git a/src/h5p/create-with-ai/index.njk b/src/h5p/create-with-ai/index.njk
index 451b565..d5a8f8c 100644
--- a/src/h5p/create-with-ai/index.njk
+++ b/src/h5p/create-with-ai/index.njk
@@ -12,6 +12,7 @@ socialImage:
alt: "The build page: its headline, Build an H5P activity with AI, and a course presentation playing beside it."
# The top bar's links to the page's own sections (topbar.njk).
topbarLinks:
+ - { href: "#install", text: "Install" }
- { href: "#prompts", text: "Prompts" }
- { href: "#types", text: "Types" }
- { href: "#open-source", text: "Open source" }
@@ -39,7 +40,8 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
H5P. It writes the activity, and the build page makes it a
.h5p file for Moodle or any other H5P site. Both are free and
open source, with no account and nothing to install: you need only a
- chat that can open a web page.
+ chat that can open a web page. In Claude or ChatGPT you can also
+ install the skill once.
Open the build page
@@ -111,6 +113,40 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
+ {#- The zips are written by vite.config.ts from the release the site pins, so each holds the skill
+ the prompt above sends a chat to. Each was uploaded as it is, by the owner, on 2026-11-13. -#}
+
+ Or install the skill once
+
+ Installed, the skill stays in your chat: ask for an activity with no address to paste, such as
+ "Use the h5p-author skill to make a quiz of five questions on the rivers of Europe".
+
+
+ -
+ Claude, on claude.ai or in its apps: download
+ h5p-author.zip and upload it under Customize,
+ then Skills, with code execution on. Claude builds the
.h5p file in the chat.
+
+ -
+ ChatGPT: download
+ h5p-author-chatgpt.zip and upload it
+ under Plugins. ChatGPT cannot install the build tool, so it hands over a link to the build
+ page. If the page says the link arrived damaged, ask for the link again, or for the source
+ as a file, and open that on the build page.
+
+ -
+ Open WebUI: an administrator imports the skill under Import from URL, from
+ its folder on GitHub,
+
https://github.com/missing-elements/h5p-offline-player/tree/main/skills/h5p-author.
+
+ -
+ An agent with a terminal, such as Claude Code, Codex or Cursor:
+
npx skills add missing-elements/h5p-offline-player --skill h5p-author.
+
+
+ The zips hold h5p-author {{ skill.version }}, the release the build page runs.
+
+
Prompts to start from
diff --git a/src/h5p/skill/index.njk b/src/h5p/skill/index.njk
index f4b83d8..265ae68 100644
--- a/src/h5p/skill/index.njk
+++ b/src/h5p/skill/index.njk
@@ -38,7 +38,9 @@ csp: "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; o
Where skills can be installed, install it instead:
- npx skills add missing-elements/h5p-offline-player --skill h5p-author.
+ npx skills add missing-elements/h5p-offline-player --skill h5p-author, or in
+ Claude's or ChatGPT's app from the zips at
+ {{ site.url }}/h5p/create-with-ai/#install.
diff --git a/vite.config.ts b/vite.config.ts
index b707cc1..f461574 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -3,6 +3,7 @@ import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildPackage, writeBundles } from '@missing-elements/h5p-author'
+import { Uint8ArrayReader, Uint8ArrayWriter, ZipWriter } from '@zip.js/zip.js'
import { build, defineConfig, type Plugin } from 'vite'
/*
@@ -202,12 +203,99 @@ function bundles(): Plugin {
}
}
+/** One date for every entry, as `h5p-author bundle` writes its zips: the same pin writes the same bytes. */
+const ZIP_DATE = new Date(2000, 0, 1, 2)
+
+/**
+ * The h5p-author skill as zips to install, beside the files `/h5p/skill/` serves, from the same
+ * pinned author package:
+ *
+ * - `h5p-author.zip`: the skill's folder at the zip's root, `h5p-author/SKILL.md`, which Claude's
+ * app takes under Customize, Skills (as does Open WebUI's import).
+ * - `h5p-author-chatgpt.zip`: the same files as a plugin, under `skills/h5p-author/`, with its
+ * `plugin.json` at the root, which ChatGPT's Plugins take. A zip with the folder at its root is
+ * refused there ("Plugin archive must contain … `skills/*\/SKILL.md`"). The manifest is the Agent
+ * Plugins one, which allows no field but its own ten, with ChatGPT's name for the plugin under
+ * `extensions`; a name that leads with "H5P" says who made it.
+ *
+ * Both were uploaded as they are written here, by the owner on 2026-11-13. ChatGPT's code cannot
+ * read a plugin's files, so neither carries the build tool: where a chat cannot install it, the
+ * build page builds.
+ */
+function skillZips(): Plugin {
+ const author = new URL('./node_modules/@missing-elements/h5p-author/', import.meta.url)
+ const guide = 'https://missing-elements.github.io/h5p/create-with-ai/'
+ let outDir = ''
+ return {
+ name: 'missing-elements:skill-zips',
+ apply: 'build',
+ configResolved(config) {
+ outDir = resolve(config.root, config.build.outDir)
+ },
+ async writeBundle() {
+ const { version } = JSON.parse(await readFile(new URL('package.json', author), 'utf8')) as { version: string }
+ // The files eleventy.config.js copies to /h5p/skill/: SKILL.md, the licence and references/.
+ const references = readdirSync(new URL('references/', author), { recursive: true, withFileTypes: true })
+ .filter((entry) => entry.isFile())
+ .map((entry) => join(entry.parentPath, entry.name).slice(fileURLToPath(author).length).split('\\').join('/'))
+ const files = ['SKILL.md', 'LICENSE', ...references].sort()
+ const skill = await Promise.all(files.map(async (path) => [path, await readFile(new URL(path, author))] as const))
+ const manifest = {
+ $schema: 'https://agent-plugins.org/schemas/1.0.0/plugin.schema.json',
+ name: 'h5p-author',
+ version,
+ description: 'Create H5P content from a prompt, a lesson or supplied material, as a playable .h5p: quizzes, flash cards, presentations, interactive books and more.',
+ author: { name: 'Missing Elements', url: 'https://github.com/missing-elements' },
+ homepage: guide,
+ repository: 'https://github.com/missing-elements/h5p-offline-player',
+ license: 'MIT',
+ keywords: ['h5p', 'quiz', 'e-learning', 'authoring'],
+ extensions: {
+ 'com.openai': {
+ interface: {
+ displayName: 'H5P Author by Missing Elements',
+ shortDescription: 'Make a playable H5P activity from a prompt or your material',
+ developerName: 'Missing Elements',
+ websiteURL: guide
+ }
+ }
+ }
+ }
+ const folder = join(outDir, 'h5p', 'skill')
+ await mkdir(folder, { recursive: true })
+ await writeFile(join(folder, 'h5p-author.zip'), await zipOf('h5p-author/', skill))
+ await writeFile(join(folder, 'h5p-author-chatgpt.zip'), await zipOf('skills/h5p-author/', skill, [['plugin.json', new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`)]]))
+ }
+ }
+}
+
+/**
+ * A zip of the files under `prefix`, after the files at its root, with an entry for each folder
+ * before what is in it, as `git archive` writes one: the shape of the zips first uploaded.
+ */
+async function zipOf(prefix: string, files: ReadonlyArray, atRoot: ReadonlyArray = []): Promise {
+ const writer = new ZipWriter(new Uint8ArrayWriter(), { useWebWorkers: false, lastModDate: ZIP_DATE, extendedTimestamp: false })
+ for (const [path, bytes] of atRoot) await writer.add(path, new Uint8ArrayReader(bytes))
+ const folders = new Set()
+ for (const [path, bytes] of files) {
+ const parts = `${prefix}${path}`.split('/')
+ for (let depth = 1; depth < parts.length; depth++) {
+ const name = `${parts.slice(0, depth).join('/')}/`
+ if (folders.has(name)) continue
+ folders.add(name)
+ await writer.add(name, undefined, { directory: true })
+ }
+ await writer.add(`${prefix}${path}`, new Uint8ArrayReader(bytes))
+ }
+ return writer.close()
+}
+
export default defineConfig({
publicDir: false,
// A missing file is a 404, as on GitHub Pages, not the home page: `pnpm preview` answers as the
// site does, where an SPA's fallback would answer every address with a page.
appType: 'mpa',
- plugins: [emptyOwnFolders(), licenceFiles(), typeFiles(), sample(), bundles()],
+ plugins: [emptyOwnFolders(), licenceFiles(), typeFiles(), sample(), bundles(), skillZips()],
build: {
outDir: '_site',
emptyOutDir: false,