Send code Get a working link
Write the handler and page in one JavaScript or TypeScript file. Send it to @gembot, and Gem replies with a link to your app in Telegram. Use Gem’s built-in tools for storage, AI, search and sharing.
Try the counter below, or give your coding assistant the build instructions. Your file can contain up to 4,000 characters.
Already have a guide, menu or manual? Send the document to get sections, search and answers from its contents. No code needed.
Run the counter, then make it yours
- Open Gem’s app builder and send the complete code below as one message.
- Tap Open in Gem’s reply. Tap the counter a few times, close the app and open it again: your number is saved.
- Use Share to send the app to a friend. They get their own counter, starting at zero.
// 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>`;
};Try an update. Change the increment from + 1 to + 2. In @gembot, send /myapps, choose Edit for your counter and send the complete replacement file. Open the same link to try the change.
For your own idea, give a coding assistant the build instructions. Use the interface guide for screens and controls, and the style guide for Gem’s type, colors and icons.
Browse the reference
For AI assistants
Give this page to your coding assistant together with the job you want the app to do. For example: “Make a counter I can use at the gym. Save my count, let me reset it, and use Telegram’s colors.” The assistant should return one complete file and the steps to launch it.
- Define the first useful version. Work from the person’s description. Establish who uses the app, what they do on its first screen and what needs to be saved. Add AI, search, scores or live interaction only when the job needs them. Ask for missing requirements together; if the brief is complete, build.
- Write one complete file. Use JavaScript or TypeScript and stay within 4,000 Unicode characters, including newlines. Aim for 3,500 to leave room for fixes. First two lines:
// title: Nameand// about: one line on what it does; Gem reads the app’s name from the file. 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. Keep the code readable; shorten it only as needed to meet the size limit. - Use Gem for services and data. No imports, no
fetch, no network: the only way out isgem.ask,gem.search,gem.store,gem.board,gem.kband, in the browser, the injectedwindow.gemlibrary. Leaderboard scores are written by server code only (gem.board(name).addinexport defaultorlive); the page reads them withgem.toporgem.best. - Use the document route when it fits. If the app is built around a document (a guide, a manual, a menu, a course), tell the person they can send the document itself to @gembot after
/newapp; for a design of your own, usegem.guide({...})orgem.kb(see Knowledge apps). - Connect the page after it loads. Put page logic in the returned HTML inside
onload = () => { … }because the library loads after your script. Usegem.themecolors so the app fits light and dark Telegram themes. - Check before sending: 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(); leaderboard scores added only in server code; notelegram-web-app.jsscript tag (Gem providesTelegram.WebApp). - Deliver the whole file. Put it in one code block, ready to paste. Include these launch steps:
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 Gem rejects the file or the app fails, fix the reported error and return the complete replacement file. Use Edit to update the existing app; a fresh /newapp request creates another one.
How it works
The handler and the HTML it returns share the 4,000-character budget. You send both in the same message.
The handler runs in a Deno worker on Gem. Its returned page runs in a separate browser sandbox. Server code has no network, filesystem or environment access.
A page request runs the handler once. For a game or shared session, a live worker stays running to manage rooms and WebSocket messages.
Use gem for AI, web search, personal storage, leaderboards, identity and sharing.
- 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>: Gem's frame with the top bar (Build · title · Share), and your page inside it. Each request runs your handler in a fresh worker (10 seconds max). The HTML you return gets thegemclient library and Gem's ownTelegram.WebApp. - Use the
gemAPIs for external capabilities. Requests run on behalf of the person using the app and use that person’s allowance.
// title: Name and // about: what it does. They name and describe the app on its card and in the top bar.Your page is sandboxed
- The app page is isolated inside Gem’s frame. It cannot read the person’s notes, reminders or cards, or use another app’s data.
Telegram.WebAppinside your page is Gem's stand-in: theme, safe areas, haptics, the Main, Back and Settings buttons, popups, links,shareMessage, QR scanning,downloadFileandCloudStorage(keys kept separate per app) work. ItsinitDatais your app's pass: it opens your app only, for 24 hours, and is not the Telegram signature. The phone number, clipboard and write access are not available to apps.- Gem supplies the Telegram interface, so omit
telegram-web-app.js; its script tag is removed. BrowserlocalStorageis available, isolated to your app and stored on the person’s device. - Links to your own pages (
/<slug>/…) load through Gem; other links open in Telegram's browser.
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 your app's pass (the client library does that for gem.call), otherwise a guest with id: 0. The pass itself never reaches your code.
Server verbs
| Verb | What it does | Cost |
|---|---|---|
await gem.ask(prompt) | Returns a short AI answer as text. Does not search the web. | One message from the user's plan |
await gem.search(query) | Searches the web and returns an AI answer with sources as text. | One message from the user's plan |
await gem.store.get(key)set(key, value) · del(key) · list() | Read and write key-value data for the current person in this app. Objects are saved 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. Scores are added only here, in server code: pages read the board but cannot write to it. | Free |
await gem.kb.list(opts)gem.kb.search(query, opts) · gem.kb.ask(question, opts) | Your app's knowledge (see Knowledge apps): the whole document in order, search by words, typos and meaning, and answers only from the document with numbered sources. | ask: one message; the rest free |
When the person has used their message allowance, ask and search throw message limit reached — /buy in @gembot. Guests (id 0) can browse knowledge and search it by words. AI, personal storage, leaderboards and live connections require Telegram sign-in.
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.gem.as(player).ask,searchandkb.askspend that player's message only right after their own message or join (within 60 seconds), at most six a minute.setIntervalandsetTimeoutwork. The worker keeps running between matches, stops after 2 minutes with nobody connected (and starts again when someone joins), and restarts when you send new code. A worker that does not respond for 15 seconds (an endless loop) is stopped, and its players are disconnected with code 4003.- Only signed-in Telegram users can connect; the first frame carries your app's pass and the client library sends it for you. A connection may send up to 40 messages a second.
Knowledge apps
Turn a document into a browsable, searchable app. People can read the cards or ask questions and follow numbered citations back to the relevant source. This works for guides, manuals, menus, courses and price lists. For example: Mausfit, a nutrition guide made from one PDF.
- Start with a document. Send
/newappto @gembot, then a PDF, Markdown, JSON or text file instead of code. Gem organizes the content into sections and cards, names the app, adds four suggested questions and returns a link. Each document can contain up to 1,500 entries. - Keep the content current.
/myapps→ Knowledge on any of your apps, then a file or pasted text. The replacement becomes the source for browsing, search and answers. - Build your own interface. In the page or in server code:
gem.kb.list({section, offset, limit})gives the document in order with its sections;gem.kb.search(query, {limit, section})finds by words and typos, and by meaning for signed-in people;gem.kb.ask(question, {history, style, strict})answers from the 10 closest entries and returns{text, sources, cited}, where the text marks facts with[n].styleis your voice and rules in plain words;strict: falselets the answer add general knowledge, marked as such. - Use the ready-made guide.
gem.guide({...})draws sections, cards, search, the ask tab with sources, a saved list and reading progress. Options:title, kicker, subtitle, accent, questions, style, strict, saved, cta, tabs;tabsadds your own tabs as{id, icon, label, render(el, api)}, the way Mausfit adds a calculator and a quiz.
// title: Our menu
// about: The menu, searchable, with answers
export default () => `<script>onload = () => gem.guide({
title: 'Our menu', accent: '#8E1B2C',
questions: ['What is vegan?', 'What is not spicy?'],
style: 'Answer like a friendly waiter, in two sentences.'
})</script>`;
// Then: /myapps → Knowledge → send the menu as a PDF or text.
For richer cards, send JSON: [{section, title, body, url, tags, meta}]. meta.kind picks the card: product (with n, brand, shop, more), tip, recipe, place, combo (with meal), lead (a section's intro line) or cta; url becomes the card's button.
Client library
Gem injects window.gem into the HTML your handler returns. Use these helpers for requests, themes and interaction:
| 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.event(name) | Report an event to the app’s project rules, for example await gem.event('completed'). Returns {ok: true}. A matching “app” rule fires once per person. Requires a signed app pass; standalone apps return 404. Call after the action succeeds, not when a page merely opens. |
gem.app.project | For an attached app: {name, title, langs, url}, also available as window.GEM_APP.project. Absent for standalone apps. Use it to show project context; it is not proof of authorization. |
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. Scores are added in server code. |
gem.kb.list/search/ask · gem.guide({...}) | Your app's knowledge and the ready-made guide (see Knowledge apps). |
For games: input, canvas, effects
These helpers power Gem-Pong and support touch, mouse and keyboard input. Use them to keep the same interaction working across Telegram clients without separate controls for each device.
| 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 (Gem's top bar is outside your page), 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) | Clamp a number to a range or interpolate between two values. |
// 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 | No network, files or environment; one worker per request, one long-lived worker per live app. Apps share runner processes with a 192 MB memory ceiling each: an app that runs out of memory is stopped (503) and its process restarts in about a second |
| Live channel | Messages up to 16 KB, JSON only, up to 40 a second per connection; signed-in Telegram users only; stream() sends at most one message per 33 ms; the worker stops after 2 minutes with nobody connected, or after 15 seconds without a response |
| Storage | Keys up to 120 characters, values up to 20,000 characters (longer is an error, not a cut), up to 1,000 keys per person per app; list() returns up to 200 keys |
| Leaderboard | Best score per person; scores from server code only; top up to 50 rows; labels up to 60 characters |
| Calls per minute | Per person per app: storage 120, leaderboard 60, share 10, knowledge search 30 (guests: 300 for the whole app). Over the limit: 429 too many requests |
| Knowledge | Up to 1,500 entries from a document sent in chat; answers use the 10 closest entries; each ask is one message |
| 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
Gem gives each app a card with its title and description. Open launches it, Save keeps it in the person’s Gem, and Share sends the card to another chat. The app’s top-bar Share button uses the same card. You can also find it from any chat by typing @gembot app <slug>.
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 wraps these HTTP endpoints. You can also call them from your app’s HTML. Use POST with JSON; authenticated calls carry initData, the app-scoped pass exposed as Telegram.WebApp.initData. The table marks endpoints that allow guest access.
| 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: top, limit} (adding scores is server code only) |
/api/apps/{slug}/kb/list | {doc?, section?, offset?, limit?} → {items, total, sections}, no sign-in needed |
/api/apps/{slug}/kb/search | {initData?, query, limit?, section?} → {hits}; by meaning only with initData |
/api/apps/{slug}/kb/ask | {initData, question, history?, style?, strict?} → {text, sources, cited} |
/api/apps/{slug}/kb/load | {initData, items | text, doc?} → {count}, the app's owner only |
/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
Open these apps to see the APIs in use. They are maintained by Gem; your submitted code must still fit the 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.
Mausfit is a knowledge app made from one PDF: a nutrition guide with 14 sections and 134 cards with shop links, search, answers with sources, a shopping list, a calculator and a quiz scored on the server, built on gem.guide with two tabs of its own.
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, Delete and Knowledge. 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. Knowledge takes a file or pasted text and makes it the app's knowledge.
Use these commands directly in @gembot; they are not listed in the general command menu.
Not yet in v0.1
- Server code cannot import packages from npm or URLs or make direct network requests. Use the documented Gem APIs.
- App code cannot run scheduled jobs, collect payments or accept file uploads. Personal storage is separate per person; leaderboards and knowledge provide shared data. Creators can upload documents through @gembot to build or update knowledge apps.
- Direct pass.io access is not available. It requires app-scoped tokens from Pass.