Telegram bot with a durable SQLite queue: runs single-step skills and multi-phase flows with state, a human gate, and recovery after a crash.

The full path of a message. The two amber boxes are the most important boundaries: the gateway never does the work, and the runtime is the only thing that decides the next phase.
SQLite in WAL mode, atomic claim, lease with heartbeat, and drain on shutdown. A kill -9 in the middle of a job, it never leaves the state undefined — nothing remains running with a live lease.
Playwright/aprovar) and selective resumption with /refazer.
A skill is a prompt + an entry in the registry. A flow is a repo with flow.json + prompts/. Neither one requires a line of TypeScript in the bot.
The interpreter validates against
io (10) · cpu (1) · texto (2) · render (1) · navegador (1). A heavy render doesn't block the text queue.
A skill = one step, stateless—starting over from scratch is acceptable. A flow = multiple phases with state, when throwing away partial work would be absurd.
Migrations → media root → recovery of expired leases → only then workers and bot. This is the order that makes the process recoverable after a crash.
Node with TypeScript, a bot created on Telegram, and a .env with the five required variables. Without one of them, boot fails loudly and early, before starting any worker.
Install and run the tests before deploying anything.
# at the repo root npm install npm test
Create the bot in @BotFather and save the token. The actual value never goes into git.
# .env (mode 600, outside Git) BOT_TOKEN=… ALLOWED_CHAT_IDS=123,456
Queue database path, state root, and log file — the other three required settings.
QUEUE_DB=./inemaccbot.db STATE_DIR=./estado LOG_FILE=./inemaccbot.log
All commands below are real—the shell commands run in the repo, and the ones that start with a slash are typed in Telegram.
Prompt text is embedded in the workflow when it’s created. Editing the prompt today doesn’t change yesterday’s workflow—and
npm install npm test # vitest run npm run typecheck # tsc --noEmit npm run build # tsc -> dist/index.js
In production via systemd, with the .env in the same working directory (deploy/inemaccbot.service).
node dist/index.js # directly systemctl --user start inemaccbot # as a service
In the authorized chat. /ajuda lists everything; /skills e /fluxos are the two catalogs.
/ping /ajuda /skills # one step, no state /fluxos # multiple phases, with state
The gateway validates who’s speaking (chat allowlist), the command becomes a job in the right queue, a worker claims the job with a lease, the agent runs it, and the notifier returns the result in chat. <skill>: <entrada> [| campo]*. Generic fields: livesN (destination), modelo=haiku, esforco=high.
transcrever: https://… # audio → text explicativo: <assunto> | vertical # 9:16 video imagem: uma raposa ruiva na neve | ratio=16:9
| sombra prints phase × target × queue × task and doesn’t queue anything. It's the cheap way to discover you were about to trigger 12 audiences by mistake.
/promoavatar <assunto> | sombra /promoavatar <assunto> --alvo=jovens /promoavatar <assunto> | alvos=mulheres | legenda
/status is the dashboard for OPEN flows; /completos lists the ones that finished. A#9, a#9, A9 e a9 are the same thing — numbers only (13) is always a job.
/status # open flows /status A#9 # phase × target detail /fila # running, pending, errors in 24h /pronto A#9 # = /aprovar — opens the gate
/refazer in a flow, resumes from the failed phase; for a single target, reruns that target. /cancelar removes it from the list without deleting anything already created elsewhere.
/refazer A#9 mulheres # only the audience that failed /cancelar A#9 # disappears from /status; the flow still exists /furar j13 # puts a pending job at the front
Without the word confirmar at the end, it only shows what would be deleted and how much it would free up. The bot only touches what IT published inside of ~/projetos/output.
/espaco # disk by area (bot × skills) /limpar A#8 # dry run /limpar A#8 confirmar # runs
claude saiu com código 143 é 128 + 15 = SIGTERM: the job was killed by a service restart, not an agent error. A reel render takes 10–15 min — two consecutive restarts use up both attempts for the same job.
sqlite3 inemaccbot.db \ "select id,fila,tarefa,status,flow_ref from jobs where status in ('queued','running');" # Empty → restart whenever you want. With a render in flight → wait.
This is the design test: a new domain shouldn’t require a line of code in the bot. Choose between a skill and a workflow based on whether the work is partial.
Use this when “running it again from scratch” is acceptable. Write the prompt, declare it in the registry, and run the tests. A declared field must be used in the prompt, and a prompt variable must be declared — there are tests for both.
# 1. prompts/minhaskill.md — uses {{input}} and {{saida}}, # and the agent's last line is RESULT: <path> # 2. config/skills.json { "command": "minhaskill", "fila": "texto", "kind": "agent", "prompt": "prompts/minhaskill.md", "artefato_exts": ["txt"], "max_tentativas": 2, "timeout_segundos": 3600 } # 3. npm test
Use this when there’s partial work that would be absurd to throw away. It gains /status, /refazer selective, resumable, and frozen definition. The domain says WHO it’s for; the bot knows WHERE — never put a path in the flow.json.
# 1. repo ~/projetos/<nome> with flow.json + prompts/ # 2. flow.json: name, prefix (the P in P#16), versao_def, # targets and phases (id, scope, queue, kind, task…) # 3. config/fluxos.json: { command, repo, descricao, exemplo } # 4. check without spending anything: /<fluxo> <assunto> | sombra
Every domain added to the catalog replies with help — not through discipline, but by design: those who understand write (HELP.md in the flow, <prompt>.help.md in the skill) and, if it didn't provide one, the help text is derived from the same record the bot uses to execute it. A test scans both catalogs and fails if any domain doesn't provide usable help.
Steps 0 through 5 completed, plus the domain workflows. v1 (inemaccvbot, mkivideos, mkitexto) is disabled.
chat_id to be INTEGER. Recommendation under review: don't replace it; add to it.