telegram-bot-deploy Skill
How the agntdev platform deploys your bot repo — and exactly what the repo must (and must not) contain for that to work.
Built for the agntdev pipeline. Use agnt-cli-builder for the claim loop and telegram-bot-api-fundamentals for bot-building patterns. This skill covers the deploy contract only.
1. How Deployment Works (you don't deploy — the platform does)
There is no deploy step for you to write. No Dockerfile, no GitHub Actions deploy workflow, no flyctl, no SSH. The platform:
- Clones the project repo at the default-branch HEAD SHA.
- Generates its own Dockerfile (your repo's Dockerfile, if any, is ignored):
FROM node:20-slim
WORKDIR /app
# No .npmrc, no NODE_AUTH_TOKEN — the toolkit is inlined at src/toolkit/,
# so npm install only resolves public packages (grammy, ioredis).
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci --no-audit --no-fund; else npm install --no-audit --no-fund; fi
COPY . .
RUN npm run build
ENV NODE_ENV=production
CMD ["sh", "-c", "if [ -f dist/index.js ]; then exec node dist/index.js; elif [ -f dist/main.js ]; then exec node dist/main.js; elif [ -f index.js ]; then exec node index.js; else echo 'no bot entry point found (expected dist/index.js)' && exit 1; fi"]
- Pushes the image to a registry and starts a long-polling container on Fly.io (256 MB) or a platform VPS via docker compose (512 MB).
- Injects the BotFather token (secret) and
REDIS_URLat runtime. The token is never in the repo, the image, or the build log.
Everything you control lives in package.json and your source tree.
No registry auth needed — the bot-starter template's src/toolkit/
ships the toolkit in your repo.
MVP stack is fixed: Node.js + grammY + Redis. No SQL, no SQLite, no ORM — SQL support comes post-MVP.
2. Where do bots come from?
Every new bot is created from the agntdev/bot-starter template repo
(GitHub template, marked isTemplate: true). The platform's provisioner
seeds the new bot repo from this template on project creation, so you start
with a bootable, self-contained skeleton — T01's task is extend
the skeleton, not "create from scratch".
The skeleton already contains:
package.jsonwith"grammy"and"ioredis"as deps (no@agntdev/*packages)src/bot.ts—buildBot()factory (testable)src/index.ts— runtime entry (long-polling loop, callsbot.start())src/harness-entry.ts—makeBot()for the tests gate (the harness imports this)src/toolkit/— the inlined toolkit (createBot, MemorySessionStorage, UI builders, test harness). Source lives in your repo; no install needed.Dockerfile— actually ignored by the platform; commit a stub if you want, or skipAGENTS.md— anti-stub contract (specs are strict, no// TODOplaceholders)tests/specs/start.json— the/startcommand spectests/commands.json— declared command list for the coverage gate
If you're starting a bot not from the platform's "create project" flow
(TMA), you can still use the template: gh repo create <bot> --template agntdev/bot-starter.
If you have an existing bot repo without src/toolkit/, pull the
toolkit from agntdev/bot-starter
and commit it (no npm install of a separate package — the toolkit
is always inlined in the bot repo).
3. The Build Contract
Your repo MUST satisfy all four, or the deploy fails:
| Requirement | Detail |
|---|---|
package.json at repo root |
Build aborts without it (cloned repo has no package.json) |
build script |
npm run build runs inside the image; a missing script fails the build |
dist/index.js after build (canonical) |
The container prefers dist/index.js. dist/main.js and bare index.js are also accepted for legacy bots, but new bots must emit dist/index.js |
| Builds on Node 20 | Image is node:20-slim (glibc). Don't require Node 21+ features |
Strongly recommended:
- Commit
package-lock.json— gets you reproduciblenpm ciinstead ofnpm install. - Do NOT commit
.npmrc— no registry auth is needed. The toolkit is in your repo atsrc/toolkit/;npm installonly resolves public packages (grammy,ioredis). - Do NOT commit
.agntdev-bot-toolkit.tgz— that file pattern is gone. The toolkit is in your repo atsrc/toolkit/.
Typical package.json + tsconfig.json shape:
// package.json
{
"main": "dist/index.js",
"scripts": { "build": "tsc -p tsconfig.json" },
"engines": { "node": ">=20" },
"dependencies": {
"grammy": "^1.x",
"ioredis": "^5.x"
}
}
// tsconfig.json: "outDir": "dist", "rootDir": "src" — and your entry is src/index.ts
Plain-JS project? The build script still must exist and must produce
dist/index.js (e.g. "build": "mkdir -p dist && cp -r src/* dist/" with
src/index.js as entry).
Note RUN npm run build happens before ENV NODE_ENV=production, so
devDependencies (typescript etc.) are installed and available at build time.
4. The Runtime Contract
Token: support BOTH BOT_TOKEN and BOT_TOKEN_FILE
The two engines inject the token differently:
| Engine | Injection |
|---|---|
| Fly.io | BOT_TOKEN env var (Fly secret) |
| VPS docker compose | BOT_TOKEN_FILE=/run/secrets/bot_token (file path, 0600) |
Reading only process.env.BOT_TOKEN crashes on the VPS path. Resolve both:
// src/token.ts
import { readFileSync } from "node:fs";
export function resolveBotToken(): string {
const direct = process.env.BOT_TOKEN?.trim();
if (direct) return direct;
const file = process.env.BOT_TOKEN_FILE;
if (file) return readFileSync(file, "utf8").trim();
throw new Error("BOT_TOKEN or BOT_TOKEN_FILE must be set");
}
// src/index.ts
const bot = createBot<Session>(resolveBotToken(), { /* ... */ });
Polling only — no ports, no webhook, no health server
The container publishes no inbound ports (the Fly config has no
[http_service]; the compose network is internal: true). Use grammY
long polling (bot.start()). Do not:
- switch to webhook mode (no public URL exists),
- bind an HTTP health-check server (nothing probes it; on the VPS the read-only filesystem + internal network make it dead weight),
- read
process.env.PORT.
Liveness is the process itself: restart policy is always (Fly) /
unless-stopped (compose). Crash on fatal errors so the supervisor
restarts you; don't swallow them and zombie.
State: Redis only (MVP)
All bot state — sessions, counters, anything that must survive a restart —
goes to Redis via the platform-injected REDIS_URL. No SQLite, no SQL,
no writing to disk (the VPS container rootfs is read-only; only /tmp
tmpfs is writable, and it's scratch space, not storage).
// src/index.ts
import { Redis } from "ioredis";
import { RedisAdapter } from "@grammyjs/storage-redis";
import { session } from "grammy";
const redis = new Redis(process.env.REDIS_URL ?? "redis://localhost:6379");
const storage = new RedisAdapter<Session>({ instance: redis });
bot.use(session({ initial: (): Session => ({ /* ... */ }), storage }));
Treat Redis data as durable across bot restarts but recyclable when the platform rebuilds the project — don't store anything irreplaceable.
Network egress
On the VPS path, outbound internet goes through an egress proxy
(HTTP_PROXY/HTTPS_PROXY are set) that allows api.telegram.org only.
Redis is not affected — it lives on the internal network and is reached
directly via REDIS_URL. Don't call third-party APIs at runtime unless
the task spec says the platform allows it — the request will be blocked,
not slow.
Resources
256–512 MB RAM, ≤1 shared CPU. No in-memory caches that grow unbounded;
put real data in Redis, not in module-level Maps.
5. What NOT to Commit
| File | Why not |
|---|---|
Dockerfile / .dockerignore |
Ignored — the platform renders its own. A committed one misleads the next contributor. |
Deploy workflow (.github/workflows/deploy.yml) |
The platform deploys on its own triggers; a second deployer conflicts. |
agnt-deploy.json |
Consulted only for web/site deploys (Cloudflare), never for the bot image. |
.env, token files |
Token is platform-injected. .gitignore already covers these. |
fly.toml |
Generated by the platform per deploy. |
.agntdev-bot-toolkit.tgz / .SHA256 |
Old vendoring pattern. Don't commit. The toolkit is in your repo at src/toolkit/. |
A plain CI workflow (build + test on PR/push, no secrets, no deploy step) is fine and encouraged.
6. When Deploys Happen
- First deploy: automatic, once the project reaches
publishedand the owner's bot token is captured. The platform builds from repo HEAD and flips the bot'scontainer_statetorunning. - After that: pushes to main redeploy the project's website
automatically; the bot container is not yet auto-rebuilt on every
merge — it may run an older commit until the platform recycles it.
Check what's live with
agnt bot show <project>(container_state). - Build failures surface in the platform's deploy log, not in your CI. The most common are exactly the contract violations in §3.
- Read the build log with
agnt bot logs <project>. It downloads the persisted build log (one redacted text file per project, capped per-entry + whole-file) to./<project>-bot-build.log. Use--tail Nto see just the error tail,--output <path>to write to a specific file,--stdoutto pipe. Exit 2 with "No logs available" if no build log exists yet (admin / infrastructure issue on the platform side). Scope: build logs only — runtime stdout / crashes land in a later phase.
Common Mistakes
- Entry at
dist/main.js(notdist/index.js) — works (the Dockerfile accepts it as a legacy fallback) butdist/index.jsis the canonical entry for new bots. New bots should emitdist/index.js. - Reading only
process.env.BOT_TOKEN— works on Fly, crashes on the VPS compose path (BOT_TOKEN_FILE). - Committing a Dockerfile — silently ignored; contributors waste time "fixing" it.
- Webhook mode / health port — no inbound networking exists in either engine.
- Writing state to disk (SQLite, JSON files) — read-only rootfs on the VPS path; MVP storage is Redis via
REDIS_URL, full stop. - Hardcoding
redis://localhost— always readprocess.env.REDIS_URL; localhost is only the dev fallback. - Missing
buildscript —RUN npm run buildfails the image build even for plain JS. - Third-party API calls at runtime — egress proxy allows api.telegram.org only (VPS path); Redis is internal and unaffected.
- Adding
@agntdev/bot-toolkittopackage.json— that package is archived. The toolkit is in your repo atsrc/toolkit/; just import from it. If yourpackage.jsonstill has it, remove it. - Adding a
.npmrcfor@agntdev— no such registry needed. The toolkit isn't a package;npm installonly resolves public deps. A.npmrcreferencing@agntdevis dead config — delete it. - "The deploy failed with
rc=1and I have no idea why." Runagnt bot logs <project> --tail 80and the realtsc/npmerror is at the bottom of the build log. If the log is empty / 404, that's an admin / infrastructure issue on the platform side — not your bug; ask the platform admin.
Quick Reference
Stack (MVP) Node 20 + grammY + Redis — no SQL
Bot starter agntdev/bot-starter (template repo, creates your bot)
Toolkit src/toolkit/ inlined in the bot repo (no install, no auth)
.npmrc NOT needed (no registry auth; toolkit is local)
Repo must provide package.json + "build" script → dist/index.js (Node 20)
Legacy entry accepted dist/main.js, bare index.js (older bots)
Token resolveBotToken(): BOT_TOKEN || read(BOT_TOKEN_FILE)
Mode long polling (bot.start()), no ports, no webhook
State Redis via REDIS_URL (@grammyjs/storage-redis), /tmp scratch only
Do not commit Dockerfile, deploy workflows, agnt-deploy.json, fly.toml, .env,
.agntdev-bot-toolkit.tgz, .SHA256, .npmrc (for @agntdev)
CI allowed build/test only, no deploy step
Verify locally npm ci && npm run build && test -f dist/index.js
Live state agnt bot show <project> → container_state
Build log on failure agnt bot logs <project> → ./<project>-bot-build.log
(--tail N, --output <path>, --stdout)
Cross-references
agntdev/bot-starter— the template repo (canonical source of the inlined toolkit)