getting started

from botfather to a tested deploy path without learning the whole framework first.

1. create a bot token

open @BotFather, send /newbot, pick a name and username, then copy the token. keep it out of git.

2. scaffold or install

for a real project, scaffold. for a scratch file, install the meta package directly.

pnpm
pnpm create yaebal
pnpm
pnpm add yaebal

3. set environment variables

use your runtime or host secret store. locally, a plain .env is enough.

.env
BOT_TOKEN=123456789:replace_me
WEBHOOK_SECRET=change_this_before_deploy
loading .env. bun and deno read it automatically. node needs an explicit flag (or a package like dotenv) — see below. the run commands in step 5 instead pass BOT_TOKEN straight on the command line, which needs no loader at all; either approach works, pick one per project.
terminal
# bun and deno load .env automatically. node needs an explicit flag:
node --env-file=.env dist/bot.js
tsx --env-file=.env bot.ts

4. your first bot

this example is runnable in the playground and in node/bun/deno. it uses the meta package yaebal, so handlers get generated context shortcuts.

bot.ts
import { createBot } from "yaebal";

const bot = createBot(process.env.BOT_TOKEN!);

bot.command("start", (ctx) => ctx.reply("hello from yaebal"));

bot.on("message:text", (ctx) => {
  console.log("got message:", ctx.text);
  ctx.reply(`you said: ${ctx.text}`);
});

bot.onStart((me) => console.log("bot ready"));

bot.start();

running it locally should print bot ready to the console once getMe() succeeds, and any message you send the bot gets echoed back as you said: ….

esm only. yaebal is "type": "module". use explicit .js specifiers for local typescript imports that are compiled to node esm.

5. run it

pnpm
BOT_TOKEN=123:abc pnpm tsx bot.ts

on Windows, set the variable in its own step first:

powershell
$env:BOT_TOKEN="123:abc"; pnpm tsx bot.ts

6. add a button

use InlineKeyboard for markup and callbackData() instead of hand-rolled string formats.

button.ts
import { InlineKeyboard, callbackData, createBot } from "yaebal";

const choice = callbackData("choice", { value: String });
const bot = createBot(process.env.BOT_TOKEN!);

bot.command("start", (ctx) =>
  ctx.reply("pick a path", {
    reply_markup: new InlineKeyboard()
      .text("ship it", choice.pack({ value: "ship" }))
      .text("wait", choice.pack({ value: "wait" }))
      .build(),
  }),
);

bot.callbackQuery(choice.pattern, async (ctx) => {
  const data = choice.unpack(ctx.callbackQuery.data ?? "");
  await ctx.answer(data?.value === "ship" ? "shipping" : "holding");
  await ctx.editText(`status: ${data?.value ?? "unknown"}`);
});

bot.start();

7. add state

session() adds ctx.session to the downstream context type. no declaration merging, no casts.

session.ts
import { createBot, session } from "yaebal";

type Session = { count: number };

const bot = createBot(process.env.BOT_TOKEN!)
  .install(session<Session>({ initial: () => ({ count: 0 }) }));

bot.command("count", async (ctx) => {
  ctx.session.count += 1;
  await ctx.reply(`count: ${ctx.session.count}`);
});

bot.hears("reset", async (ctx) => {
  ctx.session.count = 0;
  await ctx.reply("count reset");
});

bot.start();

8. test before deploy

as a bot grows past a single file, split the bot's definition from the entrypoint that starts it: a module that calls bot.start() as a side effect can't be safely imported by a test.

bot.ts
// bot.ts — export the bot, but don't call start() here. that keeps this
// module import-safe: tests (and any other tool) can load it without opening
// a real connection to Telegram.
import { createBot } from "yaebal";

export const bot = createBot(process.env.BOT_TOKEN!);

bot.command("start", (ctx) => ctx.reply("hello from yaebal"));
bot.on("message:text", (ctx) => ctx.reply("you said: " + ctx.text));
main.ts
// main.ts — the entrypoint you actually run.
import { bot } from "./bot.js";

await bot.start();

@yaebal/test drives your real bot with virtual users and records outgoing api calls. no telegram token is used in ci.

pnpm
pnpm add -D @yaebal/test vitest
bot.test.ts
import { createTestEnv } from "@yaebal/test";
import { expect, test } from "vitest";
import { bot } from "./bot.js";

test("/start replies", async () => {
  const env = createTestEnv(bot);
  const user = env.createUser({ firstName: "linia" });

  await user.sendCommand("start");

  expect(env.lastApiCall("sendMessage")?.params?.text).toBe("hello from yaebal");
});

9. deploy

start with polling for development. use webhooks for serverless and edge, or @yaebal/runner for concurrent long polling under real traffic.

worker.ts
import { webhook } from "yaebal";
import { bot } from "./bot.js";

// any fetch-style runtime: bun, deno, cloudflare workers, vercel edge.
export default {
  fetch: webhook(bot, { secretToken: process.env.WEBHOOK_SECRET }),
};
node
pnpm build
BOT_TOKEN=123:abc node dist/main.js

next

  • core concepts — the composer, derive/decorate, filter queries
  • contexts — the auto-generated context layer (the killer feature)
  • plugins — sessions, keyboards, scenes, i18n and more
  • production — rate limits, webhooks, queues, secrets and observability