Batiyao for developers
Building with an AI agent?

Give it developer.batiyao.com/miniapps.md: this whole page as one Markdown file, with every rule Batiyao checks. /llms.txt lists all the documentation.

Batiyao Mini Apps — the complete developer specification

A mini app is a small HTML5 game or tool that runs inside Batiyao (on batiyao.com and in the Batiyao apps for iPhone and Android). You write a folder of HTML, CSS and JavaScript, zip it and upload it. Batiyao hosts it, lists it, reviews it and runs it sealed off from the internet. You host nothing and run no server. It can also ask Batiyao's own AI, on the person's phone, for pictures, sounds and answers (section 12).

This page is the whole specification. Everything Batiyao checks is written here, with the exact limits. It is written to be followed by a person or by an AI coding agent.

1. Quick start

  1. Register the app. Sign in at https://batiyao.com/apps?tab=built, type the app's name, choose its category, press New app. You get an App ID like ma_abcdefgh23. Anybody with a Batiyao account and a date of birth on it can do this, for up to 5 apps; more as a Batiyao partner (section 14).
  2. Make a folder with manifest.json, your entry page (index.html), a square PNG icon and a copy of batiyao-miniapp.js. Start from the template in section 17.
  3. Test it locally with the simulator: npm run miniapp -- path/to/folder in the sample repo, then open http://localhost:3100.
  4. Zip the folder's contents (not the folder itself): the zip must contain a file whose path is exactly manifest.json, at its root. The order of entries doesn't matter and folder entries are ignored. The packer checks every rule below first: npm run pack:miniapp -- path/to/folder --app-id ma_abcdefgh23.
  5. Upload the zip on your app's page and press Test on the version: it runs exactly as people will see it, with payments that move nothing.
  6. Review. An admin checks the version. If nobody acts within 1 hour, it goes live by itself, unless the checks flagged something (section 5.3) or it asks for Batiyao's AI for the first time (section 12.1).

2. The environment your code runs in

Your app's pages run in a sandboxed frame with no origin (self.origin is "null"). Batiyao draws its own bar above your app (close, app name, report); your app gets the rest of the screen.

WorksDoesn't work (and what to do instead)
HTML, CSS, JavaScript, canvas, WebGL, WebAssembly, eval/new FunctionAny request to another host: fetch, XHR, WebSocket, EventSource, sendBeacon, images/fonts/scripts from a URL — bundle every file
fetch('data/levels.json') and XHR for files in your bundle, by relative pathWebRTC (RTCPeerConnection is removed before your code runs) — for live play with others use Batiyao.multiplayer (section 11)
<img>, <audio>, <video>, fonts, CSS from your bundle; data: and blob: URLsCookies, localStorage, sessionStorage, IndexedDB, Cache API (no origin) — use Batiyao.storage
Web Workers created from a blob: URLnew Worker('worker.js') by path — fetch the script text and create the worker from a Blob
Pointer, touch and keyboard inputalert, confirm, prompt, print (no modals) — draw your own dialogs
Web Audio and <audio> once the person has tappedPop-ups, window.open, links that leave the app, navigating the page anywhere else
<form> with JavaScript handlers (event.preventDefault())Submitting a form to a URL
Offering a picture and words for the person to share (Batiyao.share)Posting, sharing or messaging by itself
Data every player of your app can read, such as a champions list (Batiyao.db), and players' nicknames (Batiyao.getNickname) (section 10)Your own server or database: there is none to reach
A photo or video the person chooses on Batiyao's picker (Batiyao.pickImage, Batiyao.pickVideo, section 9)<input type="file">, dropping or pasting files: no file chooser opens and no file can be read — use pickImage/pickVideo
Pictures, sounds and answers from Batiyao's AI on the person's phone (Batiyao.ai, section 12), including web search results without linksCalling an AI service of your own: there is no network — use Batiyao.ai
Camera, microphone, location, notifications, clipboard, fullscreen, pointer lock, screen wake lock, sensors
<iframe>, <object>, <embed>

Every bundle file is served with this Content-Security-Policy, where BASE is your version's own folder on miniapps.batiyao.com:

sandbox allow-scripts allow-forms; default-src 'none';
script-src BASE 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src BASE 'unsafe-inline';
img-src BASE data: blob:; media-src BASE data: blob:; font-src BASE data:; connect-src BASE data: blob:;
worker-src blob:; frame-src 'none'; child-src 'none'; object-src 'none'; manifest-src 'none';
form-action 'none'; base-uri 'none'

plus Permissions-Policy denying every powerful feature, and a small script Batiyao puts before your own that removes the WebRTC constructors.

Orientation is set from your manifest (portrait, landscape or any). Make the layout fill the frame (html, body { height: 100%; margin: 0 }) and work from about 320px wide. On the web, a portrait app is shown at most about 450px wide.

3. manifest.json

At the top of the zip. UTF-8 JSON. Unknown fields are refused.

{
  "batiyao_miniapp": 1,
  "app_id": "ma_abcdefgh23",
  "version": "1.0.0",
  "name": "Wingbeat",
  "summary": "Tap to flap through the gaps. How far can you fly?",
  "description": "Optional longer text for the app's page.",
  "category": "games",
  "age_rating": "4+",
  "entry": "index.html",
  "icon": "icon.png",
  "screenshots": ["shots/1.png"],
  "orientation": "portrait",
  "access": { "type": "free" },
  "products": [
    { "id": "golden_feathers", "title": "3 golden feathers",
      "statement": "3 golden feathers for Wingbeat. Each one lets you keep flying after a crash.",
      "price": "0.5", "kind": "consumable" },
    { "id": "night_sky", "title": "Night sky",
      "statement": "The night sky theme for Wingbeat, yours to keep.",
      "price": "1", "kind": "permanent" }
  ]
}
FieldRequiredRule
batiyao_miniappyesMust be 1.
app_idyesYour App ID exactly, matching ^ma_[a-z2-7]{10}$. An upload whose app_id isn't this app's is refused.
versionyesMAJOR.MINOR.PATCH, each 0–9999 with no leading zeros (1.0.0, 2.10.3). Each upload must be higher than every version uploaded before.
nameyes2–30 characters, one line.
summaryyes10–80 characters, one line. Shown in lists.
descriptionno1–2,000 characters; line breaks allowed. Shown on the app's page.
categorynoChoose it on your app's page (Built by me → your app → Category); a change there shows at once, with no upload. Here it is optional, used only until you choose one on the page; if given it must be one of games, education, tools, entertainment, lifestyle, productivity, other. The list, with display names, is GET https://api.batiyao.com/api/v1/miniapps/categories.
age_ratingyesOne of 4+, 9+, 13+, 16+, 18+. Rate the content as the app stores do: 4+ nothing objectionable; 9+ mild cartoon violence or scares; 13+ mild violence, crude humour, simulated gambling with no real prize; 16+ stronger violence or mature themes; 18+ adult content. Selling items doesn't change the rating. An admin checks. See section 13.
entrynoPath of an .html/.htm file in the bundle. Default index.html.
iconyesPath of a PNG in the bundle, square, 128–1024 pixels. Batiyao rounds its corners; keep what matters in the middle 80% and use an opaque background.
screenshotsnoUp to 5 paths of PNG, JPEG or WebP files in the bundle.
orientationnoportrait, landscape or any (default any).
accessyesWhat opening the app costs. {"type": "free"} (no price or statement), or {"type": "one_time" or "pass_30d", "price": "…", "statement": "…"}. See section 7.4.
productsnoUp to 50 things the app sells inside itself.
requiresnoFeatures your app can't work without, by SDK name: pay, share, pickImage, pickVideo, db, nickname, multiplayer, ai.image, ai.audio, ai.assistant (also storage, purchases). A Batiyao without one of them never opens your app; the person sees "needs a newer version of Batiyao" or "open it in the Batiyao app" instead of a broken game. For anything you can do without, leave it out and check supports() / capability() instead. See section 12.4.
capabilitiesnoBatiyao's AI your app asks to use (section 12): {"ai.image": {"version": 1, "purpose": "Draws a card for each level."}}. A Batiyao admin decides; the purpose is shown to people on your app's page before they open it.

Each product:

FieldRule
id^[a-z0-9_]{1,40}$, unique in the manifest. This is what you pass to Batiyao.pay().
title1–40 characters, one line.
statement10–140 characters, one line. Says plainly what the person gets. Batiyao shows it, word for word, on the payment sheet.
priceBuckBuck as a string: digits with up to 3 decimals ("0.5", "2", "1.25"), from 0.1 to 20.
kindconsumable (can be bought again; you mark each one delivered with consume()) or permanent (bought once, owned forever).

Prices for access: 0.1 to 50 BuckBuck, same format. Text fields may not start or end with spaces or contain control characters; only description may contain line breaks. Accounts under 18 can publish free apps only (access free and no products).

4. The bundle (the zip)

RuleLimit
Zip sizeat most 20 MB
Unpacked sizeat most 60 MB in total
One fileat most 20 MB
Number of filesat most 1,000
Compressionordinary zip: stored or deflate only; no encryption
Compression ratioa file over 1 MB may not compress more than 100 to 1
Filesordinary files only: no symlinks, no special files; entries may not overlap
File nameseach part matches ^[A-Za-z0-9_-][A-Za-z0-9._-]*$ (no spaces, no hidden files, no ..), parts separated by /, at most 10 levels and 180 characters
Casetwo paths that differ only in capital letters are refused (phones treat them as one file)
Dropped silently__MACOSX/…, .DS_Store, Thumbs.db, desktop.ini, and folder entries
Refusedany other hidden file (.gitignore, .env, …): leave them out of the zip

Allowed file types — the extension decides how a file is served, and the first bytes must match the extension (a .png must really be a PNG):

html htm js mjs css json map txt csv svg png jpg jpeg gif webp avif ico mp3 m4a ogg oga opus wav mp4 webm woff woff2 ttf otf wasm glb gltf bin data

Text files (html htm js mjs css json map txt csv svg gltf) must be valid UTF-8.

5. What the upload checks in your code

5.1 Refused: anything that reaches outside the bundle

In HTML (scripts and comments are ignored for these checks, inline <style> and style="…" are checked as CSS):

In CSS (files and inline):

In SVG: <script>, event-handler attributes (onload= …), javascript: URLs, and links to the internet.

5.2 Not checked, but blocked when it runs

JavaScript is not refused for mentioning a URL, but any request to the internet fails at run time (section 2). Don't ship code paths that depend on it — analytics, ads, CDNs, fonts from Google, remote config — they will just fail.

5.3 Flagged: accepted, but waits for an admin

These are accepted, but such a version never approves itself after an hour; an admin must look at it:

Found in a .js/.mjs file or inline scriptFlag
RTCPeerConnection / webkitRTCPeerConnectionUses WebRTC
new WebSocketOpens WebSockets
EventSource(Uses EventSource
sendBeacon(Uses navigator.sendBeacon

None of these can work in a mini app; remove them (often they come from a bundled analytics or multiplayer library).

A version also waits for an admin when its manifest.json asks for Batiyao's AI that your app hasn't been granted yet, or for a higher version or a new tool of it (section 12.1). That isn't a problem to fix: it's the admin deciding.

6. The SDK — batiyao-miniapp.js

Copy the file into your bundle and load it before your own code:

<script src="batiyao-miniapp.js"></script>
<script src="app.js"></script>

It defines window.Batiyao. Every method except on and off returns a Promise. A rejected Promise carries an Error with a code (section 16). Outside Batiyao (opened directly in a browser tab) every call rejects with NO_HOST.

CallResolves toNotes
Batiyao.ready(){}Call as soon as something is on screen — don't wait for storage or purchases first; load those after and update the screen. Batiyao shows a loading cover until then (and removes it anyway 3 s after the page loads).
Batiyao.getContext(){ app_user_id, platform, locale, theme, version, test_mode, access, features?, capabilities? }app_user_id: a string that is the same for this person every time in your app and different in every other app; use it as your player id. platform: "web", "ios" or "android". locale: e.g. "en-GB". theme: "light" or "dark". version: your running version. test_mode: true in a test run. access: { type, active }. features: the optional calls this Batiyao has, e.g. ["pay", "share", "pickImage", "pickVideo", "db", "nickname", "multiplayer"] (multiplayer only where this person can play in your app; ai.image, ai.audio, ai.assistant only where they work for this person); missing in older phone apps — use supports(). capabilities: Batiyao's AI your manifest declares, with its state — read it with capability() (section 12).
Batiyao.capability(name){ supported, version, state, tools }Section 12: whether Batiyao's AI (ai.image, ai.audio, ai.assistant) works here.
Batiyao.ai.image.generate(…), .ai.audio.generate(…), .ai.ask(…), .ai.cancel()see section 12Pictures (BGen), sounds (BAudio) and answers (Batiyao AI), made on the person's phone.
Batiyao.supports(name)true or falseWhether the Batiyao your app is running in has a call, e.g. "pickImage", "db", "nickname", "multiplayer". Phone apps update on their own schedule, so a new call can be missing for a while: hide what needs it.
Batiyao.storage.get(key)the stored string, or null
Batiyao.storage.set(key, value)truevalue must be a string of at most 4,096 characters (JSON.stringify objects).
Batiyao.storage.remove(key)true
Batiyao.storage.keys()array of keys
Batiyao.pay({ product }){ status, purchase?, reason? }Section 7. Must be called from a tap or click.
Batiyao.share({ image, text }){}Section 8: the person may post it, put it in their story or send it to a chat. Must be called from a tap or click. Resolves when Batiyao's share sheet closes; never says whether or where the person shared.
Batiyao.pickImage({ max_size }){ status, image?, type?, width?, height? }Section 9: a photo the person chooses on Batiyao's picker, as a Blob, without its location or other details. status is "picked", "cancelled" or "unavailable". Must be called from a tap or click.
Batiyao.pickVideo({ max_seconds }){ status, video?, type?, size?, duration?, width?, height? }Section 9: the same for a video (up to 100 MB). Must be called from a tap or click.
Batiyao.getNickname({ ask }){ nickname } (a string, or null)Section 10.1. With ask: true and no nickname yet, Batiyao's sheet asks the person (call it from a tap).
Batiyao.db.set(key, value, { replaceable, rank }){ saved }Section 10.2: write your own record to the data every player can read.
Batiyao.db.get(key, { player })a record, or nullYour own record, or another player's.
Batiyao.db.list(key, { order, limit, cursor }){ records, next }Every player's record under key: a leaderboard.
Batiyao.db.remove(key) / Batiyao.db.usage()true / { bytes, limit }
Batiyao.multiplayer.host(…), .join(…), .list(), .send(…), .leave(), .on(event, fn)see section 11Live matches of 2–8 players, where a Batiyao admin has switched multiplayer on for your app.
Batiyao.getPurchases(){ purchases: [{ id, product, kind, purchased_at, consumed_at? }], access: { type, active, until? } }The record of what this person owns in your app.
Batiyao.consume(purchaseId){ consumed: true }Marks one consumable as delivered.
Batiyao.close(){}Closes your app.
Batiyao.on(event, fn) / Batiyao.off(event, fn)—"visibility" → { visible } when the person leaves Batiyao and returns (pause your game). There is no event at start: assume visible. On the web the page's own visibilitychange fires too. "theme" → { theme }.

Storage: keys match ^[A-Za-z0-9_.:-]{1,128}$; at most 1,024 keys per person per app; at most 120 writes a minute. It is kept on Batiyao's servers for this person in your app, on every device they use, and survives new versions of your app. It is not a place for secrets: the person's own devices can read it.

Timeouts: the ai calls wait as long as the work takes (the person can cancel). ready and getContext reject with TIMEOUT after 10 s; storage, getPurchases and consume after 20 s. pay, share, pickImage, pickVideo, getNickname({ ask: true }), multiplayer.host and multiplayer.join wait as long as the person takes; the other db and multiplayer calls time out after 20 s.

Errors: error.message is written for you, the developer, not for players. Show your own words to players and log the code.

6.1 The protocol (if you don't use the SDK file)

The SDK is a thin wrapper over postMessage to window.parent. Requests: { batiyao: 1, id: "<unique string, ≤ 64 chars>", method: "<method>", params: { … } }. Batiyao answers each request once with { batiyao: 1, id, result } or { batiyao: 1, id, error: { code, message } }, and sends events as { batiyao: 1, event, data }. Methods, their params and their result:

methodparamsresult
ready{}{}
getContext{}{ app_user_id, platform, locale, theme, version, test_mode, access, features?, capabilities? } (capabilities: { "ai.image": { version, state, tools } } for each one your manifest declares)
pay{ product }{ status, purchase?, reason? }
share{ image, text } (image a PNG/JPEG data: URL or ""){}
pickImage{ max_size? }{ status, image?, type?, width?, height? } (image a Blob)
pickVideo{ max_seconds? }{ status, video?, type?, size?, duration?, width?, height? } (video a Blob)
getNickname{ ask? }{ nickname }
db.set{ key, value, replaceable?, rank? }{ saved }
db.get{ key, player? }{ record }
db.list{ key, order?, limit?, cursor? }{ records, next }
db.remove / db.usage{ key } / {}{ removed: true } / { bytes, limit }
mp.host{ max_players, public?, meta? }{ status, match? }
mp.join{ code } or { match }{ status, match? }
mp.list / mp.send / mp.leave{} / { data, to? } / {}{ matches } / {} / {}
getPurchases{}{ purchases, access }
consume{ purchase }{ consumed: true }
storage.get{ key }{ value } (null if unset)
storage.set{ key, value }{ saved: true }
storage.remove{ key }{ removed: true }
storage.keys{}{ keys }
ai.image.generate{ prompt, avoid?, quality?, seed? }{ status, image?, type?, width?, height?, seed? } (image a Blob)
ai.audio.generate{ prompt, kind?, seconds?, seed? }{ status, audio?, type?, seconds?, seed? } (audio a Blob)
ai.ask{ prompt, context?, web_search?, format?, max_chars? }{ status, text?, json?, searched? }
ai.cancel{}{}
close{}{}

A request larger than 64 KB as JSON is dropped, except share, which may be up to 9 MB (it carries the picture). Answers to pickImage and pickVideo carry a Blob (messages are structured clones, not JSON). Events: nickname { nickname }; ai.progress { request, stage, done?, total? } (request is the id of your running ai request; stage is downloading, loading, making, searching or writing); and mp.joined { player }, mp.left { player, reason }, mp.message { from, data }, mp.host { player }, mp.closed { reason }. A method an older Batiyao doesn't know is answered with the error UNKNOWN_METHOD. Accept answers only from window.parent.

7. Payments

Payments are in BuckBuck, Batiyao's own points, which people earn on Batiyao.

7.1 How a payment works

  1. Your app calls Batiyao.pay({ product: "golden_feathers" }) from a tap or click: directly inside a click, pointerup/touchend or keydown handler, without awaiting anything slow first (the browser's "user activation" lasts only a few seconds). This is checked on the web, and on phones wherever the web view can tell.
  2. Batiyao shows its own payment sheet over your app: your app's name and icon, "Made by @you", the product's statement, the total, the person's balance after, and a note that a third party made the app. Your app cannot draw, change or cover this sheet. While it is open your app keeps running (timers continue) but receives no input and no visibility event, so pause your game before calling pay(). There is no API to read prices: show the price from your own manifest; the sheet shows the authoritative one.
  3. The person taps Pay or Cancel, and the Promise resolves.
ResultMeaningWhat to do
{ status: "paid", purchase: { id, product, kind, purchased_at } }Done.Give the item (section 7.2).
{ status: "cancelled" }The person changed their mind.Nothing.
{ status: "pending" }Batiyao is still confirming with the ledger.Tell the person it will arrive. Check getPurchases() again on visibility: { visible: true } and at the next start; there is no event when it confirms.
{ status: "failed", reason: "insufficient_balance" }Not enough BuckBuck. Batiyao already told the person.Nothing else.
{ status: "failed", reason: "already_owned" }A permanent product they already have.Treat it as owned.
{ status: "failed", reason: "unavailable" }Batiyao couldn't take the payment (it already showed why).Let them try later.

pay() rejects (instead of resolving) only for mistakes in your code or a session that can't pay at all: NEEDS_TAP (not called from a tap or click), BUSY (a payment sheet is already open), NO_SUCH_PRODUCT, INVALID, LAUNCH_INVALID, NO_HOST, and on phones OFFLINE. Everything Batiyao shows the person itself (too little BuckBuck, payments unavailable, too many requests) resolves as failed.

7.2 Delivering what was bought

Your app has no server, so getPurchases() is the record. To never lose a purchase:

  1. At start-up, call getPurchases(). For each consumable without consumed_at, if you haven't credited it yet: credit it in your state, save your state with Batiyao.storage.set, then call consume(id). For each permanent, unlock it.
  2. After pay() resolves paid, do the same for purchase.id.

Keep a list of purchase ids you have credited (in storage) so a crash between "credit" and "consume" doesn't credit twice. A consumable nobody consumes within 24 hours is refunded to the person automatically. A refunded purchase disappears from getPurchases() (a refunded permanent item is no longer owned: lock it again). A test run sees only test purchases; a normal run only real ones.

7.3 Getting paid

You earn the full price. When someone buys something in your app, whether an unlock, a 30-day pass or an in-app item, the whole amount is yours. Batiyao currently keeps no share.

How a sale becomes your earnings

  1. The person pays. Their BuckBuck leaves their balance straight away, and the purchase shows in their BuckBuck history as a "Mini app" entry naming your app and the item.
  2. Batiyao holds the payment for 7 days. This protects buyers: if your app turns out to be broken or misleading, Batiyao can return their BuckBuck. It also protects you, because a sale that survives the hold is final.
  3. You are paid. When the 7 days are up, the payment moves to your own Batiyao account automatically, usually within a minute. Several sales that finish their hold together arrive as one payment, shown in your BuckBuck history as "Mini app sales".

Where to see your earnings. Your app's page (Mini apps → Built by me → your app) shows three figures: Purchases (sales so far), Held (BuckBuck waiting out its 7 days) and Paid to you (BuckBuck already in your balance).

What you need. Nothing beyond the Batiyao account you registered the app with. Earnings always go to that account. You don't open a separate seller account, add bank details or set anything up: every Batiyao account gets its BuckBuck wallet when it signs up.

When a sale doesn't pay out

If a payout is late. If Batiyao can't complete a payment automatically, it doesn't try again blindly, so nobody can ever be paid twice. Instead a Batiyao administrator checks it and completes it by hand. Your earnings are never lost: they stay safely with Batiyao until they reach you. If "Held" hasn't moved to "Paid to you" a day after a sale's 7 days ended, contact Batiyao support at https://batiyao.com/support.

About BuckBuck. Earnings are paid in BuckBuck, Batiyao's own points. They work like any other BuckBuck in your balance, on everything Batiyao offers. They are not money and can't be exchanged for cash.

7.4 Paid access

access makes the app itself paid: one_time (an unlock, kept forever) or pass_30d (30 days; the person renews it themselves — nothing renews automatically). People buy access on the app's page before it opens; your app never needs to check or sell it. getContext().access.active is always true while your app runs.

7.5 Test runs

A version opened with Test on your app's page (or by a reviewing admin) is a test run: getContext().test_mode is true, the payment sheet says TEST, no BuckBuck moves, and test purchases are kept apart from real ones. Test any version, whatever its review state.

8. Sharing

Your app can offer the person something to share as a post, in their story, or in a chat: a picture it made (a score card, a drawing, a result) and suggested words. As with payments, Batiyao draws the sheet and the person decides. Your app never posts, and never learns what the person chose.

shareButton.addEventListener('click', function () {
  // Straight from the click.
  Batiyao.share({ image: scoreCanvas, text: 'I scored 42 in Tap Counter. Can you beat it?' })
    .then(function () { /* the sheet closed; nothing else to do */ })
    .catch(function (e) { console.warn('share', e.code); });
});

8.1 What happens

  1. Your app calls Batiyao.share({ image, text }) from a tap or click.
  2. Batiyao checks the picture and re-draws it from its pixels. Nothing else in your file survives: no metadata, no location, no extra bytes. Then it opens its own sheet over your app, which is frozen meanwhile. The sheet shows the picture; a choice of Your story (free; the picture only, for the person's followers, for 48 hours), A post (costs the person the usual price of a post, currently 1 BuckBuck; the picture and words, for their posts' usual audience) or A chat (free; the picture and words as a message in one of their chats, which they pick on Batiyao's sheet); the words, which the person can change; and a note that your app made it.
  3. If the person shares, it is posted or sent as them, under every rule that applies to their own posts and messages (age, guardians, audience, who they may message, the post's price). Posts and stories are marked "via <your app> (mini app)". A post or chat message ends with a line linking back to your app, Play <name>: https://batiyao.com/apps/<slug>, which chats show as your app's card with a Play button. That line is added by Batiyao, so don't put links in your text.
  4. share() resolves {} when the sheet closes. Your app is never told whether the person shared, where, or with whom. It never sees the person's chats. This is on purpose: it means nothing can reward or require sharing.

8.2 What you can send

FieldRule
imageOptional, but a story needs one. A <canvas> (the SDK calls toDataURL('image/png')) or a PNG or JPEG data: URL, at most 6 MB, 64 to 4,096 pixels on each side. A PNG stays PNG unless the re-drawn file is over 5 MB; it then becomes a JPEG on a white background. Stories look best at 9:16 (720×1280 or 1080×1920).
textOptional. At most 380 characters, no control characters. No @ tags, # hashtags or links: those are refused (INVALID_TEXT). The person may add any of them themselves on the sheet.

Send at least one of the two.

8.3 Rules

Everything the person sees is handled by Batiyao: sharing being unavailable, too many shares, or the person not being able to post or send (too little BuckBuck for a post, a guardian's rule, a chat they can't write in, a missing date of birth). In all those cases share() still resolves {}. It rejects only for your own mistakes: INVALID, INVALID_IMAGE, IMAGE_TOO_LARGE, INVALID_TEXT, NEEDS_TAP, BUSY, LAUNCH_INVALID, NO_HOST.

8.4 Testing it

In a Test run, the sheet is marked TEST and nothing is posted. The simulator shows a simple version of the sheet and logs what the person chose (your app still hears only {}).

9. Photos and videos the person picks

Your app can ask the person for a photo or a video from their device. Batiyao opens its own picker and the person chooses. Your app gets only that one file, as a Blob, with everything but the picture and the sound removed. Your app can't open the device's files, camera or photo library by itself (section 2).

photoButton.addEventListener('click', function () {
  // Straight from the click.
  Batiyao.pickImage({ max_size: 1024 }).then(function (r) {
    if (r.status !== 'picked') return; // 'cancelled' or 'unavailable'
    return createImageBitmap(r.image).then(function (bitmap) {
      ctx.drawImage(bitmap, 0, 0);
    });
  }).catch(function (e) { console.warn('pickImage', e.code); });
});

// Phone apps update on their own schedule: show the button only where the
// Batiyao your app runs in has the call.
Batiyao.supports('pickImage').then(function (yes) { photoButton.hidden = !yes; });

9.1 What happens

  1. Your app calls Batiyao.pickImage() or Batiyao.pickVideo() from a tap or click.
  2. Batiyao shows its own sheet over your app, which is frozen meanwhile: your app's name, that it asks for a photo (or video), and that only the one the person chooses goes to your app, without its details. The person presses Choose a photo and picks on the device's own picker, which may also offer the camera.
  3. Batiyao prepares the file (9.2). If it can't be used (too big, too long, a format Batiyao can't open), Batiyao tells the person and they can choose another. Your app isn't told.
  4. The call resolves with a status:
statusMeaning
pickedThe person chose a file. It is in the result.
cancelledThey closed the sheet without choosing.
unavailableThis person can't give photos and videos to mini apps (a guardian's setting, 9.4). Batiyao has told them and offered to ask their guardian. Your app isn't told why.

9.2 What you get

CallOptionpicked result
Batiyao.pickImage({ max_size })max_size: the longest side in pixels, a whole number from 64 to 4,096. Default 2,048.{ status, image, type, width, height }: image a Blob; type "image/png" or "image/jpeg"; the size in pixels.
Batiyao.pickVideo({ max_seconds })max_seconds: the longest video you accept, from 1 to 600. Default 600.{ status, video, type, size, duration, width, height }: video a Blob; type "video/mp4", "video/quicktime" or "video/webm"; size in bytes; duration in seconds; the size in pixels.

Photos are drawn again from their pixels, upright, and scaled down (never up) so the longer side is at most max_size. A PNG stays a PNG and keeps its transparency, unless it would be over 10 MB, when it becomes a JPEG; everything else becomes a JPEG on a white background. Nothing of the original file survives: no location, camera, time or other metadata. The person can choose pictures up to 50 MB in any format their device opens (JPEG, PNG, WebP, the first frame of a GIF, HEIC on iPhones).

Videos up to 100 MB, in MP4, MOV or WebM, are not re-encoded: the picture and sound are exactly as recorded. Batiyao removes the location, the camera details, the title and other descriptive metadata, and the recording time first.

Using the file: createImageBitmap(blob), blob.arrayBuffer(), and <img>, <video> or <audio> with URL.createObjectURL(blob) all work (blob: URLs are allowed; revoke them when you're done). There is nowhere to keep the file itself: Batiyao.storage holds text up to 4,096 characters, so keep what your app made from it. To let the person post or send something made from it, offer it with share() (section 8).

9.3 Rules

9.4 Children

A guardian decides whether their child can give photos and videos to mini apps, separately from opening mini apps and from paying in them. It's off until the guardian turns it on. When it's off, the call resolves unavailable. Your app doesn't need to do anything else.

9.5 Testing it

A Test run works exactly as it will for people. The simulator shows a simple version of the sheet, prepares photos the same way (it passes videos through without removing anything; Batiyao does that), and can answer unavailable so you can try that path (see the simulator's README).

10. Nicknames and shared data

Your app can keep data that every player of your app can read: a champions list, a world record, the most-solved puzzles. Batiyao stores it (1 MB per app); you run no server. Players appear by a nickname they choose for your app on Batiyao's own sheet, never by their name.

// After a game: save this player's best score, replaceable so a full store
// drops the lowest scores first.
Batiyao.db.set('best', JSON.stringify({ score: 42 }), { replaceable: true, rank: 42 });

// The champions list.
Batiyao.db.list('best', { order: 'rank_desc', limit: 10 }).then(function (page) {
  page.records.forEach(function (r) {
    addRow(r.player.nickname || 'Player', JSON.parse(r.value).score, r.player.id === me);
  });
});

10.1 Nicknames

Batiyao.getNickname() resolves { nickname }: this person's nickname in your app, or null if they haven't chosen one. With { ask: true } and no nickname yet, Batiyao shows its sheet first (call it from a tap; NEEDS_TAP otherwise) and resolves with what they chose, or null if they closed it. Only Batiyao asks and saves: your app can't set a nickname or draw that screen.

A player as your app sees one: { id, nickname }, where id is the app_user_id from getContext().

10.2 The shared store

CallResolves toNotes
Batiyao.db.set(key, value, { replaceable, rank }){ saved: true } or { saved: false }Writes your own record under key (one per player per key; writing again replaces it). value: text of at most 1,024 bytes (JSON.stringify objects). replaceable (default false): may Batiyao remove this record to make room when the store is full? rank: an optional number used for sorting and for what goes first.
Batiyao.db.get(key, { player })a record, or nullYour own record under key, or another player's (player: their id).
Batiyao.db.list(key, { order, limit, cursor }){ records, next }Every player's record under key. order: "rank_desc" (default), "rank_asc" or "recent"; records without a rank come last. limit 1–100 (default 20). Pass next back as cursor for the next page.
Batiyao.db.remove(key)trueRemoves your own record.
Batiyao.db.usage(){ bytes, limit }How full the store is (limit is 1,048,576).

A record: { player: { id, nickname }, key, value, rank, replaceable, updated_at }. Keys are 1–128 of A–Z a–z 0–9 _ . : -.

When the store is full (1 MB, counted as the bytes of each key and value), a write that doesn't fit makes room by removing records that were written as replaceable, in this order: records without a rank first, then the lowest rank, and among equals the oldest. The record being written takes part: if it would be the first to go (a score lower than everything on a full top board), it isn't saved and set resolves { saved: false }. A record written without replaceable is never removed by Batiyao; if nothing can make room, set rejects with STORE_FULL.

Writes are limited to 60 a minute per person, reads to 120 a minute (RATE_LIMITED). Test runs have their own store, so testing never touches your live board.

10.3 Rules

11. Multiplayer

Players of your app can play live, together: 2–8 people in a match, sending small messages through Batiyao. Your app still has no network; Batiyao relays.

Multiplayer is off until a Batiyao admin switches it on for your app. Ask on your app's page (batiyao.com/apps → Built by me → your app → Multiplayer), saying what players do together. An admin opens it to adults, or to adults and children, or not at all, and you get a notification.

Batiyao.supports('multiplayer').then(function (yes) { playOnlineButton.hidden = !yes; });

playOnlineButton.addEventListener('click', function () {
  Batiyao.multiplayer.host({ max_players: 2, public: true, meta: { mode: 'race' } }).then(function (r) {
    if (r.status !== 'joined') return; // 'cancelled' or 'unavailable'
    showCode(r.match.code);           // the other player joins with it
  });
});

Batiyao.multiplayer.on('joined', function (e) { startRace(e.player); });
Batiyao.multiplayer.on('message', function (e) { moveCar(e.from, e.data); });
Batiyao.multiplayer.on('closed', function (e) { backToMenu(e.reason); });

function onTick() { Batiyao.multiplayer.send({ x: car.x, y: car.y }); }

11.1 Matches

CallResolves toNotes
multiplayer.host({ max_players, public, meta }){ status, match? }Starts a match. max_players 2–8; public (default false): listed by list() for anyone to join; meta: a small object (≤ 256 bytes of JSON) shown in the list, such as a mode. From a tap.
multiplayer.join({ code }) or join({ match }){ status, match? }Joins by the 6-character code someone shared, or by a match id from list() (public matches only). From a tap.
multiplayer.list()an array of { id, players, max_players, meta, host }Open public matches of your app that this person can join (up to 50).
multiplayer.send(data, { to }){}Sends data (any JSON, ≤ 1,024 bytes) to everyone else in the match, or to one player (to: their id). No receipt.
multiplayer.leave(){}Leaves the match.
multiplayer.on(event, fn) / .off(event, fn)—"joined" { player }, "left" { player, reason }, "message" { from, data }, "host" { player } (the host left; this is the new one), "closed" { reason }.

status is "joined" (with match), "cancelled" (the person closed Batiyao's nickname sheet) or "unavailable" (multiplayer isn't open to this person in your app; for a child, Batiyao has told them and offered to ask their guardian). A match: { id, code, max_players, public, meta, host, me, players }, where host and me are player ids and players is [{ id, nickname }]. Before someone's first match Batiyao makes sure they have a nickname.

Limits: 20 messages a second per player (short bursts to 40; extra messages are dropped, and a player sending far more is disconnected); one match at a time per person; 500 live matches per app (RATE_LIMITED); a match with one player and no messages for 10 minutes closes, and every match closes after 4 hours. closed reasons include "unavailable" (an admin switched multiplayer down, or the app was blocked), "idle", "expired" and "connection_lost". Nothing a player sends is stored.

11.2 Children

Where an admin has opened your app to children, a child can play only if their guardian has switched on Playing online in mini apps, and only ever with children of their own age group (13–15, 16–17); adults and children never meet in a match, whatever code is shared. Children appear by a Batiyao-suggested nickname. Your app doesn't need to do anything for this.

11.3 Testing it

In a Test run, multiplayer always works for you and Batiyao's admins, among yourselves: open the Test run in two browser windows to play against yourself (the second seat's id ends in ~2). Test matches never meet real players. The simulator runs matches across browser tabs and can answer unavailable (see its README).

11.4 Rules

12. Batiyao's AI: pictures, sounds and answers

Your app can ask Batiyao's own AI, which runs on the person's phone, to make things for it:

CapabilityBatiyao featureWhat it doesVersion 1
ai.imageBGenA picture from words.384 × 384 PNG; about 40 s (fast) to a minute (best) on a phone.
ai.audioBAudioMusic or a sound effect from words, without singing.WAV; music 8, 15 or 30 s, sounds 3, 6 or 12 s; a few seconds to make.
ai.assistantBatiyao AIAn answer in words (or JSON), optionally after searching the web.Up to 1,500 characters; a few seconds (about a minute the very first time, while the model loads).

Nothing your app asks for leaves the phone, except Batiyao AI's web searches, which go through Batiyao's own search (always safe-search, and an app's results never carry links). Batiyao AI in your app can't act for the person: it can't post, message, pay or read anything of theirs; it only answers.

12.1 Asking for it

Declare each capability in manifest.json, with the version you need and your reason in one line (10–140 characters, shown to people as you wrote it):

"capabilities": {
  "ai.image": { "version": 1, "purpose": "Draws a card for each level from the words you choose." },
  "ai.assistant": { "version": 1, "purpose": "Writes the quiz questions for each round.", "tools": ["web_search"] }
},
"requires": ["ai.assistant"]

12.2 Calling it

const c = await Batiyao.capability('ai.image');
// { supported, version, state: 'ready' | 'needs_download' | 'unavailable', tools }
if (c.state !== 'unavailable') drawButton.hidden = false;

drawButton.onclick = async () => {        // a tap: required for pictures and sounds
  const r = await Batiyao.ai.image.generate({
    prompt: 'a paper kite over green hills, watercolour',
    quality: 'fast',                      // 'fast' | 'balanced' | 'best'
    onProgress: (p) => showProgress(p.done, p.total),
  });
  if (r.status === 'made') img.src = URL.createObjectURL(r.image);
};
CallResolves toNotes
Batiyao.capability(name){ supported, version, state, tools }Never rejects. state is ready, needs_download (Batiyao will ask the person to download the model the first time; it's 1–2.5 GB) or unavailable. Batiyao never says why something is unavailable.
Batiyao.ai.image.generate({ prompt, avoid, quality, seed, onProgress }){ status: 'made', image: Blob, type: 'image/png', width, height, seed }prompt 1–400 characters; avoid up to 200 (what to leave out); the same seed and words make the same picture. From a tap.
Batiyao.ai.audio.generate({ prompt, kind, seconds, seed, onProgress }){ status: 'made', audio: Blob, type: 'audio/wav', seconds, seed }kind music (8/15/30 s, default 15) or sound (3/6/12 s, default 6). From a tap.
Batiyao.ai.ask({ prompt, context, web_search, format, max_chars, onProgress }){ status: 'answered', text, json, searched }prompt 1–2,000 characters; context up to 8,000 (text to answer from: your game's rules, a level's words); web_search: true needs the tool; format: 'json' makes it answer with JSON only, parsed into json (null if it couldn't); max_chars 50–1,500 (default 800). Needn't come from a tap.
Batiyao.ai.cancel(){}Stops your app's running request (it answers cancelled).

Every answer has a status: made / answered; cancelled (the person stopped it, or left your app); unavailable (not here or not for this person — carry on without it); blocked (Batiyao wouldn't make that: ask for something else). One AI request runs at a time per app (BUSY otherwise). Batiyao shows its own strip over your app while it works, with your words and a Cancel button; the first time, it asks the person whether your app may use it.

12.3 Rules

12.4 Where it works, and versions

These capabilities run in the Batiyao phone app on phones that can run the models (most phones from the last few years with 4–5 GB of memory). On batiyao.com and on phones that can't, capability() says unavailable and every call answers { status: 'unavailable' }, so check before you show the button.

If your app is pointless without one, put it in requires. Then Batiyao never opens your app where it can't work: on the website the person is told to open it in the Batiyao app, on an older phone app to update Batiyao, and on a phone that can't run the model, that this phone can't. Your app page shows this upfront.

Each capability has a version (version in the manifest is the lowest you need). New versions only ever add options and answers; anything that worked keeps working, so a newer Batiyao always runs an app written for an older version.

12.5 Testing it

The simulator answers with stand-ins — a picture with your words on it, a tone, a canned answer — so you can build the flow; its switch tries needs_download and unavailable. On Batiyao, a Test run uses the real thing on a phone (Built by me → your app → Test, in the Batiyao app).

13. Ages and who can use your app

ViewerSees apps rated up to
Adults (18+)18+
16–1716+
Under 16, people who haven't given a date of birth, and signed-out visitors13+

Children also need a guardian to switch mini apps on and to allow your app specifically; paying, giving your app photos and videos, playing online and using Batiyao's AI each need a separate guardian switch. You don't need to handle any of this: Batiyao does it before your app opens. Your app never learns the person's age, name, username, contacts or location.

14. Review, versions and limits

Declined or blocked for: a statement that doesn't say what the person gets; pressuring people to pay (fake timers, payment prompts nobody tapped for); rewarding, requiring or nagging for sharing; asking for photos or videos the app doesn't need, or before the person asks for something that needs one; letting players chat or message each other (that bans the developer and blocks all their apps); content above the declared age rating; pretending to be Batiyao or someone else; trying to reach the network; collecting personal information.

15. Testing tools

16. Error codes

Codes an SDK call can reject with (error.code):

CodeMeaning
NO_HOSTNot running inside Batiyao or the simulator.
TIMEOUTBatiyao didn't answer in time (section 6).
INVALIDBad parameters (e.g. pay() without a product id, a non-string stored value, a max_size out of range).
INVALID_KEYA storage key that doesn't match the pattern.
VALUE_TOO_LONGA stored value over 4,096 characters.
STORAGE_FULLOver 1,024 keys.
RATE_LIMITEDToo many writes or payment requests; wait a moment.
NEEDS_TAPpay(), share(), pickImage(), pickVideo(), getNickname({ ask: true }), multiplayer.host(), multiplayer.join(), ai.image.generate() or ai.audio.generate() wasn't called from a tap or click.
BUSYA Batiyao sheet (payment, share, picker or nickname) is already open, or one of your AI requests is still running.
STORE_FULLThe shared store is full (1 MB) and nothing in it may be replaced (section 10.2).
MATCH_FULLThat match has as many players as it allows.
NO_SUCH_MATCHNo open match with that code or id for this person.
ALREADY_IN_MATCHLeave the current match first.
NOT_IN_MATCHsend() without a match.
MESSAGE_TOO_LARGEA multiplayer message over 1,024 bytes of JSON.
INVALID_IMAGEThe image passed to share() isn't a readable PNG or JPEG of 64–4,096 px a side.
IMAGE_TOO_LARGEThe image passed to share() is over 6 MB.
INVALID_TEXTshare() text is over 380 characters, or has an @ tag, a # hashtag, a link or a control character.
NO_SUCH_PRODUCTThe product id isn't in the running version's manifest.
NOT_CONSUMABLEAlready consumed, or not a consumable.
LAUNCH_INVALIDThe session ended (e.g. after 12 hours); the person reopens the app.
MINI_APP_UNAVAILABLEThe app was blocked while running.
MINI_APP_DELETEDThe app was deleted by its developer while running.
OFFLINE(Phones) no connection for a call that needs Batiyao's servers.
UNKNOWN_METHODA method this version of Batiyao doesn't know.
NOT_DECLAREDAn ai call for a capability (or the web_search tool) that your manifest doesn't declare.

Upload problems are listed all at once on your app's page, in plain sentences, each naming the file and what to change.

17. A complete minimal app (copy and adapt)

manifest.json:

{
  "batiyao_miniapp": 1,
  "app_id": "ma_abcdefgh23",
  "version": "1.0.0",
  "name": "Tap Counter",
  "summary": "Count your taps, keep your best, buy a golden theme.",
  "category": "games",
  "age_rating": "4+",
  "icon": "icon.png",
  "orientation": "portrait",
  "access": { "type": "free" },
  "products": [
    { "id": "gold_theme", "title": "Golden theme",
      "statement": "A golden colour theme for Tap Counter, yours to keep.",
      "price": "0.5", "kind": "permanent" }
  ]
}

index.html:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
  <title>Tap Counter</title>
  <style>
    html, body { height: 100%; margin: 0; font-family: system-ui, sans-serif; }
    body { display: grid; place-items: center; background: #eef2ff; }
    body.gold { background: #fde68a; }
    button { font: inherit; padding: 12px 20px; border-radius: 12px; border: 0; }
    #tap { font-size: 48px; width: 200px; height: 200px; border-radius: 50%; background: #4f46e5; color: #fff; }
  </style>
</head>
<body>
  <main style="text-align:center">
    <p>Best: <span id="best">0</span></p>
    <button id="tap">0</button>
    <p><button id="buy">Golden theme · 0.5 BuckBuck</button></p>
    <p id="msg" role="status"></p>
  </main>
  <script src="batiyao-miniapp.js"></script>
  <script src="app.js"></script>
</body>
</html>

app.js:

(function () {
  'use strict';
  var count = 0, best = 0;
  var $ = function (id) { return document.getElementById(id); };

  // Something is on screen already: tell Batiyao now, load the rest after.
  Batiyao.ready().catch(function () {});

  function applyPurchases() {
    return Batiyao.getPurchases().then(function (r) {
      var gold = r.purchases.some(function (p) { return p.product === 'gold_theme'; });
      document.body.classList.toggle('gold', gold);
      $('buy').hidden = gold;
    }).catch(function (e) { console.warn('getPurchases', e.code); });
  }

  $('tap').addEventListener('click', function () {
    count += 1;
    $('tap').textContent = count;
    if (count > best) {
      best = count;
      $('best').textContent = best;
      Batiyao.storage.set('best', String(best)).catch(function (e) { console.warn('save', e.code); });
    }
  });

  $('buy').addEventListener('click', function () {
    // Straight from the click: Batiyao shows its own payment sheet.
    Batiyao.pay({ product: 'gold_theme' }).then(function (r) {
      if (r.status === 'paid' || r.reason === 'already_owned') return applyPurchases();
      if (r.status === 'pending') $('msg').textContent = 'Confirming your payment…';
    }).catch(function (e) {
      console.warn('pay', e.code);
      $('msg').textContent = 'That didn’t work. Please try again.';
    });
  });

  Batiyao.on('visibility', function (d) {
    // Pause here when d.visible is false. On return, pick up any payment
    // that was still being confirmed.
    if (d.visible) applyPurchases();
  });

  // Each step on its own, so one failing doesn't skip the other.
  Batiyao.storage.get('best')
    .then(function (v) { best = Number(v) || 0; $('best').textContent = best; })
    .catch(function (e) { console.warn('load', e.code); });
  applyPurchases();
})();

Plus icon.png (square, 128–1024 px) and batiyao-miniapp.js from the SDK.

18. Checklist (for people and AI agents)