Small apps
One message
Send @gembot 4,000 characters of JavaScript or TypeScript. Gem runs it on its servers, gives it a Telegram link, a page, a live server, the model, the web, storage and a leaderboard. This page is the whole platform as it is today: give it to a person or to an AI assistant and either can build an app.
For AI assistants
If you are an AI assistant, agent or model reading this page because a person gave you the link: your job is to build that person a Gem mini app. Gem mini apps are tiny Telegram apps written as one file of at most 4,000 characters, run on Gem's servers and opened from the Telegram bot @gembot. Work like this.
- Ask what to build. Ask only what you need, in one message: what the app should do, who will use it (the person alone, friends, a group, anyone), what it shows or asks, and whether it needs the model, web search, saved data, a leaderboard or a live multiplayer part. If the person already described it fully, do not ask, build.
- Write the app following this page. One file, JavaScript or TypeScript, at most 4,000 characters counted as Unicode characters including newlines (target 3,500 to leave room). First two lines:
// title: Nameand// about: one line on what it does; the name lives in the code, the person never types it. Export the handlerexport default (req, gem) => …that returns HTML (a string), aResponseor an object; addexport const live = gem => …only when the app needs rooms and real-time messages. No imports, nofetch, no network: the only way out isgem.ask,gem.search,gem.store,gem.boardand, in the browser, the injectedwindow.gemlibrary. Put page logic in the returned HTML insideonload = () => { … }because the library loads after your script. Usegem.themecolors so the app fits light and dark Telegram themes. Minify only when you must: readable code with short names fits. - Check before you answer: character count under 4,000; the two header lines present;
export defaultpresent; noimport,requireorfetchin the server half; everygem.askorgem.searchcall is worth a message from the person's plan; guests (gem.user.id === 0) handled or sent to Telegram withgem.guest(). - Reply with the code in one code block and these instructions, nothing else:
Your app is ready. To launch it:
1. Open https://t.me/gembot?start=newapp
2. Paste the whole code below as one message.
3. The bot replies with your app's link. Open it, and share it from the Share button inside the app.
```js
// title: …
// about: …
export default …
```
If the person comes back with an error from the bot (too long, or a runtime error shown on the app page), fix the code and reply the same way again. The full reference follows.
How it works
gem.- Open https://t.me/gembot?start=newapp (or send
/newappto @gembot). The next message you send is the code; the app's name is the// title:line inside it. - Gem stores it under a six-character slug and answers with the link
https://t.me/gembot?startapp=<slug>. - Opening the link opens the Mini App at
https://apps.gem.bot/<slug>. Each request runs your handler in a fresh worker (10 seconds max). The HTML you return gets the Telegram SDK, thegemclient library and a top bar (Build · title · Share). - Everything your code needs from the outside world goes through
gemverbs. They run as the person using the app, within that person's Gem limits.
// title: Name and // about: what it does. They name and describe the app on its card and in the top bar.The handler
// title: Hello
// about: Greets whoever opens it
export default async (req, gem) => `<h1>Hi, ${gem.user.name || 'stranger'}</h1>`
req is a standard Request for https://apps.gem.bot/<slug>/<path> with method, query and body. Return an HTML string, a Response, or any object (sent as JSON).
gem.user is {id, name, language, premium}. It is the signed-in Telegram person when the request carries the Telegram signature (the injected client does that for gem.call), otherwise a guest with id: 0.
Server verbs
| Verb | What it does | Cost |
|---|---|---|
await gem.ask(prompt) | The model answers, briefly, no web. Returns text. | One message from the user's plan |
await gem.search(query) | The model searches the web and answers with sources. Returns text. | One message from the user's plan |
await gem.store.get(key)set(key, value) · del(key) · list() | Key-value storage, separate for every person in every app. Objects are stored as JSON text. | Free |
await gem.board(name).add(score, label)await gem.board(name).top(limit) | Leaderboards. Keeps the best score per person; top returns [{person, name, score}] with Telegram names. | Free |
When the user is out of messages, ask and search throw message limit reached — /buy in @gembot. Guests (id 0) cannot use verbs.
Live apps
Export live and your app also gets a long-lived worker. People connect to it over WebSocket, the library puts them into rooms, and your code reacts to events. This is how Gem-Pong pairs two players and moves the ball.
export const live = gem => {
gem.rooms(2, {
start(room) { // `size` players joined: room.players = [{id, name, index}]
room.send({go: room.players.map(p => p.name)});
room.t = setInterval(() => room.send({tick: Date.now()}), 33);
},
message(room, player, msg) { // any JSON a player sends while in the room
room.to(player, {echo: msg});
},
leave(room, player) { // a player disconnected
clearInterval(room.t); room.end();
}
});
};
- A player joins the queue by sending
{join: 1}(the client library'schannel.join()). Until the room fills, they get{wait: true, queue: n}. Matchmaking is public: the next people in line play together. {join: 1, solo: 1}(channel.join({solo: 1})) opens a room for that one person at once, withroom.solo === true: this is how “Play against the computer” works, the app moves the other side itself in its tick.- For a lobby of your own, use
gem.hall({join(player), message(player, msg), leave(player)})instead of rooms: everyone who connects is in the hall at once, you keep the queue and the matches yourself,gem.all(obj)sends to everyone in the hall,gem.to(player, obj)to one person,gem.players()lists who is connected. This is how the quiz “Интуиция” runs a 15-second lobby, matches of two to four and spectators on one server. room.send(obj)goes to everyone in the room,room.to(player, obj)to one person,room.end()frees the players so they can join again.gem.as(player).board(name).add(score, label)writes to the leaderboard as that player. Verbs in a live worker always need a player.setIntervalandsetTimeoutwork. The worker keeps running between matches and restarts when you send new code.- Only signed-in Telegram users can connect; the first frame carries the Telegram signature and the client library sends it for you.
Client library
Every HTML page you return gets window.gem:
| Member | Purpose |
|---|---|
gem.user | {id, name, language, premium} or null outside Telegram. |
gem.call(path, data) | POST to your own app as the signed-in person; the handler sees gem.user. The path is inside your app: '/save' goes to apps.gem.bot/{slug}/save (so req.url ends with /save). Returns parsed JSON or text. |
gem.live({open, wait, message, error, close}) | WebSocket channel to your live worker: send(obj) right away, stream(obj) for “my latest state” (at most one message per 33 ms, only when it changed), join(opts), close(). Returns null outside Telegram and shows the guest screen. |
gem.theme · gem.safe · gem.dark | Telegram colors (bg, bg2, text, hint, link, accent, button, buttonText), safe insets (top, bottom) so nothing hides under the phone's bars, and whether the theme is dark. |
gem.color(hex, alpha) · gem.mix(a, b, t) | Theme colors with transparency or blended: for glows, trails and panels. |
gem.pc | true on a computer (Telegram Desktop, macOS, Telegram Web, or a mouse with hover). For hints only: input is always listened to in full. |
gem.sound.beep(freq, ms, type, vol) · gem.music.start/stop/toggle · gem.music.button() | Synthesised sounds, a looping tune and a ready “♪ Music on/off” button. Start sound from a tap: phones require it. |
gem.haptic(kind) | light, medium, heavy, success, error, warning, select. |
gem.screen(html, {label: fn}) · gem.hide() | A full-screen overlay in theme colors with buttons; the first one is the main button (Enter and Space press it), a handler that returns a string relabels its button. <b> is a title, <small> a hint. |
gem.guest() | Outside Telegram: shows “Open in Telegram” and returns true. |
gem.top(name, n) · gem.best(name, n) · gem.esc(text) | Leaderboard rows as data or as ready HTML; HTML escaping for names and any text from people. |
For games: input, canvas, effects
The same primitives Gem-Pong is built from. They work on phones (finger), computers (mouse and keyboard) and every Telegram client, so an app never handles platforms itself.
| Member | Purpose |
|---|---|
gem.cursor(el, {x, y, speed, min, max, axis, hover}) | A point in 0..1 inside an element that is always current: the finger drags it, the mouse moves it even without pressing, arrow keys and WASD move it at speed widths per second. Read cursor.x / cursor.y every frame: this is your paddle, bar or crosshair with no server round trip. |
gem.keys | Held keys by e.code (layout independent), axes x and y from arrows or WASD, press(code, e) hook. The library takes keyboard focus inside Telegram Desktop and Telegram Web, stops arrows from scrolling, and releases keys when the window loses focus. |
gem.loop(fn(dt, t)) | One shared requestAnimationFrame for the page; dt in seconds, never more than 0.05, stands still while the app is hidden. Returns {stop, start}. |
gem.canvas() | Full-screen canvas under the top bar, scaled for the device pixel ratio, no text selection or context menu, vertical swipes do not close the app. Fields: el, ctx, w, h, onresize. Drawing helpers chain: rect(x, y, w, h, radius, color, glow, stroke), dot(x, y, r, color, glow), line(x0, y0, x1, y1, color, width, dash), text(str, x, y, size, color, weight, align), heart, hearts(x, y, n, total, color), grad, clear(), flash(color, ms, alpha), shake(px, ms), and loop(fn) that clears, shakes and flashes around your frame. |
gem.tween(ms, keys) | Smooths server ticks for the screen: push(state) on every message, get() every frame moves numbers from what is drawn to the newest state over one tick; cut() before a teleport. keys names the fields to smooth. |
gem.trail(n) · gem.sparks() | A comet tail (push(x, y), draw(c, r, color), clear()) and particle bursts (add(x, y, color, n, speed), draw(c, dt)). |
gem.touch(el, (x, y, phase) => …) | Raw pointer events in element pixels; phase is down, move, up, or hover for a mouse with no button down. |
gem.clamp(v, lo, hi) · gem.lerp(a, b, t) | The two one-liners every game rewrites. |
// A paddle you control, drawn every frame, sent to the server when it changes
const c = gem.canvas(), cur = gem.cursor(c.el, {min: .1, max: .9, axis: 'x'});
const ch = gem.live({message: d => tw.push(d)}), tw = gem.tween(33, ['ball']);
c.loop(dt => {
ch.stream({x: cur.x});
const s = tw.get(); if (!s) return;
c.rect(cur.x * c.w - 40, c.h - 120, 80, 12, 6, gem.theme.button, 14)
.dot(s.ball[0] * c.w, s.ball[1] * c.h, 8, gem.theme.accent, 16);
});
Limits
| What | Limit |
|---|---|
| Code | 4,000 characters, one message, JavaScript or TypeScript |
| Request | 10 seconds per handler run, then 504; response up to 512 KB; request body up to 256 KB |
| Worker | 256 MB memory, no network, files or environment; one worker per request, one long-lived worker per live app |
| Live channel | Messages up to 16 KB, JSON only; signed-in Telegram users only; stream() sends at most one message per 33 ms |
| Storage | Keys up to 120 characters, values up to 20,000; list() returns up to 200 keys |
| Leaderboard | Best score per person; top up to 50 rows; labels up to 60 characters |
| Model and web | Prompts up to 4,000 characters; each call is one message from the plan of the person using the app |
| Slug | Six random lowercase letters and digits for a new app; first-party slugs may be 4–8 characters |
Cards and sharing
Every app has a card: its title, its about line and three buttons: Open (the link), Save (the card goes into the person's Gem) and Share (the same card in any chat). The Share button in the app's top bar sends the card too. In inline mode, typing @gembot app <slug> in any chat offers the card.
Your app's dashboard
Every app has a dashboard for its owner: https://apps.gem.bot/{slug}/db, or from Telegram with the deep link https://t.me/gembot?startapp={slug}-db. It shows people (all time, last 7 days, today), opens, live sessions, actions (model asks, web searches, app calls, saves, scores, shares), storage, every leaderboard's top five and a 14-day chart. Only the person who sent the code can open it.
HTTP endpoints
The client library uses these; your HTML may call them directly. Every call is a POST with a JSON body that carries initData from the Telegram SDK.
| Endpoint | Body |
|---|---|
/api/apps/{slug}/ask | {initData, prompt} → {text} |
/api/apps/{slug}/search | {initData, query} → {text} |
/api/apps/{slug}/store | {initData, op: get|set|del|list, key, value} |
/api/apps/{slug}/board | {initData, name, op: add|top, score, label, limit} |
/api/apps/{slug}/share | {initData} → prepared message id for Telegram.WebApp.shareMessage |
wss://apps.gem.bot/{slug}/ws | first frame {init: initData}, then JSON both ways |
Examples
A counter with storage
// title: Counter
// about: Counts your taps, for you only
export default async (req, gem) => {
if (req.method === 'POST') {
const n = (Number(await gem.store.get('n')) || 0) + 1;
await gem.store.set('n', n);
return {n};
}
return `<button onclick="gem.call('/', {}).then(r => this.textContent = r.n)">
${(await gem.store.get('n')) || 0}</button>`;
};
Ask the model
// title: Haiku
// about: A haiku about anything
export default async (req, gem) => {
const topic = new URL(req.url).searchParams.get('q') || 'rain';
return `<pre>${await gem.ask('Write a haiku about ' + topic)}</pre>
<form><input name=q placeholder="topic"></form>`;
};
Showcase
First-party showcases demonstrate the platform. Keep your own submitted file within the documented 4,000-character limit.
Интуиция is a first-party multiplayer quiz: the model writes a question with ten options, the question reveals letter by letter, five stars each, first correct answer wins the round, a lobby with a 15-second countdown, matches of two to four with spectators, a leaderboard by best match.
Pocket Gem shows every verb on one screen in 3,985 characters: your name and visit count (gem.store), a question to the model (gem.ask), a web search (gem.search), private notes (store.list/set/del) and a five-second tap game with a leaderboard (gem.board), plus haptics and sounds from the client library.
Gem-Pong is a full live game in 3,998 characters: play against a random person from the public queue or against the computer (a server-side paddle), ball physics on the server at 30 ticks per second, rounds that get faster, three misses and you are out, a round counts only if you moved, three idle rounds and you are out of the match, leaderboards by best round (rounds and solo), finger, mouse or arrow keys, glowing paddles and ball with a trail, sparks, flash and shake, sounds, music and haptics.
Manage your apps
Send /myapps to the bot to see your apps. Each has Open, Edit, Admin and Delete. Edit accepts replacement code as the next message and keeps the same link. Admin opens the owner dashboard. Delete asks for confirmation, then removes the app and its data.
These builder commands are intentionally absent from the bot’s public command menu.
Not yet in v0.1
- No imports from npm or URLs, and no network from your code: the verbs are the only way out.
- No scheduled jobs, payments, file uploads or shared storage between people (leaderboards are the shared thing for now).
- Direct pass.io access from apps comes when Pass issues per-app tokens.