Mini apps
SDK reference
Every call a mini app's page can make through window.th.
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.
const info = await th.ready();
// { app: { slug, name }, user: { id: "u_3f9c…", name }, lang: "zh-CN" }
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.
th.chat.send(options)
Sends one message in your app's conversation and resolves with the full reply.
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
onDelta: (text) => { /* each new piece of the reply */ },
onImage: ({ url }) => { /* a picture, as soon as it is ready */ },
});
// r = { text, images: [url, ...], messageId }
- The conversation remembers. Each message sees the earlier ones, so a follow-up question works. Start over with
th.chat.reset(). - 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,imagesis empty andtextsays why, in words written for the person — show it rather than a generic "try again". - Structured answers. Ask for JSON in your text, then read it with
th.util.parseJson(r.text). - Photos in.
imagestakes 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
sendwaits for the first. - Errors reject with an
Errorwhosecodesays why — for examplerate_limited, or a daily limit being reached. Showerror.messageto 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.
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.
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. Showerror.message; it is written for the person.
th.media.speak(text, options)
Reads text aloud with the device'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 | This device has no voice for that language | Show the words on screen instead |
not_started | The device did not start reading | 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 |
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; rate — from 0.5 to 2 (default 0.9). th.media.stopSpeaking() stops 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.
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
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.
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.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.
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.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.
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.
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.
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.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.
// 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.
byis the writer's id in your app (the same asuser.idfromth.ready()), andmineistrueon 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.cloudinstead. - Finding records.
wherematches fields exactly (up to five).orderByiscreated_at(the default, newest first),updated_at, or one of your fields (smallest first unlessdesc: true).limitis up to 100; pass thenextyou got back asoffsetto 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.
| 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:
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.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 |