🧰 HOOP Tools SDK · 开发者指南 / Developer Guide
写一份单文件网页,它就能在 HOOP 聊天里跑:自用道具、双人对局、多人报名投票、全屏演出。 这一页是**给你和你的 AI 一起读的**:规格严到照着写就不会出 bug。SDK 本体 /tools/_kit.js,类型 /tools/hoop-tool.d.ts,多人模拟器 /tools/_sim.html(都在 hoop-tools.pages.dev)。
SDK v22026-09-02Tora(聊天线)范例 4 个全部在线可拆
① 三分钟跑通② 目录清单③ 五条铁规矩④ API 参考 ⑤ 状态模型:什么用哪个⑥ 四种形态范例⑦ 模拟器与交活前自检 ⑧ AI 最常写错的 12 处⑨ 提交⑩ 给你的 AI 的系统提示

① 三分钟跑通 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(过渡);上架后平台配扁平图空 = 🧩
categorygame 有输赢 · social 报名/投票/评论 · utility 自用道具 · effect 演出认不得 = utility
width / height浮层大小(dp);0×0 = 铺满聊天画面
duration_ms>0 到点容器自动收;0 = 用户自己关。铺满的必须 >0铺满且 0 = 挡住对方聊天到天荒地老,不许上架
trigger_modemanual 只从「+」开(自用)· 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.mefrom 是不透明字符串,同一局内稳定;不是真人 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.restoreshared 只活在这一局的内存里
这局的结论要留下来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 truesend / onEvent;一轮只认对手第一手;没都出齐不露对方的手
多人参与signup 报名tap · 浮层 · duration 0 · interactive true · socialcollection + 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 条(每条在模拟器里都能点出来):
  1. 2 个玩家:一方操作,另一方 300ms 内看到
  2. 3 个玩家同时操作(勾上"乱序/重发/延迟"):三边最终状态完全一致
  3. 点「+ 迟到玩家」:新来的人 1 秒内看到现状,不是空白
  4. 点「重开某人」:他回来后看到的和别人一样(顺序也一样)
  5. 只剩一个人时重开:kv 把东西带回来(如果你用了 kv)
  6. 同一个人连点两下:不会产生两条(幂等)
  7. 结束时 finish 出现在日志里,标题和行数对
  8. 切 dark:界面能看,没有白底黑字消失的情况
  9. 浏览器 Console 没有 [hoopTool] 的 warn/error
  10. Network 面板:除了 _kit.js 和你自己的资源,零外部请求

⑧ AI 最常写错的 12 处 Common AI mistakes

#错法正确
1ready 之前读 ctx.me全部逻辑放 ready(fn)
2把多人名单塞进 shared.set 一个 keyshared.collection,各写各的
3add() 从 kv 恢复collection.restore(saved)
4期待 send 之后自己也收到 onEvent回声没有;自己的动作自己先画
5同一轮收到对手两次动作都处理一轮只认第一手
6群里三个人时把第二个来的人也当对手锁定第一个 from,其余忽略(或明确设计成多人)
7from 当名字显示 / 拿去 fetch 别的接口影子 id 只用来比对相等;名字让用户填
8引 CDN 的 React/Vue/字体/图标库全部内联;或不用
9fetch 调任何外部 API(天气、翻译…)Tool 没有外网;这类需求现在不做
10铺满型(0×0)写 duration_ms: 0铺满必须有时长;自己再 close() 当后备
11迟到者页面先画空数据再等快照,用户看到"没人报名"闪一下先画"加载中",快照/kv 到了再画
12结果只在屏幕上,关了就没了finish 定格进聊天

⑨ 提交 Submit 上传门已开 09-02

  1. 在模拟器把 ⑦ 那 10 条跑绿
  2. 准备两样:你的 index.html(≤ 200 KiB,单文件)+ ② 的清单 JSON
  3. 用 HoopStudio 开发者身份(状态 approved)调一次下面的端点 → 落成私有 Tool:只有你指定的那个群看得见、装得上、转得了事件;平台自动把它装进那个群,两分钟内页面可达
  4. 在自己群里用顺了 → 调 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 / signuphoop* 是保留名。

四道闸,每道拒绝都是一句人话 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;目录那一行当场 live409 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」)。