① 三分钟跑通 Quick start
一个 Tool 就是一个 HTML 文件。引 SDK,等 ready,然后画你的界面。下面这份已经是一个能上架的 Tool。
<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<script src="/tools/_kit.js"></script> <!-- 唯一允许的外部脚本:它和你同源 -->
<style> body{font-family:-apple-system,sans-serif;padding:16px} </style>
</head><body>
<h3>Hello</h3><p id="who"></p><button id="x">Close</button>
<script>
hoopTool.ready(function (ctx) { // ① 必须在 ready 之后再用任何东西
document.getElementById('who').textContent = 'me = ' + ctx.me + ' · theme = ' + ctx.theme;
});
document.getElementById('x').onclick = function () { hoopTool.close(); };
</script></body></html>
在浏览器里直接打开也能跑(SDK 检测到没有容器就用保守默认值)。要试多人,用 ⑦ 的模拟器。
② 目录清单 Manifest
每个 Tool 在目录里是一行。这些值决定容器怎么开它 —— 写错的后果都列在最后一列。
| 字段 | 值 | 写错了会怎样 |
id | 短名,英数和 - _,≤36 | 带冒号/斜杠被拒 |
name / name_zh / name_en | 显示名;中英至少一份 | 缺的语种回退 name |
icon | 一个 emoji(过渡);上架后平台配扁平图 | 空 = 🧩 |
category | game 有输赢 · social 报名/投票/评论 · utility 自用道具 · effect 演出 | 认不得 = utility |
width / height | 浮层大小(dp);0×0 = 铺满聊天画面 | — |
duration_ms | >0 到点容器自动收;0 = 用户自己关。铺满的必须 >0 | 铺满且 0 = 挡住对方聊天到天荒地老,不许上架 |
trigger_mode | manual 只从「+」开(自用)· tap 发一张卡,对方点了才开(对局/报名)· send 发出去双方就播(演出) | 认不得 = tap |
interactive | 要不要转发事件。false 的后端一条都不转,send/shared 全失效 | 写 false 又用 shared = 各玩各的 |
{ "id": "signup", "name": "Sign-up", "name_zh": "报名", "icon": "📋", "category": "social",
"width": 300, "height": 420, "duration_ms": 0, "trigger_mode": "tap", "interactive": true }
③ 五条铁规矩 Hard rules (enforced by the platform)
这些不是建议,是容器和后端强制的。写了也跑不通,而且多数情况下不报错,只是静默不工作 —— 所以先记住。
| # | 规矩 | 违反了会怎样 |
| 1 | 只能访问自己的源。不能 fetch/XHR 别的网站,不能 location.href 跳走,不能开新窗口 | 跨源 fetch 被中止;跳转被取消 —— 无声 |
| 2 | 不能要相机 / 麦克风 / 定位 / 通讯录。连弹框都不会有 | 一律 DENY |
| 3 | 单条消息 ≤ 4 KiB。(send 的 payload、shared 的一次写) | SDK 当场抛错;绕过 SDK 发的后端静默丢 |
| 4 | 一份 HTML 装完。CSS/JS 内联;图片用 data: URI 或同源路径;唯一允许的外部脚本是 /tools/_kit.js | 外部脚本/样式加载失败,页面残废 |
| 5 | 身份都是影子 id。ctx.me 和 from 是不透明字符串,同一局内稳定;不是真人 id,别解析、别存到别处、别当名字显示 | 需要名字就让用户自己填(见报名范例) |
还有三条目前管不到但别依赖的:<img src=外站>、<script src=外站>、WebSocket。今天不拦,下一版会拦 —— 现在就当它们不存在。
④ API 参考 Reference
全部挂在 window.hoopTool。类型见 hoop-tool.d.ts(把它一起喂给你的 AI)。
生命周期
| 方法 | 语义 | 注意 |
ready(fn) | fn(ctx):拿到上下文后再画界面。ctx = {tool, instance, me, conversation, theme, locale, size, interactive, dev} | 所有 SDK 调用都放在 ready 之后;ready 之前 ctx.me 是占位值 |
onTheme(fn) | 亮/暗主题;立即回调一次当前值 | 用 data-theme 属性切 CSS 变量(见范例) |
close() | 关掉自己 | 铺满型的到点容器也会收,你不必自己数时间 |
finish(card) | 定格:把 {title(≤80), lines(≤12 行,每行≤120)} 发成一张结果卡进聊天,永久留痕,然后关闭。返回 Promise | 报名截止、比赛结束、投票揭晓用它;结果不定格 = 关掉就没了 |
一次性动作(对局类)
| 方法 | 语义 | 注意 |
send(action, payload) | 发给其他所有人(自己收不到回声)。action 短名 ≤32 字、不许以 $ 开头;payload 传对象,SDK 序列化 | 需要 interactive: true;超 4 KiB 抛错 |
onEvent(fn) | fn({action, payload, from, seq, at})。from 是后端填的影子 id,可信;重发/乱序已由 SDK 丢弃 | 你不用管序号;但要自己处理「同一轮对方出两次」(见猜拳范例:一轮只认第一手) |
共享状态(多人类)
| 方法 | 语义 | 注意 |
shared.collection(name) | 一组各自独立的条目:add(id, value) / remove(id) / get / has / all() / size() / on(fn) / restore(saved)。并发各加各的永不互相覆盖;删除是墓碑;all() 按加入时间排 | 报名、投票、评论、举手、答题 —— 凡是多人各交一份的,用这个。每人的 id 就是 ctx.me |
shared.set(key, value) / get / on | 单个共享值,最后写赢(全序 = 时钟 + 写者) | 只用于"该由一个人改"的东西:阶段、主持人选的题、倒计时起点。别用它装一整张多人名单 —— 两人同时写,后写的整张盖掉先写的 |
shared.update(key, fn) | 读-改-写 | 同上:单写者才安全 |
| 迟到者 | 后进来的人自动收到在场者的快照,不用写任何代码 | 快照到达有 30~250ms;先画"加载中"再画数据 |
持久化(跨局)
| 方法 | 语义 | 注意 |
kv.get(key) / set(key, v) / remove / all | 按工具 × 会话一份 JSON 文档,≤16 KiB,30 天不动才过期。异步(Promise) | 关掉再开还在。多人共写同一份:SDK 已去抖、内容没变不存 |
| 和 shared 怎么配合 | 存 collection.all(),开局用 collection.restore(saved) 拿回。restore 是"兜底":在场任何人的快照都盖过它 | 别用 add() 去恢复 —— 那是"我现在报名",会把别人手里的正版盖掉 |
玩家
| 方法 | 语义 |
players.all() / others() / count() / has(id)、onJoin(fn) | 这一局里见过的人(影子 id)。只有"进来过",没有"离开"(聊天里的窗口关了不算走) |
⑤ 状态模型:什么用哪个 Which primitive
| 你要做的 | 用 | 为什么 |
| 出拳、点一下、掷骰子 —— 一次性动作 | send / onEvent | 不需要"当前状态",只需要"发生了什么" |
| 报名、投票、评论、举手、每人答一题 | shared.collection | 多人各交一份;并发不能互相覆盖;迟到者要看到全部 |
| 当前阶段、主持人选的题目、倒计时起点、谁是庄 | shared.set | 只有一个人会改它;最后写赢正好 |
| 关了再开还得在(报名单、榜) | kv + collection.restore | shared 只活在这一局的内存里 |
| 这局的结论要留下来 | finish | 结果卡进聊天流才是永久记录;kv 30 天会过期 |
最容易写错的一条:把一整张名单塞进 shared.set('list', [...])。两个人同时报名,后一个的写入把前一个的整张覆盖 —— 三边一致地丢了一个人,零报错。模拟器里真跑出来过。多人各加各的,永远用 collection。
⑥ 四种形态范例 Recipes — all live, view-source
| 形态 | 范例 | 清单要点 | 用到的 API |
| 自用道具 | calc 计算器 | manual · 浮层 · duration 0 · interactive false | 只有 close |
| 双人回合 | rps 猜拳 | tap · 浮层 · duration 0 · interactive true | send / onEvent;一轮只认对手第一手;没都出齐不露对方的手 |
| 多人参与 | signup 报名 | tap · 浮层 · duration 0 · interactive true · social | collection + kv.restore + finish(⑤ 那条坑的正确写法) |
| 演出 | inkcat 泼墨 | send · 铺满 0×0 · duration 3000 · interactive false | 背景透明;时间轴对齐 duration;2.85s 自己 close 当后备 |
多人参与的骨架(照抄就对)
var roster;
hoopTool.ready(function (ctx) {
roster = hoopTool.shared.collection('signups');
hoopTool.kv.get('signups').then(function (saved) { roster.restore(saved); paint(); }); // 兜底
roster.on(function (items) { paint(items); hoopTool.kv.set('signups', items); }); // 一变就画+存
});
join.onclick = function () {
if (roster.has(hoopTool.ctx.me)) roster.remove(hoopTool.ctx.me);
else roster.add(hoopTool.ctx.me, { name: nameInput.value.trim() || 'Guest' }); // 只写自己那条
};
done.onclick = function () {
hoopTool.finish({ title: 'Sign-up closed · ' + roster.size(), lines: roster.all().map(function (it, i) { return (i + 1) + '. ' + it.value.name; }) });
};
双人回合的骨架
var opponent = null, mine = {}, theirs = {}, round = 1;
hoopTool.onEvent(function (e) {
if (e.action !== 'throw') return;
if (opponent === null) opponent = e.from; // 第一个非我的人就是对手,之后别人不收
if (e.from !== opponent) return;
var r = e.payload.r || 1;
if (theirs[r]) return; // 这一轮他已经出过:只认第一手
theirs[r] = e.payload.m; paint();
});
pick.onclick = function (m) { if (mine[round]) return; mine[round] = m; hoopTool.send('throw', { m: m, r: round }); paint(); };
// paint():两边都出了才揭晓;只出了一边,对方那格显示 ✅ 不显示手型
⑦ 模拟器与交活前自检 Simulator & checklist
打开 https://hoop-tools.pages.dev/tools/_sim.html?tool=你的目录名&players=3(本地开发用 python3 -m http.server 在 tools 的上一级目录起服务,再开 localhost:端口/tools/_sim.html?…)。它在浏览器里开 N 个玩家跑你同一份页面,中间那段代码模拟真后端的全部语义:影子身份、序号、只转他人、可选乱序/重发/延迟、kv、finish。
交活前必须过的 10 条(每条在模拟器里都能点出来):
- 2 个玩家:一方操作,另一方 300ms 内看到
- 3 个玩家同时操作(勾上"乱序/重发/延迟"):三边最终状态完全一致
- 点「+ 迟到玩家」:新来的人 1 秒内看到现状,不是空白
- 点「重开某人」:他回来后看到的和别人一样(顺序也一样)
- 只剩一个人时重开:kv 把东西带回来(如果你用了 kv)
- 同一个人连点两下:不会产生两条(幂等)
- 结束时
finish 出现在日志里,标题和行数对
- 切 dark:界面能看,没有白底黑字消失的情况
- 浏览器 Console 没有
[hoopTool] 的 warn/error
- Network 面板:除了
_kit.js 和你自己的资源,零外部请求
⑧ AI 最常写错的 12 处 Common AI mistakes
| # | 错法 | 正确 |
| 1 | 在 ready 之前读 ctx.me | 全部逻辑放 ready(fn) 里 |
| 2 | 把多人名单塞进 shared.set 一个 key | shared.collection,各写各的 |
| 3 | 用 add() 从 kv 恢复 | collection.restore(saved) |
| 4 | 期待 send 之后自己也收到 onEvent | 回声没有;自己的动作自己先画 |
| 5 | 同一轮收到对手两次动作都处理 | 一轮只认第一手 |
| 6 | 群里三个人时把第二个来的人也当对手 | 锁定第一个 from,其余忽略(或明确设计成多人) |
| 7 | 拿 from 当名字显示 / 拿去 fetch 别的接口 | 影子 id 只用来比对相等;名字让用户填 |
| 8 | 引 CDN 的 React/Vue/字体/图标库 | 全部内联;或不用 |
| 9 | 用 fetch 调任何外部 API(天气、翻译…) | Tool 没有外网;这类需求现在不做 |
| 10 | 铺满型(0×0)写 duration_ms: 0 | 铺满必须有时长;自己再 close() 当后备 |
| 11 | 迟到者页面先画空数据再等快照,用户看到"没人报名"闪一下 | 先画"加载中",快照/kv 到了再画 |
| 12 | 结果只在屏幕上,关了就没了 | finish 定格进聊天 |
⑨ 提交 Submit 上传门已开 09-02
- 在模拟器把 ⑦ 那 10 条跑绿
- 准备两样:你的
index.html(≤ 200 KiB,单文件)+ ② 的清单 JSON
- 用 HoopStudio 开发者身份(状态 approved)调一次下面的端点 → 落成私有 Tool:只有你指定的那个群看得见、装得上、转得了事件;平台自动把它装进那个群,两分钟内页面可达
- 在自己群里用顺了 → 调
publish-request 申请进公共目录 → 人审(不是机器) → 全平台可装
不想调接口? App 里也能交:群资料 → Tools → 顶上那行「Make a tool」,填清单、把 index.html 贴进去,一样走这四道闸。
端点 Endpoint
POST https://api.hoopcomm.com/v1/tools/submit
Authorization: Bearer <your HOOP token>
Content-Type: application/json
{
"conversation_id": "<the group it goes into — you must be owner/admin there>",
"manifest": { "id": "dice-roll", "name": "Dice", "name_zh": "骰子", "name_en": "Dice", "icon": "🎲",
"category": "game", "width": 300, "height": 420, "duration_ms": 0,
"trigger_mode": "tap", "interactive": true },
"html": "<!doctype html>…the whole file as one JSON string…"
}
→ 200 { "tool_id": "dice-roll", "version": 1, "entry_url": "https://hoop-tools.pages.dev/tools/p/dice-roll/",
"private": true, "live_in_s": 120 }
同一个 id 再交一次 = 更新(version +1),只有原作者、原群能覆盖。id 就是网址里的目录名:小写字母开头,字母/数字/连字符,2–36 位;calc / rps / inkcat / signup 和 hoop* 是保留名。
四道闸,每道拒绝都是一句人话 Gates and their error codes
| 闸 | 看什么 | 拒绝时的 code |
| 身份 | developers.status = approved(HoopStudio 申请) | 403 not_developer |
| 范围 | 清单每一格落在 ② 的范围内;conversation_id 里你得是群主/管理员 | 400 bad_manifest(带哪一格)· 400 bad_conversation · 403 forbidden |
| 安检 | 静态扫描整份网页(注释里的不算):外链脚本只准 /tools/_kit.js;不准 <link href=http…> / iframe / meta refresh;不准出现任何出站网址(图片用 data: URI);不准 localStorage / sessionStorage / indexedDB / cookie;不准 eval / new Function / import();不准 window.open / location 跳转 / WebSocket / Worker / sendBeacon;必须 <!doctype html> 开头、挂 kit、调 hoopTool.ready( | 400 scan_failed(带撞在哪一句) |
| 账本 | 每次交件留一行(sha256 / 体量 / 谁 / 哪个群),回溯用 | — |
过闸之后:文件 commit 进 HOOP-games 仓 tools/p/<id>/index.html,CI 自动部署到 hoop-tools;目录那一行当场 live。409 id_taken = 这个 id 是别人的;502 push_failed = 仓没推进去,稍后再试。
申请进公共目录 Request publishing
POST /v1/tools/{id}/publish-request → 200 { "ok": true, "review": "manual" }
只有作者、只有私有的行能申请。审核在人手里:通过 = 平台把它的 owner_conversation_id 清空,全平台可装。
页面地址是相对的 /tools/_kit.js,所以本地在模拟器跑和上线跑是同一份文件,不用改一个字。
⑩ 给你的 AI 的系统提示 System prompt for your AI (copy as-is)
You are writing a HOOP chat Tool: ONE self-contained HTML file that runs inside a small window in a chat.
Load the SDK with <script src="/tools/_kit.js"></script> (the only external script allowed). API is window.hoopTool
(types: hoop-tool.d.ts). HARD RULES (platform-enforced, silent failure): no cross-origin fetch/XHR/navigation;
no camera/mic/location; each message ≤ 4 KiB; everything inline (CSS/JS/data-URI images); identities are opaque
shadow ids — compare them, never parse or display them.
STRUCTURE: put ALL logic inside hoopTool.ready(function (ctx) {...}); read ctx.me/ctx.theme there; support dark theme
via hoopTool.onTheme. Draw a "loading" state until data arrives.
STATE: one-shot actions → hoopTool.send/onEvent (no echo of your own sends; drop a second action from the same
opponent in the same round). Multi-person contributions (sign-ups, votes, comments, answers) → hoopTool.shared.collection(name):
each person calls add(ctx.me, value) / remove(ctx.me) on their OWN id only. Single-owner values (phase, host's choice)
→ hoopTool.shared.set/get/on. Never store a multi-person list in a single shared key.
PERSISTENCE: to survive reopen, save collection.all() with hoopTool.kv.set(key, items) on change, and on start call
collection.restore(await hoopTool.kv.get(key)). Never use add() to restore.
RESULTS: when the round ends call hoopTool.finish({title, lines}) so the outcome becomes a permanent chat card.
UI: mobile-first, touch targets ≥ 40px, no text smaller than 12px, English strings by default.
DELIVERABLE: the single HTML file + a manifest JSON {id,name,name_zh,name_en,icon,category,width,height,duration_ms,trigger_mode,interactive}.
SUBMISSION GATES (static scan, rejected with a reason): the ONLY external script is /tools/_kit.js; NO absolute http(s)://
URL anywhere (images as data: URIs); no localStorage/sessionStorage/indexedDB/cookie; no eval/new Function/import();
no window.open/location changes/WebSocket/Worker/sendBeacon; file starts with ; ≤ 200 KiB; id is
lowercase [a-z][a-z0-9-]{1,35} and not calc/rps/inkcat/signup/hoop*.
Before finishing, walk through the 10-point simulator checklist and the 12 common mistakes from the HOOP Tools guide.
版本
SDK v2(2026-09-02):在 v1 三句之上加 ready/shared/collection/kv/finish/players/onTheme;v1 工具不用改。变更记录见 HOOP-games 仓 tools/CHANGELOG(在总 CHANGELOG 里搜「Tool」)。