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
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,imagesis empty andtextsays 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. - Structured answers. Ask for JSON in your text and pass
json: true: the reply comes back already read, inr.json(nullif 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 happensonStatus("again")is called and the text fromonDeltastarts over — reset any progress you show. Withoutjson, read the text yourself withth.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 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 |
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.
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.
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.
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.
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.
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.
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.
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 |