# Rudder.chat mini apps — the guide for AI coding tools

You are helping someone build or change a Rudder.chat mini app: a small web app — one HTML page with its CSS, JavaScript and assets, and, if it needs one, code that runs on our servers — published at apps.rudder.chat. Read this whole file before you start. The reference after the first two sections is what the platform enforces when you deploy.

Address: https://rudder.chat/miniapp-guide.md

## How to work

1. **Get the deploy script** (Node.js 18 or later, no dependencies):

   ```
   curl -fsSL https://rudder.chat/miniapp-deploy.mjs -o miniapp-deploy.mjs
   ```

   Then sign this computer in, once:

   ```
   node miniapp-deploy.mjs login
   ```

   It opens Rudder.chat in the person's browser, where they allow this computer; the key is kept on the computer, out of sight, and every later command uses it. Tell the person to allow it in the browser. If login exits with code 3, it is still waiting: run it again once they have allowed it. Never ask the person for a key, and never open, print or copy the saved one. (For CI, a key in the `RUDDER_API_KEY` environment variable is used instead.)

2. **Start from the app as it is now.** Many apps are begun in Rudder.chat's own builder and continued with a tool like you. Download the current version first:

   ```
   node miniapp-deploy.mjs pull <slug>          # into ./<slug>/
   ```

   Read every file before changing anything, and keep what already works. For a new app instead: `node miniapp-deploy.mjs init ./<folder>` writes a working one to start from.

3. **Build in the folder.** Plain files only: `index.html`, any `.css` and `.js`, `assets/`, `tokenharbor.json` (name, colour, address), `instructions.md` (what the app's AI is told) and, if needed, `server.js` (code that runs on our servers). No build step, no npm packages, no other HTML pages, and no network requests from the page: it talks to the platform only through `window.th`, the SDK below.

4. **Deploy and read the answer:**

   ```
   node miniapp-deploy.mjs ./<folder> --json
   ```

   Exit code 0: deployed. 1: refused — every problem names the file, what is wrong and how to fix it; fix them all and deploy again. 2: not signed in, no network, or a wrong argument.

5. **Hand it back.** What you deploy is a draft that only its owner sees, at the link the deploy printed. Publishing is the owner's decision, made in Rudder.chat (Publish), and every app is reviewed before anyone else sees it. Tell the person what you changed and give them the link.

## Must-knows

- **Anything in the page can be read** by whoever opens the app. Answers, scoring, game state and anything else worth protecting belong in `server.js`; the AI's instructions belong in `instructions.md`, which is never sent to the page.
- **The AI is reached only through the platform**: `th.chat.send` in the page, `ctx.ai` in server code. Never call another AI service and never put an API key in the app.
- **People can use a published app without signing in.** Keep everything that does not need the AI working without an account, and never call the AI before the person asks for something. Never put people who are not signed in on a leaderboard, a ranking or a player count.
- **Back closes the app's own screens first.** Open a sheet, an overlay or a second screen with `th.back.push` so the phone's Back closes it instead of leaving the app.
- **Limits**: about 1 MB of text and 2 MB of assets in all; `server.js` up to 200 KB.
- **The rules and the developer terms apply** (Rules, below). Never copy another developer's app.

---

<!-- https://tokenharbor.ai/docs/mini-apps -->

# Mini apps

A mini app is a small web app that runs inside TokenHarbor chat, with your name, your logo and your colours. It is a folder of plain HTML, CSS and JavaScript; TokenHarbor gives it the models. You share a link such as `apps.rudder.chat/your-app`. Anyone can open it; people sign in with a free account when they use its AI.

Two examples to open and take apart: [Menu Snap](https://apps.rudder.chat/menu), which turns a photo of a menu in any language into an interactive English menu with pictures of the dishes, and [Harbor Tales](https://apps.rudder.chat/voyage), a story where the reader picks what happens next.

## What an app can do

- **Can:** use the camera and photos, talk to AI and read pictures, draw pictures, keep each user's data on their account (`th.cloud`), share data between all its users in its own database (`th.db`, 100 MB), and offer memberships.
- **Not yet:** load live data from the internet, or take payments of its own.
- It opens in the browser on any phone or computer — no app store needed.

## Who pays for what

- **Apps run on TH-Rudder by default, which is free** — text, reading photos, and pictures within the daily picture allowance. The limits are the same as in the chat: see [Web chat limits](https://tokenharbor.ai/docs/web-chat/limits).
- **You can pick another model** in your app's **Settings** — any model the chat offers, except the image models. Only TH-Rudder draws pictures for an app.
- **On a paid model, you choose who pays:**
  - **Each user pays for their own use** (the default) — from their own Pass or balance, as in the chat. You pay nothing, however popular the app gets.
  - **You pay**, from your balance, at the model's price — free for everyone who uses it. You set a daily budget; once it is spent, the app stops answering until 00:00 UTC.
- **Every app says who pays**, in its top bar and in the app list: *Free*, *Uses your balance*, or *Paid by the developer*.
- **Anyone with the link can use your app, signed in or not.** Someone who is not signed in uses everything that does not need an account; when they reach the AI (or speech, dictation, a membership, a vote), the app shows them a sign-in sheet — signing up is free, and they come straight back to the app. Their data stays on their device until they sign in. The link previews your app's name and picture in chat apps.

## Your users and members

Like a mini program in a messaging app, your app has its own users: everyone who opens it signed in. You see how many there are, who joined and who came back — each by an id for your app and the name on their account, never an e-mail — in **Settings → Users and members**. Your app can keep each person's data on their account with `th.cloud`, so it follows them from device to device.

Your app can have members too: up to three plans, free or paid, and you can make any of your users a member yourself. Free plans and memberships you give work now; paid plans can be set up but aren't open for buying yet. When they open, people pay from the balance they bought, 30 days at a time; you receive 80% of each sale as Token Harbor credit to spend on Token Harbor (it cannot be withdrawn as cash), and Token Harbor keeps 20%. See the [SDK](https://tokenharbor.ai/docs/mini-apps/sdk) and the [Mini App Terms](/mini-app-terms).

## How you build one

1. In the chat, open **Mini apps** in the sidebar and say what you want to make — "a page where I photograph a menu and get it in English, with the best dishes first" — then press **Make it**. The app is named for you, and it starts being built straight away.
2. It appears in the preview beside the chat as soon as it is ready. On a phone, switch between **Chat** and **Preview** at the top of the screen.
3. Keep talking to change it: "make the buttons bigger", "add a dark mode", "remember my last three menus". Each change is saved as a new version and the preview reloads. **Undo** goes back one version, and says so in the chat. A change that doesn't go through is tried once more by itself before you hear about it. If something in the app breaks while you try it, the preview shows what went wrong with a **Fix it** button.
4. **Settings**, on the bar above the preview, holds everything else: the name, logo and colour, the AI model and who pays, the people building it, the app's files, and deleting it.
5. When it works, tap **Publish**. It is checked first, usually within a minute; once it is approved you get the link to send, and the button becomes **Share**. Anyone with the link can use it, signed in or not — the AI asks for a free account when it is first used. Published apps also appear in **Public apps** on Rudder.chat, most used first. To share yours by its link only, turn off **Show in Public apps** in its settings — before or after publishing.

Now and then an app needs a closer look, which takes longer; the bar says so. Either way you get an e-mail when it is decided — with the link if it is approved, or with what to change if it is sent back.

Nobody but you can open your app until it is approved, and later changes reach everybody else only after you publish them and they are approved too — your published version keeps running meanwhile.

To take a published app down without deleting it, tap **Unpublish** in its settings. Nobody but you can open it from then on, and it leaves Public apps; its data, versions and chats stay. To bring it back, publish it again — it is reviewed again first, like any new version.

Building runs on TH-Rudder, free. For a bigger change you can switch the chat to a stronger model, paid from your balance like any other chat.

Prefer to write it yourself — in VS Code, or with an AI coding tool? Write the app's folder in [the fixed structure](https://tokenharbor.ai/docs/mini-apps/package) and deploy it with one command, or with **Deploy a folder** in the app's **Settings**, which also shows its files read-only. A folder that does not follow the structure is refused with every problem and how to fix it. Whatever writes the app, it only has to use the [SDK](https://tokenharbor.ai/docs/mini-apps/sdk).

## Your first app

This page is a working app on its own. It asks the model a question and shows the answer as it streams in. Next to it, an app's folder needs only a `tokenharbor.json` with its name and address — see [App folder](https://tokenharbor.ai/docs/mini-apps/package).

```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hello</title>
</head>
<body>
<h1 id="hi">Hello</h1>
<textarea id="q" placeholder="Ask anything"></textarea>
<button id="go">Send</button>
<div id="out" style="white-space: pre-wrap"></div>
<script>
  th.ready().then(function (info) {
    if (info.user.name) document.getElementById("hi").textContent = "Hello, " + info.user.name;
  });
  document.getElementById("go").addEventListener("click", function () {
    var out = document.getElementById("out");
    out.textContent = "";
    th.chat
      .send({
        text: document.getElementById("q").value,
        onDelta: function (d) { out.textContent += d; },
      })
      .then(function (r) { out.textContent = r.text; });
  });
</script>
</body>
</html>
```

`th` is there before your first script runs; you do not include anything.

## What an app can and cannot do

Your page runs in a sealed frame. It can use HTML, CSS and JavaScript freely, but:

- It **cannot reach the internet**. No `fetch`, no external scripts, no external images except pictures the chat made. Everything the app needs goes in its folder: scripts, stylesheets, and pictures, fonts and sounds in `assets/`.
- It **cannot see the user's account**: not their email, their balance, their other chats, or their login.
- It talks to TokenHarbor **only through the SDK**: send a message, get a reply and pictures back, keep data on the user's device, on their account, or in the app's own database.

That is what lets a person open an app made by someone they have never met.

## Review

Every version is reviewed before it goes public — usually within a few minutes of **Publish**; an app that needs a closer look can take a few days. The review reads all of your app: its page, its scripts, its pictures and the instructions it gives the AI.

The line is the law, not taste. Brand your app as your own — your name, logo, colours and style — and mention other brands or products freely, as long as you don't pretend to be them.

Your app carries your brand; Token Harbor stays in the background. One line at the foot of every app's page, outside the app itself, says that it is made by an independent developer who is responsible for it, and hosted on Token Harbor — with links to report the app and to the [Mini App Terms](/mini-app-terms). You agree to the Mini App Developer Terms each time you publish.

What is and isn't allowed — with examples of each — is on [What you can build](https://tokenharbor.ai/docs/mini-apps/rules). In short: anything lawful, edgy and borderline included; nothing illegal, sexually explicit, deceptive or harmful, no asking for passwords or card details, no payments outside the app's memberships — and no apps made for children, no political campaigning, no arranging for strangers to meet.

A review ends in one of three ways:

- **Approved** — share the link.
- **Sent back** — something needs changing first, or the app does not work yet (an empty or placeholder page, for example). The note — in your e-mail and on its page — says what to change; change it and publish again.
- **Not approved** — the app broke one of [the rules](https://tokenharbor.ai/docs/mini-apps/rules). **An app that is not approved stays private for good.** You can keep using it yourself, but it cannot be sent for review again, published or shared with anyone; the reason is shown on its page.

When the first check cannot settle it, the app gets a closer look, which can take a few days. You get an e-mail when it is decided. Published apps that break these rules later are taken down.

## Next

- [What you can build](https://tokenharbor.ai/docs/mini-apps/rules) — what is allowed and what isn't, with examples.
- [App folder](https://tokenharbor.ai/docs/mini-apps/package) — the fixed structure, its rules, and deploying from your own tools.
- [SDK reference](https://tokenharbor.ai/docs/mini-apps/sdk) — every call your page can make.
- [API](https://tokenharbor.ai/docs/mini-apps/api) — create and update apps from your own scripts or CI.

## Building together

- **Invite people** in **Settings → People building this app**, by the e-mail of their Token Harbor account — up to 5. They accept on their Mini apps page.
- Everyone shares the app's chats and sees every change in the preview. Each question shows who asked it.
- **Only the owner** publishes, unpublishes, deletes the app, invites people and decides who pays for its AI.
- If two people change the app at the same moment, both changes are kept; if they changed the same part, the second is refused rather than overwriting the first.
- Someone who leaves or is removed no longer sees the app's chats, their own included.

### Team chat

- Once someone joins, the app's project gets a **Team chat**: everyone building the app talks there, live.
- The AI stays quiet until someone mentions **@Bot**. It then reads the whole conversation before it answers, so "do that" means what the team just discussed. It answers questions in words, and changes the app when what to change is clear — when it is not, or the team disagrees, it asks first.
- The person who mentions @Bot pays for that answer, as with any building turn. Changes it makes are saved to Main, like any other.
- Only the people building the app can read the team chat. Use the flag next to a message to report it.

## Branches

- **Main** is the app itself — what gets published. A **branch** is a copy for trying an idea; open **Branches** from the preview bar.
- Each chat works on one branch, and any branch can be previewed. **Undo** works per branch.
- **Merge into Main** combines a branch with whatever changed on Main since. Where both changed the same part of a file, you choose: let the AI combine both, keep Main's, or keep the branch's. **Bring in Main's changes** works the other way.
- **Publishing:** the owner publishes Main, or any branch — Main then becomes that branch. To publish several branches, merge them into Main and publish Main.

---

<!-- https://tokenharbor.ai/docs/mini-apps/package -->

# App folder

Every mini app is one folder in a fixed structure. Whether the app was built by chatting, written by hand in VS Code, or written by an AI coding tool, it is the same folder — and only a folder in this structure can be deployed.

```
my-app/
  tokenharbor.json    the app's name, address, colour and logo      required
  index.html          the page                                      required
  instructions.md     what the app's AI model is told               optional
  server.js           code that runs on the server, never sent       optional
                      to the browser — see Server code
  style.css           any number of .css and .js files,             optional
  js/app.js           in any sub-folders, loaded from index.html
  assets/             pictures, fonts and sounds                    optional
    logo.png
```

Start a new one with the [deploy script](#deploy-with-the-script): `node miniapp-deploy.mjs init my-app` writes this folder with a working page in it.

## tokenharbor.json

```json
{
  "slug": "menu-reader",
  "name": "Menu Reader",
  "tagline": "Point your camera at a menu",
  "color": "#c2410c",
  "logo": "assets/logo.png"
}
```

| Field | | |
|---|---|---|
| `slug` | required | The app's address: `tokenharbor.ai/a/menu-reader`. 3–32 lowercase letters, digits and hyphens, not starting or ending with a hyphen. Deploying a folder whose `slug` is free creates the app; deploying one whose `slug` is yours updates it. |
| `name` | required | 1–40 characters. |
| `tagline` | optional | One line, up to 80 characters. |
| `color` | optional | Six-digit hex, like `#1d4ed8`. Without a logo, the app is shown by its initials on this colour. |
| `logo` | optional | Your own logo, shown on the app's page, its card and its link preview: a PNG, JPG or WebP picture in `assets/`, at most 300 KB — for example `"assets/logo.png"`. SVG is not accepted for the logo. Use only a logo you own or may use. You can also upload one in the app's **Settings**. |
| `aiWatermark` | optional | `false` to leave the mark off the pictures the AI draws for your app ("Generated on TokenHarbor.ai · AI image, for reference only"). On when left out. The same as the switch in the app's **Settings**. |
| `icon` | not used | From before logos. Still accepted, never shown. |

## The files

- **`index.html`** is the only page. Put every screen in it and switch between them with JavaScript.
- **`.css` and `.js` files** can be anywhere in the folder. `index.html` loads each one with a relative path — `<link rel="stylesheet" href="css/style.css">`, `<script src="js/app.js"></script>` — in the order they should run.
- **`assets/`** holds pictures (png, jpg, webp, gif, svg, ico), fonts (woff2, woff) and sounds (mp3, wav, ogg). Refer to them with relative paths from HTML, CSS or JavaScript: `<img src="assets/logo.png">`, `url(../assets/bg.webp)` in `css/style.css`, `"assets/ding.mp3"` in a script.
- **`instructions.md`** is what the app's AI model is told before every message, up to 4,000 characters.

Files that are not part of an app are skipped and listed back to you, not refused: anything starting with a dot (`.git/`, `.vscode/`, `.DS_Store`) and `README`, `LICENSE` and `CHANGELOG`.

When you deploy, every stylesheet and script is placed inside the page and every asset is embedded in it, because the app runs sealed off from the network and cannot load files on its own. You keep writing separate files; nothing about how you write them changes.

## The rules

- **Plain HTML, CSS and JavaScript.** No npm packages, no build tools, no TypeScript, JSX, Vue or Sass. If your project has a build step, build it and deploy the output folder (for example `dist/`), as long as that folder follows this structure.
- **No network.** Nothing loaded from `http(s)://` — no CDN scripts, web fonts or remote pictures — and no `fetch`. Download what you need into the folder. Talk to the AI with [`th.chat.send`](https://tokenharbor.ai/docs/mini-apps/sdk).
- **No modules.** Scripts cannot `import`, `export` or `require`; load each with its own `<script src>`, in order.
- **One page.** No other `.html` files.
- **No data files.** `.json`, `.csv`, `.txt` and the like cannot be fetched; put the data in a `.js` file as a variable.

### Limits

| | |
|---|---|
| Files | 100 |
| One text file | 500 KB |
| All text files | 1 MB |
| One asset | 1 MB |
| All assets | 2 MB |
| Path | 200 characters, 6 levels deep |

## Deploy with the script

The script needs Node.js 18 or later.

```bash
curl -fsSL https://rudder.chat/miniapp-deploy.mjs -o miniapp-deploy.mjs
node miniapp-deploy.mjs login             # once: allow this computer in the browser

node miniapp-deploy.mjs init my-app       # a new folder with a working page
node miniapp-deploy.mjs pull my-app       # or: an app as it is now, to carry on from there
node miniapp-deploy.mjs my-app            # deploy it
```

`login` opens Rudder.chat in your browser; you allow this computer there, and the script keeps the key in your user folder (`~/.config/rudder`, or `%APPDATA%\rudder` on Windows), readable by you alone and never printed. `logout` signs the computer out and stops the key working. For CI and other scripts, put an API key from [Dashboard → API keys](https://tokenharbor.ai/dashboard/api-keys) in `RUDDER_API_KEY` instead.

`pull` downloads an app you already have — one begun in Rudder.chat's builder, say — into a folder: its files, its pictures and its instructions, as its draft is now. It will not write into a folder that is not empty unless you add `--force`.

It prints the app's address when the deploy is accepted, and every problem with how to fix it when it is not. Add `--json` to get the server's answer as JSON. It exits `0` when deployed, `1` when the folder was refused or its `slug` is taken, and `2` for anything else (no key, no network).

You can also deploy from the browser: in the chat, open [Mini apps](/chat/apps), open your app, then **Settings** → **Deploy a folder**.

A deploy changes your draft only. You can open it at the app's address straight away; nobody else sees it until it is [reviewed](https://tokenharbor.ai/docs/mini-apps#review).

## When a deploy is refused

Nothing is changed. The answer lists **every** problem at once, each with the file, a code, what is wrong and what to do:

```json
{
  "ok": false,
  "error": { "code": "invalid_package", "message": "The app does not follow the mini app structure: 2 problems to fix. Fix them and deploy again." },
  "problems": [
    {
      "file": "index.html",
      "code": "external_reference",
      "message": "It loads the script https://cdn.example.com/chart.js from the internet, which a mini app cannot reach.",
      "fix": "Download the script into the folder, load it with a relative <script src>, and make sure it has no import/export."
    },
    {
      "file": "src/App.tsx",
      "code": "file_not_allowed",
      "message": ".tsx files need compiling, and a mini app runs plain files only.",
      "fix": "Build your app to plain .js and .css files and deploy those. If you write JavaScript by hand, use .js with no import/export."
    }
  ],
  "warnings": [],
  "skipped": [".git/HEAD", "README.md"],
  "guide": "https://tokenharbor.ai/docs/mini-apps/package",
  "rules": "A mini app is one folder: tokenharbor.json (required: …"
}
```

| Code | Means |
|---|---|
| `file_not_allowed` | A file that is not part of the structure: `node_modules`, build configuration, `.ts`/`.jsx`/`.vue`, a second `.html`, server code, a data file, an asset outside `assets/`. |
| `missing_manifest` · `bad_manifest` · `manifest_field` | `tokenharbor.json` is missing, is not a JSON object, or a field does not fit. |
| `slug_mismatch` | The folder's `slug` is not the app you are deploying to. |
| `missing_entry` | No `index.html`, or it is empty. |
| `external_reference` | Something is loaded from the internet. |
| `missing_reference` | A path points at a file that is not in the folder. |
| `module_import` · `css_import` | `import`/`export`/`require` in a script, or `@import` in a stylesheet. |
| `file_too_big` · `package_too_big` · `too_many_files` | Over a [limit](#limits). |
| `bad_path` · `bad_file` | A path outside the folder, or content that is neither text nor base64. |

**Warnings** do not stop a deploy: `not_loaded` (a `.css` or `.js` file `index.html` never loads), `network_call` (the code calls `fetch` or similar, which will fail inside the app) and `unknown_field` (a field in `tokenharbor.json` that is not used).

## For AI coding tools

If you are an AI tool deploying a mini app for a user, everything you need is in one file: [rudder.chat/miniapp-guide.md](https://rudder.chat/miniapp-guide.md). The loop:

1. For an app that already exists, start with `node miniapp-deploy.mjs pull <slug>`. Otherwise write the folder in the structure above. Use `th.chat.send` for anything AI; read the [SDK reference](https://tokenharbor.ai/docs/mini-apps/sdk).
2. Run `node miniapp-deploy.mjs <folder> --json` (or `POST /api/miniapps/deploy`, below).
3. If `ok` is `false`, apply each problem's `fix` to the named `file`, and deploy again. The `rules` field states every rule in one paragraph.
4. Repeat until `ok` is `true`. Give the user the `url`.

A refused deploy changes nothing, so retrying is always safe. Deploys are limited to 30 a minute.

## The deploy API

```
POST https://tokenharbor.ai/api/miniapps/deploy
Authorization: Bearer $TOKENHARBOR_API_KEY
Content-Type: application/json
```

```json
{
  "files": {
    "tokenharbor.json": "{\"slug\": \"menu-reader\", \"name\": \"Menu Reader\"}",
    "index.html": "<!doctype html>…",
    "css/style.css": "body { margin: 0 }",
    "assets/logo.png": { "base64": "iVBORw0KGgo…" }
  }
}
```

Each path is relative to the app's folder. Text files are strings; pictures, fonts and sounds are `{ "base64": "…" }`. The whole request can be up to about 4 MB.

`201` means the app was created, `200` that it was updated, and both return `"ok": true` with the app, its new `version`, its `url`, any `warnings` and `skipped` files. `422` is a refused folder, as above. `409` with the code `slug_taken` means the address belongs to another app: choose another `slug`. Both carry `problems` in the same shape, so the loop above handles them alike.

---

<!-- https://tokenharbor.ai/docs/mini-apps/sdk -->

# SDK reference

Every mini app page has a global `th` object. Every method returns a Promise.

## th.ready()

Resolves once the app is connected. Call it first.

```js
const info = await th.ready();
// { app: { slug, name }, user: { id: "u_3f9c…", name }, lang: "zh-CN", params: {} }
```

`params` holds the `?key=value` pairs of the link that opened the app — the ones you put there with [`th.share`](#thshareoptions) — or `{}`.

Everyone who opens your app signed in is one of its users. `user.id` is that person's id **for your app only** — the same person has a different id in every other app — so you can tell your users apart without knowing who they are. `user.name` is the name on their account, or `null`. Apps never receive an email address.

People can also use a published app **without signing in**: then `user.signedIn` is `false`, `user.name` is `null`, and `user.id` is an id this browser keeps for your app. Everything works for them except what needs an account — `th.chat.send`, `th.media.dictate`, new speech, `th.membership.open`, votes, writing to `th.db` (they read and watch it), and in your server code `ctx.ai`, writing to the database and any request to the internet. Those show a sign-in sheet by themselves and reject with the code `sign_in_required`: just stop quietly, the sheet says the rest. `th.cloud` keeps a guest's data on their device; `th.translate`, `th.media.picture` and `th.describe` give them what is already in your app's library. So keep what does not need the AI working without an account, and don't call the AI before the person asks for something. Guests play but are not recorded: keep them off leaderboards, rankings and player counts, and say "Sign in to join the board" where their place would be.

## th.chat.send(options)

Sends one message in your app's conversation and resolves with the full reply.

```js
const r = await th.chat.send({
  text: "Translate this menu into Chinese.",   // required, up to 8,000 characters
  images: [photoDataUrl],                      // optional, up to 4 pictures
  read: true,                                  // optional: the pictures are text to read
  json: true,                                  // optional: the reply must be JSON
  onDelta: (text) => { /* each new piece of the reply */ },
  onImage: ({ url }) => { /* a picture, as soon as it is ready */ },
  onStatus: (s) => { /* "again": the reply is being asked for again */ },
});
// r = { text, images: [url, ...], messageId, json }
```

- **The conversation remembers.** Each message sees the earlier ones, so a follow-up question works, and the conversation lasts the visit: a reload of the page carries on with it. Start over with `th.chat.reset()`.
- **Send what a question is about.** A long conversation is shortened before the model sees it, so a follow-up about something the AI read earlier — a menu, a document — should carry what was read, in short, with the question, and say to answer only from it. Otherwise the model answers from what it knows in general.
- **Your app's instructions** (its `instructions.md`, or whatever you tell its building chat) are given to the model before the conversation, like a chat's own instructions.
- **Pictures.** Ask for one in plain words ("draw a photo of …") and the reply carries it in `images` — ready to put in an `<img>`. When no picture could be made, `images` is empty and `text` says why, in words written for the person — show it rather than a generic "try again". The picture carries a faint two-line mark in its bottom-right corner — see [The mark on AI pictures](#the-mark-on-ai-pictures).
- **Structured answers.** Ask for JSON in your text and pass `json: true`: the reply comes back already read, in `r.json` (`null` if it still is not JSON). A long reply is written in pieces and joined, and once in a while the join breaks the JSON; a reply that does not read as JSON is then asked for once more in its place, automatically. While that happens `onStatus("again")` is called and the text from `onDelta` starts over — reset any progress you show. Without `json`, read the text yourself with `th.util.parseJson(r.text)`.
- **Photos in.** `images` takes JPEG, PNG, WebP or GIF data URLs, each under 1 MB. `th.media.pickImage()` gives you one of the right size.
- **Photos to read.** When the point of a photo is the text on it — a menu, a sign, a printed page — pass `read: true`. It is answered by a model that is better at reading text in photos, including handwriting and non-Latin scripts.
- One message is answered at a time; a second `send` waits for the first.
- Errors reject with an `Error` whose `code` says why — for example `rate_limited`, or a daily limit being reached. Show `error.message` to the user; it is written for them.

## th.chat.reset()

Forgets the conversation. The next `send` starts a new one.

## th.media.pickImage(options)

Opens the camera or photo library and resolves with a JPEG data URL, or `null` if the person cancelled. Call it from a tap or click.

```js
const photo = await th.media.pickImage({ camera: true });   // camera: open the camera on phones
if (photo) await th.chat.send({ text: "What is in this picture?", images: [photo] });
```

Options: `camera` (default `false`), `maxSide` (default `1600` pixels), `quality` (default `0.82`).

## th.media.dictate()

Listens to the person and resolves with what they said, as text — or `null` if they cancelled or nothing was heard. Call it from a tap or click, such as a microphone button.

```js
const words = await th.media.dictate();
if (words) await th.chat.send({ text: words });
```

- **Token Harbor records, not your app.** Your app never gets the microphone. The page around it asks for permission, shows its own listening bar with **Stop** and **Cancel** while the microphone is open, and gives your app only the text. Draw a button and a waiting state; no recording screen of your own.
- **Started from a tap**, it starts listening at once. Called without one, the bar asks the person to press **Start** first.
- One recording at a time, up to 2 minutes. It counts toward the person's voice limits, the same as the chat's microphone.
- Errors reject with a `code`: `mic_blocked` (no permission), `no_mic`, `busy` (already listening), `rate_limited`, `voice_daily_limit`, `transcription_unavailable` (voice is off for now), `transcription_failed`. Show `error.message`; it is written for the person.

## th.media.speak(text, options)

Reads `text` aloud in the platform's own voice — for any language, on any phone, whether or not the phone has a voice of its own for it. Only when that cannot be had does it fall back to the phone's own voices. Call it from a tap or click, such as a **Read aloud** button. It resolves when the reading ends, with `{ ok: true }`, or `{ ok: false, reason }`:

| `reason` | What happened | Say to the person |
|---|---|---|
| `no_voice` | No voice for that language, here or on this phone | Show the words on screen instead |
| `not_started` | The reading did not start | Check the volume; show the words instead |
| `stopped` | It was stopped, or another reading began | Nothing |
| `unsupported` | This browser cannot read aloud | Show the words instead |
| `failed` | Anything else | Try again |

```js
button.onclick = async () => {
  const r = await th.media.speak("ผัดกระเพรา หนึ่งจาน", { lang: "th-TH" });
  if (!r.ok && r.reason !== "stopped") showMessage("This phone could not read it aloud. Show the screen instead.");
};
```

Options: `lang` — the language's code, such as `th-TH`, `ja-JP` or `zh-CN` (left out, it is told from the words); `rate` — from `0.5` to `2` (default `0.9`). Up to 1,500 characters at a time. `th.media.stopSpeaking()` stops it.

The same words, in the same language and at the same pace, are made into speech once and kept for everyone using your app: after the first person they play at once and cost nothing. A small speaker beside a name in another language — a dish, a street, a product — is a good use of it.

Use it rather than `speechSynthesis` directly: it plays on an iPhone with its silent switch on, and it reports a reading that never started instead of staying quiet.

## th.media.picture(options) / th.media.votePicture(id, good)

A picture of something — a dish, a plant, a place — from your app's shared picture library. The first person to ask for a thing gets a free photo found for it, or one the AI draws when there is none; everyone after gets the same picture at once, for free.

```js
const pic = await th.media.picture({
  key: "ผัดกระเพรา",                 // what makes two things the same
  subject: "stir-fried holy basil", // what the picture shows
  describe: "minced pork, chilli, a fried egg on rice",
  search: "Phat kaphrao",           // its usual English name, for a free photo
  kind: "food",
});
if (pic) img.src = pic.url;          // pic.credit: show it under a photo
```

A picture the AI draws takes ten to twenty seconds the first time. Until it comes, show that it is being drawn — a soft moving shimmer and a short word such as "Drawing…" — rather than an empty box, and when it comes back `null`, leave its place out.

A found photo shows the thing as it usually is, which is not always how your app describes it: the usual green papaya salad comes with crab, and a restaurant's may come with shrimp. When the picture must match your own description, leave out `search`, and the AI draws it from `describe`, with nothing the description does not name.

It resolves with `{ id, url, source, credit, good, bad, mine }` — `source` is `"photo"` or `"ai"`, `mine` is the person's own vote — or `null` when there is no picture to give. Ask for several at once; they come as they are ready. A picture the AI drew carries the mark below; its `url` is then the marked picture itself, a `data:` address — set it on an `<img>` as it is.

### The mark on AI pictures

Every picture the AI draws for your app — from `th.media.picture` or in a `th.chat.send` reply — has two faint lines in its bottom-right corner: **Generated on TokenHarbor.ai** and **AI image, for reference only**. They are drawn into the picture before your app receives it, so a picture someone saves keeps them. A found photo has none.

It is on by default. To turn it off, switch off **Watermark on AI pictures** in your app's **Settings**, or write `"aiWatermark": false` in `tokenharbor.json`; like any change, it reaches people once the app is published.

Let people say when a picture is wrong: `th.media.votePicture(pic.id, false)` (or `true` for right). When more people say a picture is wrong than right, it is taken out, and the next person to ask gets a new one. A free photo that was voted out is not used again.

## th.describe(options)

What a dish is, for someone reading a menu in a language they do not know: what it actually is, what its name says word for word and why (when the words are not what it is — 虎皮青椒 is "tiger-skin peppers"), and what it usually has.

```js
const about = await th.describe({
  key: "Sichuan|虎皮青椒",          // what makes two dishes the same
  subject: "Blistered Green Peppers", // what it is
  original: "虎皮青椒",               // as printed
  cuisine: "Sichuan",
});
// about = { meaning, literal, story, ingredients: [...], allergens: [...], sauces: [...], calories } or null
```

It is written once for a key and kept for everyone using your app, so after the first person it comes back at once. It is in English: pass the parts you show through `th.translate`. It describes the dish as it is usually made, not this kitchen's recipe — say so beside it, and tell people with an allergy to ask the staff. A story that is a legend is told as one.

Let people say when it is wrong: `th.voteDescription(key, false)` (or `true` for right). When more people say it is wrong than right, it is written again — knowing what was wrong — the next time anyone asks with `th.describe`. Any cuisine, any language: nothing about a country is built in.

## th.back.push(onBack)

A screen your app opens over itself — a sheet, an overlay, a full-screen view, a picture shown large — should close when the person goes back (the edge swipe on a phone, the back button), not leave your app and lose what they were doing.

```js
function openSheet() {
  sheet.hidden = false;
  const layer = th.back.push(() => { sheet.hidden = true; }); // Back closes it
  closeButton.onclick = () => { sheet.hidden = true; layer.close(); }; // so does your own button
}
```

`onBack` runs when the person goes back while it is the top screen; close it there. When your app closes it another way, call `layer.close()` so going back next time does what the person expects. Screens opened on top of each other close one at a time, the last first. A `<dialog>` opened with `showModal()` needs nothing: Back closes it.

## th.translate(texts, lang)

Your app's own words in another language. Write them in English and pass them as `{ key: text }`; you get the same keys back, translated. Each text is translated once per language and kept for everyone using your app, so after the first person the words come back at once.

The page opens in the reader's language, not in English first: for someone whose browser is not in English, the page stays hidden until the translations your app asks for as it starts are back — up to 1.5 seconds from when it asks, and 400 ms after `th.ready()` when it asks for none. So ask for your interface words right after `th.ready()`, before drawing them.

Texts are translated by what they mean, never word for word: a dish called "Tiger Skin Peppers" becomes blistered green peppers in the other language. When you need the words themselves — what a name literally says, to explain it — pass `{ exact: true }` as the third argument: `th.translate({ literal: "pockmarked grandmother tofu" }, lang, { exact: true })`. Anything the first try misses is asked for again before the answer comes back; a text that still could not be translated comes back as it was.

```js
const EN = { scan: "Scan a menu", order: "My order" };
const { lang } = await th.ready();               // the person's language
const words = await th.translate(EN, lang);      // { scan: "สแกนเมนู", order: "รายการของฉัน" }
```

`lang` is a code such as `th`, `zh-CN` or `es`; English comes back as it was. Words you add later are translated the first time they are asked for. Up to 500 texts, of up to 1,000 characters each, at a time.

## th.currency.rates(base)

Today's exchange rates from one currency, for showing prices in the currency a person chooses. Pass an ISO 4217 code such as `"THB"`; you get how many units of each currency one unit of it is worth. About 300 currencies are covered, updated daily, the same for everyone.

```js
const { rates, date } = await th.currency.rates("THB");   // rates.USD ≈ 0.0298
const shown = new Intl.NumberFormat(lang, { style: "currency", currency: "USD" }).format(120 * rates.USD);
```

Convert what the person reads; keep anything shown or read to someone else — a waiter, a shop — in the original currency, which is what they will charge. An unknown code is refused with `bad_currency`; when no rate source answers, the call fails with `unavailable` and the app should keep showing the original prices.

## th.session.get(key) / th.session.set(key, value) / th.session.remove(key)

Keeps JSON for this visit, until the tab is closed. It survives a reload — and, while you build, every new version of your app, which reloads the preview. Keep what the person is in the middle of in it: the photo they picked, what the AI made of it, their choices, the screen they are on. Then put it back when your app starts, and they carry on where they were.

```js
await th.ready();
const kept = await th.session.get("order");   // null on a fresh visit
if (kept) restore(kept);
// …and each time it changes:
await th.session.set("order", { photo, menu, picked });
```

Keys are 1–64 letters, digits, `_`, `.` or `-`. A value can be up to 2 MB of JSON; a tab has about 5 MB in all. The live app, Main and each branch keep their own.

## th.storage.get(key) / th.storage.set(key, value)

Keeps a little JSON on the person's device, visible only to your app.

```js
await th.storage.set("prefs", ["no spicy food"]);
const prefs = await th.storage.get("prefs");   // null if never set
```

Keys are 1–64 letters, digits, `_`, `.` or `-`. A value can be up to 200 KB of JSON.

## th.cloud.get(key) / th.cloud.set(key, value)

Like `th.storage`, but kept on the person's account, so it is there on every device they use your app on.

```js
await th.cloud.set("progress", { level: 3, stars: 41 });
const progress = await th.cloud.get("progress");   // null if never set
await th.cloud.set("progress", null);              // removes it
```

Each person reads and writes only their own data. Up to 100 keys per person, 64 KB of JSON per key.

## th.server — your app's server code

```js
const { right } = await th.server.call("checkAnswer", { question: "q1", answer: "Paris" });
```

Runs an exported function of the app's `server.js` on Rudder.chat's servers, never in the page, and returns what it returned. Its code, its private data and its rules never reach the browser. See [Server code](https://tokenharbor.ai/docs/mini-apps/server).

## th.db — your app's database

Every app has its own database, shared by everyone who uses it: a leaderboard, posts and comments, a list a group keeps together. Data is kept as records of JSON, in collections you name.

```js
// Add a record: it belongs to the person who wrote it.
const me = await th.ready();
await th.db.add("scores", { name: me.user.name, score: 1200 });

// Read: everyone sees every record.
const top = await th.db.list("scores", { orderBy: "score", desc: true, limit: 10 });
// { items: [{ id, by, mine, value: { name, score }, created_at, updated_at }, …], next: 10 | null }

const mineOnly = await th.db.list("scores", { mine: true });
const easy = await th.db.list("puzzles", { where: { level: "easy" } });
const howMany = await th.db.count("scores");

// Change or remove: only your own records.
await th.db.update("scores", top.items[0].id, { name: me.user.name, score: 1500 });
await th.db.remove("scores", top.items[0].id);
```

- **Who can do what.** Everyone using the app reads every record. A record can be changed or removed only by the person who wrote it — and by you and the people building the app with you, so you can clear out what should not be there.
- **Who wrote it.** `by` is the writer's id in your app (the same as `user.id` from `th.ready()`), and `mine` is `true` on the reader's own records. Nobody's account is ever shown. To show a name next to a record, save the name in it.
- **Nothing private.** Every user can read the database, so keep a person's own data in `th.cloud` instead.
- **Finding records.** `where` matches fields exactly (up to five). `orderBy` is `created_at` (the default, newest first), `updated_at`, or one of your fields (smallest first unless `desc: true`). `limit` is up to 100; pass the `next` you got back as `offset` to read on.
- **Test data stays apart.** While you build, the preview uses its own copy of the database; your published app's users never see what you wrote while testing, and you start your users' data clean.

### Live updates: `th.db.watch`

Show other people's changes the moment they happen — a leaderboard that moves, a feed that fills, a board a group edits together. Nothing to poll: the change is pushed to everyone watching.

```js
const live = th.db.watch("scores", (change) => {
  if (change.op === "add") showScore(change.record);          // the record, as th.db.list returns it
  if (change.op === "update") replaceScore(change.record);
  if (change.op === "remove") dropScore(change.id);
  if (change.op === "resync") reloadScores();                 // the connection dropped and came back: read again
});
// …and when the screen goes away:
live.stop();
```

Your own changes arrive too, with `mine: true`. Up to 20 watches at a time.

| | Limit |
|---|---|
| Database size | 100 MB per app (each record counts its JSON plus 64 bytes) |
| One record | 64 KB of JSON |
| Collection and field names | 1–40 letters, digits or `_` (collections may also use `-`) |
| Speed | 60 changes and 300 reads a minute per person |

When the database is full, adding fails with the code `db_full`; remove records you no longer need.

## th.membership.status() / th.membership.open()

Give your app members. Set up to three plans in your app's **Settings → Membership plans** — free, or monthly-paid — then:

```js
const m = await th.membership.status();
// { active: true, owner: false, plan_name: "Pro", expires_at: "2026-11-09T…", source: "free" | "granted" | "paid" }
if (!m.active) {
  const after = await th.membership.open();   // shows your plans; resolves when the person closes it
  if (after && after.active) unlockProFeatures();
}
```

`open()` is drawn by Token Harbor, outside your app: your code cannot see or press anything in it. A free plan is joined with one tap and has no end (`expires_at: null`). You can also make any of your users a member — for 30 days, a year or with no end — from **Settings → Users and members**. You are always a member of your own app (`owner: true`).

**Paid plans aren't open yet.** You can set them up now and people see them, marked as not open yet. Once paid memberships open, people pay from the balance they bought — not free credit — for 30 days at a time, without automatic renewal; you get 80% of each sale as Token Harbor credit, and Token Harbor keeps 20%.

## th.share(options)

Shares your app's link: through the phone's share sheet where there is one, otherwise by copying the text and the link. Call it from a tap.

```js
const r = await th.share({
  text: "I cracked door 5 in 7 messages. Can you beat that?",
  query: { door: 5 },               // optional: becomes ?door=5 in the link
});
// r.how: "shared" | "copied" | "cancelled" | "failed"; r.url is the link
if (r.how === "copied") showToast("Link copied");
```

Whoever opens the link gets `query` back in `th.ready().params` (as text), so a shared link can open on what was shared — a level, a spot on a board, a room to join. Offer sharing where someone has something to show or someone to bring in.

## th.util.parseJson(text)

Finds the first JSON object or array in `text` and parses it, ignoring code fences and anything around it. Returns `null` if there is none.

## Limits

| | Limit |
|---|---|
| Page | One HTML file, up to 500 KB |
| Instructions | 4,000 characters |
| Message | 8,000 characters, 4 pictures of up to 1 MB each |
| Voice (`th.media.dictate`) | 2 minutes a recording; the person's own voice limits |
| Kept for the visit (`th.session`) | 2 MB per key, about 5 MB per tab |
| Speed | The same per-person limits as the chat |
| Network | None — everything the page needs must be inside it |
| Database (`th.db`) | 100 MB per app, 64 KB per record |
| Account data (`th.cloud`) | 100 keys per person, 64 KB per key |
| Images you can show | `data:` and `blob:` URLs, and pictures the chat made |

---

<!-- https://tokenharbor.ai/docs/mini-apps/server -->

# Server code

Everything in your app's page — `index.html` and the scripts and styles it loads — is sent to the browser of every person who opens it, and anyone can read it there. That is how every web page works.

**server.js** is different. It sits at the top of your app's folder, runs on Rudder.chat's servers, and is never sent to anyone. Only what its functions return leaves the server. Put there whatever is the value of your app or must not be seen or changed by the people using it:

- the app's rules and scoring, and anything a player must not be able to fake;
- answers, solutions and hidden game state;
- prices, formulas and other logic that took you work;
- the questions your app asks the AI, and what it does with the answers;
- calls to other services on the internet.

What needs no protecting — drawing the screen, animations, talking to the AI with `th.chat.send` — stays in the page.

## Write a function

`server.js` is a JavaScript module. Each function the page can call is exported:

```js
// server.js
export async function checkAnswer(args, ctx) {
  const found = await ctx.db.list("$answers", { where: { question: args.question } });
  const right = found.items.length > 0 && found.items[0].value.answer === args.answer;
  if (right) await ctx.db.add("scores", { player: ctx.user.id, points: 10 });
  return { right };
}
```

## Call it from the page

```js
const { right } = await th.server.call("checkAnswer", { question: "q1", answer: "Paris" });
```

`args` and what the function returns are JSON. When the function throws, `th.server.call` rejects with an `Error` carrying the same message — say why in your message, and the page can show it.

## What a function gets

| | |
|---|---|
| `args` | What the page sent. Never trust it to say who is asking. |
| `ctx.user.id` | The caller's id in your app — the same id `th.ready()` gives the page. This one comes from Rudder.chat, so it can be trusted. |
| `ctx.user.builder` | `true` for you and the people building the app with you. |
| `ctx.user.signedIn` | `false` for someone using the app without signing in. For them your code reads the app's database and nothing more: writing to it, `ctx.ai` and any request to the internet throw `sign_in_required`, and the page asks them to sign in. Don't record guests on leaderboards, rankings or player counts. |
| `ctx.db` | Your app's [database](https://tokenharbor.ai/docs/mini-apps/sdk), as the app's server: `add`, `get`, `list`, `count`, `update`, `remove`, the same as `th.db`. |
| `ctx.ai` | The AI, asked from the server: `await ctx.ai({ text, system, json })` → `{ text, json }`. See below. |
| `fetch` | The internet, as in any JavaScript server — for people who signed in. A guest's request throws `sign_in_required` before it leaves. |

## Asking the AI from the server

```js
export async function judge(args, ctx) {
  const { json } = await ctx.ai({
    system: "You judge riddle answers. Reply {\"right\": true|false}.",
    text: `Riddle: ${args.riddle}\nAnswer: ${args.answer}`,
    json: true,
  });
  return { right: json?.right === true };
}
```

What you ask and what comes back pass between Rudder.chat's servers only — the page sees only what your function returns. `system` is optional: without it the AI follows your app's instructions. With `json: true` the reply comes back read in `json` (null if it was not JSON). It is free for your app, like `th.chat.send`, and counted against the person using the app: up to 30 questions a minute each, and 300 a minute for the whole app. Text up to 32,000 characters. Pictures and files are sent with `th.chat.send` from the page.

## Private data

A collection whose name starts with `$` — `$answers`, `$deck`, `$rules` — is read and written **only by server.js**. The page cannot read it with `th.db`, even yours.

A collection whose name starts with `@` — `@board`, `@firsts` — is **read by everyone and written only by server.js**. Nobody using the app can add to it, change it or remove from it, not even you from the page. Put there whatever must be true for everyone: a leaderboard written after checking the score, the first person to make something, results everyone shares. The page reads and watches it with `th.db` as usual. A record's `mine` is never true there (server.js wrote it): store the person's id (`ctx.user.id`) in the record and compare it with the id `th.ready()` gives the page.

## Test and live

While you build, your preview runs the server code you are working on, with the app's test data. Once the app is approved and published, people using it run the published server code, with its live data. Publishing reviews `server.js` with the rest of the app.

## Limits

- One file, at most 200 KB. No `import` of other files and no npm packages: write what you need in the file.
- Per call: 100 ms of computing, 20 requests to the internet, 30 seconds in all; 64 KB sent to it and 1 MB returned.
- Up to 120 calls a minute per person.
- `server.js` follows the same [rules](https://tokenharbor.ai/docs/mini-apps/rules) as the rest of the app: it may not send people's data elsewhere, attack or spam other sites, or do work that has nothing to do with the app.

---

<!-- https://tokenharbor.ai/docs/mini-apps/design-kit -->

# Design kit

Every mini app can use the design kit: a stylesheet of tokens and components, six fonts, more than 2,000 icons, an animation library, and confetti and sound effects. You load it from `index.html` by paths that start with `th-kit/`. The files are not in your folder: when your app is built, the platform puts them into the page, so they work in the sealed frame like your own files do.

```html
<link rel="stylesheet" href="th-kit/ui.css">
<link rel="stylesheet" href="th-kit/fonts/bricolage-grotesque.css">
<script src="th-kit/motion.js"></script>
<script src="th-kit/fx.js"></script>
```

The kit is a floor to build on, not a look to wear. Set `--accent` to your app's colour, pick a font, and give the app one element people will remember.

## Styles: `th-kit/ui.css`

**Tokens.** Set them on `:root` to make the kit yours.

| Token | What it is |
|---|---|
| `--accent`, `--on-accent` | Your colour, and the text colour on it |
| `--bg`, `--surface`, `--surface-2`, `--surface-3` | Page and card backgrounds |
| `--text`, `--text-2`, `--text-3`, `--border`, `--border-strong` | Text and lines |
| `--ok`, `--warn`, `--bad` | Status colours |
| `--font-body`, `--font-display`, `--font-mono` | Fonts |
| `--text-xs` … `--text-4xl` | Type sizes; the large ones scale with the screen |
| `--space-1` … `--space-8`, `--radius` | Spacing and corner radius |
| `--shadow-1` … `--shadow-5` | Layered shadows |
| `--ease-out`, `--ease-in-out`, `--ease-drawer`, `--ease-spring` | Motion curves |
| `--dur-press`, `--dur-fast`, `--dur`, `--dur-slow` | Motion durations, 120 ms to 320 ms |
| `--gray-1` … `--gray-12`, `--violet-9`, `--grass-11`, … | Colour scales, in light and dark |

Light and dark follow the device. Put `data-theme="dark"` or `data-theme="light"` on `<html>` to fix one, and `data-neutral="slate"`, `"sand"` or `"mauve"` for warmer or cooler grays.

**Components.**

| Class | What it is |
|---|---|
| `th-btn` + `th-btn-primary`, `-soft`, `-ghost`, `-outline`, `-sm`, `-lg`, `-icon`, `-block` | Buttons; add `is-loading` while waiting |
| `th-card`, `th-card-tap` | A card, and one you can tap |
| `th-input`, `th-textarea`, `th-select`, `th-label` | Form fields |
| `th-chip` (`is-on`), `th-badge`, `th-avatar` | Small parts |
| `th-tabs` | Segmented tabs: buttons with `aria-selected="true"` on the chosen one |
| `th-list` | Rows divided by lines |
| `th-progress` | A bar: `style="--value: 60%"` with a `<span>` inside |
| `th-modal`, `th-sheet` | On a `<dialog>` opened with `showModal()` |
| `th-toast` | Add `is-shown` to show it |
| `th-spinner`, `th-skeleton`, `th-dots` | Loading states |
| `th-empty` | An empty or first-time screen |
| `th-container`, `th-stack`, `th-row`, `th-grid` | Layout |

**Effects.** Use one or two, where they mean something.

| Class | What it does |
|---|---|
| `th-enter` | Fades and rises in; `style="--i: 1"`, `2`, `3` … staggers siblings |
| `th-pop`, `th-shake`, `th-float`, `th-pulse` | A pop in, a shake, a gentle float, a pulsing ring |
| `th-glow`, `th-glass`, `th-gradient-text` | A coloured glow, frosted glass, gradient text |
| `th-shine`, `th-beam` | A light sweeping across, a beam running round the border |
| `th-aurora`, `th-grain` | A slow wash of colour, film grain |
| `th-marquee` | A row that scrolls by itself |

Everything respects *reduce motion*: movement becomes a gentle fade, never nothing at all.

## Fonts: `th-kit/fonts/NAME.css`

| File | Font family | Good for |
|---|---|---|
| `inter.css` | Inter | Text in any app |
| `bricolage-grotesque.css` | Bricolage Grotesque | Bold, friendly titles |
| `fraunces.css` | Fraunces | Warm, editorial titles |
| `space-grotesk.css` | Space Grotesk | Technical, modern titles |
| `nunito.css` | Nunito | Soft and rounded: children, casual games |
| `jetbrains-mono.css` | JetBrains Mono | Code, scores, numbers |

They carry Latin letters only. Keep the system fonts after them so other scripts still show:

```css
:root { --font-display: "Fraunces", var(--font-body); }
```

## Icons

Write an `<i>` with the name of any [Lucide](https://lucide.dev/icons) icon. It becomes the icon itself, inline, in the text colour:

```html
<button class="th-btn th-btn-primary"><i data-icon="sparkles"></i> Make it</button>
```

Its classes and `aria-label` carry over. For icons you create in JavaScript, list them on `icons.js`, then call `icon(name)`:

```html
<script src="th-kit/icons.js?heart,star,x"></script>
<script>
  button.innerHTML = icon("heart") + " Like";
</script>
```

If you use a name that does not exist, the build stops and suggests the nearest names.

## Animation: `th-kit/motion.js`

`window.anime` is [anime.js](https://animejs.com) v4: `animate`, `createTimeline`, `createTimer`, `stagger`, `spring`, `eases`, `waapi`, `svg` and `utils`.

```js
anime.animate(".card", {
  translateY: [12, 0],
  opacity: [0, 1],
  delay: anime.stagger(40),
  duration: 400,
  ease: "outExpo",
});
```

`window.autoAnimate(list)` makes a list animate its own items as they are added, removed and moved ([AutoAnimate](https://auto-animate.formkit.com)).

## Confetti, sounds and small moments: `th-kit/fx.js`

| Call | What it does |
|---|---|
| `fx.confetti(options)`, `fx.celebrate()`, `fx.burst(element)` | Confetti: a burst, a celebration, or a little burst from an element |
| `fx.sound(name)` | `tap`, `pop`, `select`, `success`, `fail`, `coin`, `whoosh`, `magic`, `levelUp`, `tick` — synthesised, no sound files |
| `fx.mute(true)` | Turns sounds off; give people a switch |
| `fx.pop(el)`, `fx.shake(el)`, `fx.flash(el)`, `fx.enter(el)` | Quick reactions to what someone did |
| `fx.count(el, 1280)` | Counts a number up |
| `fx.type(el, text)` | Writes text a letter at a time |
| `fx.haptic()` | A short vibration, where the phone allows it |

## Good taste, in short

- Start from what the app is about. A kitchen timer, a kids' quiz and a tool for traders should look nothing alike.
- Animate what people do — adding, confirming, winning — not every card on the page. Keep interface motion between 120 ms and 300 ms, and save confetti for real wins.
- One memorable element, everything else quiet.

## Credits

The kit is built from open source, used under its licences:

- [anime.js](https://github.com/juliangarnier/anime) — MIT, Julian Garnier
- [AutoAnimate](https://github.com/formkit/auto-animate) — MIT, FormKit
- [canvas-confetti](https://github.com/catdad/canvas-confetti) — ISC, Kiril Vatev
- [ZzFX](https://github.com/KilledByAPixel/ZzFX) — MIT, Frank Force
- [Open Props](https://github.com/argyleink/open-props) — MIT, Adam Argyle
- [Radix Colors](https://github.com/radix-ui/colors) — MIT, Radix
- [Lucide](https://github.com/lucide-icons/lucide) — ISC, Lucide Contributors
- Inter, Bricolage Grotesque, Fraunces, Space Grotesk, Nunito and JetBrains Mono — SIL Open Font License 1.1, through [Fontsource](https://fontsource.org)

The design guidance the building AI follows draws on Anthropic's frontend-design skill (Apache-2.0) and Emil Kowalski's animation skills (MIT).

---

<!-- https://tokenharbor.ai/docs/mini-apps/rules -->

# What you can build

Mini apps are reviewed against **the law**, plus three rules of our own: no apps made for children, no political campaigning, and no arranging for strangers to meet. Taste is yours. Your own brand is welcome, and so are romance, flirting, dark humour and edgy topics. This page lists what crosses the line. It says the same as the [Mini App Developer Terms](/mini-app-terms) (B3), which you accept each time you publish.

## Allowed

- **Your own brand.** Your app's name, logo, colours and style. You can mention other brands and products — "works with Google Sheets", "compare iPhone and Pixel" — as long as you don't pretend to be them.
- **Romantic, flirtatious, suggestive or edgy content** that stops short of sexually explicit: dating coaches, romance stories, horror, roasting games.
- **Games of chance played for fun**, with no real money: dice, spin the wheel, fortune telling.
- **General information about health, law or money**, with a plain note that it is not professional advice.
- **Charging people**, through the app's memberships on Token Harbor.

## Not allowed

### Illegal goods and services

- Weapons, drugs, or anything else illegal — selling it, or helping people get or use it.
- Gambling, betting or lotteries for real money.
- Pirated content, or selling copyrighted work that isn't yours.
- Copying another developer's app — its code, design, content or AI instructions — and publishing it as yours, even with small changes.

*Not allowed:* a betting tipster that takes stakes; a "free movies" app. *Fine:* a quiz with points and no money.

### Sexually explicit content

- Sex acts or nudity, written or shown; pornography.
- Anything sexual involving a minor — never. It is removed and reported to the authorities.

*Fine:* a flirting coach, a romance story that fades to black.

### Harm

- Violence, self-harm, hate, harassment or extremism.
- Instructions that put people in danger.

### Deceiving people

- Phishing, scams, fake prizes or giveaways, misleading claims.
- Passing the app off as another real company, person, government or official body so that people trust it.

*Not allowed:* "Bank of X — verify your account". *Fine:* "Statement Helper — reads your Bank of X statements", under your own name.

### Asking for secrets

- Passwords, payment card numbers, bank details, government ID numbers or health records.
- Personal data the app does not need.

### Money taken another way

- Payment links, bank transfers, crypto addresses or anything else outside the app's memberships. People pay through Token Harbor only.
- Investment, trading or crypto schemes that promise returns.

### Posing as a professional

- Presenting the app as a licensed doctor, lawyer or financial adviser.
- Medical, legal or financial instructions given as if they were a professional's.

### Made for children

- Apps made for children under 13 to use themselves. Token Harbor isn't for them.

*Fine:* a lesson planner for teachers, a bedtime-story writer a parent reads aloud.

### Political campaigning

- Campaigning for or against a candidate, party or ballot measure: election ads, campaign fundraising, getting out the vote for one side.

*Fine:* neutral information about how to vote, news, debate practice.

### Meeting strangers

- Arranging for strangers to meet in person: dating or hookup matching, meetups between people who found each other in the app.

*Fine:* dating advice, an AI companion, planning with people you already know.

### Getting around Token Harbor

- Using the app to bulk-generate content, or to resell the AI as a service of your own.
- Malware or deliberately hidden code, or trying to break out of the app's frame.
- Telling the AI to ignore its rules, or writing to the reviewers inside the app.
- An app made for what the chat itself would refuse.

## Not ready yet

A blank, broken or placeholder page isn't a rule broken: it is sent back until it works.

## What happens

- **Before it goes public.** Every version is reviewed when you press **Publish** — usually within minutes, a few days when it needs a closer look.
- **Sent back.** Something needs changing first. The note on the app's page and in your e-mail says what. Change it and publish again.
- **Not approved.** The app broke a rule above. It stays private for good: you can still use it yourself, but it can't be published or shared. The reason is on its page.
- **After it is public.** Anyone can report an app from the link under it. A person looks at every report. An app that breaks these rules is taken down, and you are told what and why; you can reply to have it looked at again.
- **Serious or repeated breaks.** We may stop you publishing mini apps.
- **Copying.** An app reported as a copy of someone else's work is taken down when the complaint holds up, and people who copy repeatedly can no longer publish. The same protects you: if someone copies your app, report it as **Copyright or trademark** from the link under it ([Mini App Terms](/mini-app-terms), Part C).

When you're not sure, ask before you build: support@tokenharbor.ai.

---

<!-- https://tokenharbor.ai/docs/mini-apps/api -->

# Mini apps API

Everything the dashboard does with your apps is also an API, so you can deploy from a script, a build step or CI.

Authenticate with an API key from [Dashboard → API keys](https://tokenharbor.ai/dashboard/api-keys):

```
Authorization: Bearer $TOKENHARBOR_API_KEY
```

A key only ever manages your own apps. Requests and responses are JSON.

## The app object

```json
{
  "slug": "menu-reader",
  "url": "https://tokenharbor.ai/a/menu-reader",
  "name": "Menu reader",
  "tagline": "Point your camera at a menu",
  "color": "#c2410c",
  "logo": "assets/logo.png",
  "logo_url": "/a/menu-reader/logo?v=draft&t=2026-09-23T08%3A00%3A00Z",
  "system_prompt": "You help travellers read menus…",
  "status": "draft",
  "review_note": null,
  "html_bytes": 20562,
  "updated_at": "2026-09-23T08:00:00Z",
  "published_at": null,
  "submitted_at": null,
  "html": "<!doctype html>…",
  "files": {
    "tokenharbor.json": "{\n  \"slug\": \"menu-reader\", …",
    "index.html": "<!doctype html>…",
    "assets/logo.png": "data:image/png;base64,iVBORw0KGgo…"
  }
}
```

`status` is one of `draft`, `in_review`, `not_approved`, `published`, `published_with_unreviewed_changes`, `taken_down`. `not_approved` is final: the app stays private to you and cannot be sent for review again. `html` (the page as it runs) and `files` (the app's folder, with pictures, fonts and sounds as `data:` URLs) are included when you ask for one app.

## Create an app

```bash
curl -X POST https://tokenharbor.ai/api/miniapps \
  -H "Authorization: Bearer $TOKENHARBOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"slug": "menu-reader", "name": "Menu reader"}'
```

`slug` is the address: 3–32 lowercase letters, digits and hyphens. You can also send `tagline`, `color`, `system_prompt` and `html`. You can keep up to 20 apps.

## Deploy a folder

The usual way to update an app from your own tools is to deploy its whole folder — see [App folder](https://tokenharbor.ai/docs/mini-apps/package) for the structure, the deploy script, `POST /api/miniapps/deploy`, and the refusals an AI tool can fix and retry on.

## Update the page

```bash
curl -X PATCH https://tokenharbor.ai/api/miniapps/menu-reader \
  -H "Authorization: Bearer $TOKENHARBOR_API_KEY" \
  -H "Content-Type: application/json" \
  --data "$(jq -Rs '{html: .}' index.html)"
```

Send any of `name`, `tagline`, `color`, `system_prompt`, `html`. These map onto the folder (`tokenharbor.json`, `index.html`, `instructions.md`) and must follow the same [rules](https://tokenharbor.ai/docs/mini-apps/package#the-rules): a page that breaks them is refused with `422` and the list of problems. An update changes only your draft: you can try it at the app's address straight away, and nobody else sees it until it is reviewed. Updating an app that is in review takes it out of review.

## Send for review

```bash
curl -X POST https://tokenharbor.ai/api/miniapps/menu-reader/submit \
  -H "Authorization: Bearer $TOKENHARBOR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"accept_terms": "2026-10-09.2"}'
```

`accept_terms` is the version of the [Mini App Developer Terms](/mini-app-terms#developers) you accept for this app: you made it and you are responsible for it and everything it shows. Without the current version the answer is `400` with the code `terms_required`, naming the version to send.

An app is reviewed once per submission. If it is not approved, that is final: submitting it again answers `409` with the code `not_approved`, and the app stays private to you.

## Unpublish

```bash
curl -X POST https://tokenharbor.ai/api/miniapps/menu-reader/unpublish \
  -H "Authorization: Bearer $TOKENHARBOR_API_KEY"
```

Takes the published app down without deleting it: nobody but you can open it from then on, and it leaves Public apps. Its folder, versions and data stay, and a submission waiting for review is withdrawn with it. To bring it back, send it for review again. An app that is not published answers `409` with the code `not_published`.

## Read and delete

```bash
curl https://tokenharbor.ai/api/miniapps \
  -H "Authorization: Bearer $TOKENHARBOR_API_KEY"                 # all your apps

curl https://tokenharbor.ai/api/miniapps/menu-reader \
  -H "Authorization: Bearer $TOKENHARBOR_API_KEY"                 # one, with its page

curl -X DELETE https://tokenharbor.ai/api/miniapps/menu-reader \
  -H "Authorization: Bearer $TOKENHARBOR_API_KEY"
```

## Errors

Errors come back as `{"error": {"code": "...", "message": "..."}}` with a matching HTTP status: `400` for a field that does not fit, `401` without a valid key, `404` for an app that is not yours or does not exist, `409` for an address that is taken or an app that was not approved, `422` for a page or folder that breaks [the folder rules](https://tokenharbor.ai/docs/mini-apps/package) (with a `problems` list saying what to fix), `429` when you change things too quickly (30 a minute).
