PTENES
System map · three repos

How the system works — and where to make changes in each thing.

One bot (inemaccbot) that knows nothing about video, and two domains (promoavatar, promoavatar3) that know nothing about queues. This page has the diagram for each one, the configuration file map, and the “I want to change X, where do I make the change?” table.

1 · Overview

Five layers, one rule

The rule that underpins the design is written at the top of the src/fluxos/runtime.ts: the orchestrator doesn’t do the work, and the worker doesn’t make decisions. THE SIX STEPS OF montar-reel.py done/failed; the runtime reads this and chooses the next phase — doing so within the same ack transaction, so “phase completed” and “next phase queued” never exist separately.

CHAT GATEWAY QUEUE EXECUTION DELIVERY Telegram gateway/telegram.ts · long polling One paired chat (ALLOWED_CHAT_IDS). Every system input passes through here. gateway/mensagem.ts — the router classifies the message into four paths and returns a response in seconds (no heavy work here) comandos.ts /fila /status /espaco /ping gramatica.ts skill: input | fields interpret.ts free text → short agent comandos-fluxo.ts /promoavatar3 · /aprovar The same skeleton, with the target becoming the registry: a closed catalog. fila/store.ts — SQLite in WAL mode, one durable queue jobs: queued → running → done | failed | canceled · lease with heartbeat · atomic claim · resume after a crash render · 1 browser · 1 text · 2 io · 10 cpu · 1 fila/filas.ts · CONCORRENCIAS worker.ts one per queue kind: agent runner-claude.ts · runner-chrome.ts kind: function fila/tarefas/* — no model, no prompt heygen.gerar · heygen.gerar-creditos · heygen.baixar · heygen.estudio reel.montar · ffmpeg.thumb · http.get (registered in fila/tarefas/index.ts) fluxos/ runtime.ts reads the job ack and queues the next one phase in the SAME transaction state/artefatos/ canonical source for the bot publicar.ts renames it to the title destinos.ts livesN → yt-pub-livesN/imports/videos notificar.ts notifies you in chat
The domain says

🚪 Gateway

Measured in seconds. Classifies the message, validates it against the registry, and queues it. If the agent for the interpret If it hallucinates a command that doesn’t exist, the response is to refuse — closed catalog.

🗄️ Queue

SQLite in WAL mode. Five resource classes with independent concurrency, so a render doesn't take a download's turn. Lease with heartbeat: if the process crashes, the job returns to the queue instead of disappearing.

⚙️ Execution

Two types of jobs. kind: agent calls a model with a prompt; kind: function is deterministic TypeScript code. Today, only the text phase uses a model — avatar and reel are functions.

2 · Configuration

The bot knows nothing about video

This is the boundary that lets the system grow without getting bloated. The bot knows about queues, gates, retries, and delivery. The domain knows about audiences, prompts, templates, and the rendering engine. They meet in exactly two files.

BOT REPO · ~/projetos/inemaccbot public · TypeScript · doesn’t know about any domain DOMAIN REPO · ~/projetos/promoavatar3 sibling, not a submodule · JSON + prompts + Python config/fluxos.json records the command and says WHICH repo to load · read at BOOT config/skills.json single-step skills (transcribe, explainer, reel…) · read at BOOT src/dominio/flow.ts validates flow.json · freezes the definition · resolves variant and CTA src/fila/tarefas/index.ts task catalog: what `kind: function` can call .env TELEGRAM_TOKEN · ALLOWED_CHAT_IDS · HEYGEN_API_KEY · PROJETOS_DIR state/ · inemaccbot.db queue, flow state, and artifacts — not edited by hand flow.json targets · phases · avatar/voice · templates · cta · motor_repo prompts/*.md what the text phase writes · variants swap this file templates/*.json + templates/mapa.json reel layout · and the editorial format → layout map scripts/ (preparar.py · montar.py · montar-reel.py) the rendering ENGINE — can be shared via motor_repo cta/*.mp4 closing clip, selected by variant HELP.md · textos/ helps in two layers and the output from the text phase repo: validates
Only two arrows cross the boundary. config/fluxos.json says which folder to load; the flow.ts validates the flow.json that came from there and rejects anything malformed before any job exists.
Golden rule for anyone building another system like this: the domain references the channel by NAME (lives2), never by path. The bot is the only one that knows where this lives on disk (src/dominio/destinos.ts). A new channel appears just by creating the folder yt-pub-livesN — without editing or recompiling the bot.

When the file is read

FileRead whenNeed to restart?
config/fluxos.jsonwhen the process bootsYes — and check that the queue is empty first: restart kills in-flight renders
config/skills.jsonwhen the process bootsYes
flow.json of the domainwhen each flow is createdNo
prompts/*.md of the domainwhen it's created — the text is frozen within the flowNo (but an existing workflow keeps the old text)
templates/*.jsonat render time, through preparar.pyNo
.envat bootYes
3 · Anatomy

What’s ready, and what’s intentionally missing

A flow isn't a script that runs from top to bottom. It's a definition that is frozen when it's created, then advances phase by phase, with the state in the database. This is what lets you restart the process midway and pick up where it left off.

/promoavatar3 <assunto> | targets=… | prompt=… | studio createFlow validates flow.json · rejects early comVariante() changes the phase prompt freeze() embeds the prompt TEXT in the definition From the point of freezing onward, the flow no longer depends on the domain's disk: editing the prompt today doesn't change a flow created yesterday, and the /refazer reuses the same definition. definicaoEfetiva — filters the optional phases fases.filter(f => !f.opcional || opcoes[f.opcional]) an optional marked phase runs only when the flag was requested (| api, | estudio, | creditos) scope: "fluxo" ONE job for the entire flow. What happens between the command and the first job and writes one file per target to textos/. 1 model call is what keeps costs low scope: "alvo" ONE job PER target — 12 in promoavatar, up to 36 in promoavatar3. Each one fails, retries, and is canceled independently. N parallel jobs limited by the queue's concurrency pausa_apos: true — the human gate When ALL jobs in the phase finish, the flow stops and sends the result to the chat: the full scripts when the phase has "flow" scope, only the list when it has "target" scope. Only unlocked with /aprovar C#23 · idempotent: approving twice doesn't duplicate the job
GATE freezing (immutability), optional (phases on demand), scope (1 job or N) and gate (review before spending).

The four concepts that explain any workflow: flow.json

FieldWhat’s intentionally missingValues
idphase name — appears in /statustexto, gerar, baixar, reel…
escopo1 job per flow, or 1 job per targetfluxo · alvo
filaresource class — defines concurrencytexto io render navegador cpu
kindcalls a model, or runs codeagent · function
tarefawhich bot task runsfluxo-agente, heygen.gerar, reel.montar…
promptdomain file (only in kind: agent)prompts/fase1-3versoes.md
variantesalternative prompts, swapped by | prompt=<nome>{"viral": "prompts/fase1-viral.md"}
opcionalthe phase only runs if the flag was requestedapi estudio creditos navega
pausa_aposhuman gate after the phasetrue
esperaexternal work polling{intervalo, timeout} in seconds
max_tentativasretry before marking as failed2, 3
4 · Domain A

promoavatar — one video per audience

The format is 12 targets (one per audience), prefix A, seven phases, four of which are alternative optional routes for producing the same avatar.

prefix A · targets: pessoacomum, jovens, profissionais, mulheres, empreendedores, tecnicos, 40mais, 60mais, educadores, criadores, recolocacao, familia (12) text agent · text queue flow scope · opus PRO 12 scripts go integers for the chat four routes for the SAME avatar — choose one with the flag generate function · io · | api generate-credits function · io · | credits studio function · browser · | studio navega-avatar agent · browser · | navigates only exists here — promoavatar3 doesn’t have it download function · io target scope PRO polls for up to 40h reel function · render montar-reel.py publish renames it to the title copy for livesN
Seven phases, but never seven jobs per target: the four middle routes are mutually exclusive and only run when flagged. A typical flow runs texto → estudio → baixar → reel.
5 · Domain C

promoavatar3 — three videos per audience

The reel engine—one engine, N domains <publico>-<tipo>: 12 audiences × 3 types = 36 targets, prefix C. It gained prompt variants, CTA by variant, and the layout map — and lost the route navega-avatar.

target = <audience>-<type> · -alc reach 25–40s (sharing) · -aut authority 35–60s (saving) · -pro promotional 30–45s (CTA) each target is loaded in flow.json: channel (livesN) · trigger (that audience’s pain point) · ending (the ending for that type) text agent · text queue flow scope 1 call → 36 files PRO variants | prompt=manifest | prompt=viral change the phase prompt three routes for the avatar generate function · io · | api generate-credits function · io · | credits studio function · browser · | studio download function · io search by title PRO reel function · render montar-reel.py --flow --cta publish name = title copy for the channel of the target cta: { padrao, viral } the closing clip is chosen BY VARIANT and by flow — never by target type. All 36 targets receive the same clip. src/dominio/flow.ts → ctaDaDefinicao()
The structural difference from promoavatar isn't the number of phases — it's what the target means. Here it loads type, channel, trigger, and closing, and that’s what turns one topic into 36 different scripts in a single call.

promoavatar vs promoavatar3

promoavatarpromoavatar3
prefixAC
target<publico> — 12<publico>-<tipo> — 36
phases7 (4 optional routes)6 (3 optional routes)
route | navegahas it (the agent operates the browser)doesn’t have
prompt variantsnomanifesto, viral
CTAengine defaultcta: {padrao, viral} by variant
text phase modelopus pinned in the flow.jsondefault bot profile
reel timeout10800s (3h)21600s (6h)
Be careful when editing both: the prompts diverged without anyone deciding to do that — about 330 identical lines were edited in triplicate. If you change a structural rule (one the engine requires, such as the sections SOBREPOSIÇÕES e IMAGENS), must be replicated in every prompt in both repos. Nothing in the system enforces this.
6 · Engine

The engine is deliberately dumb on the bot side and smart on the domain side. That’s what lets you change the look of all reels by editing one JSON file, without recompiling anything.

The phase reel doesn’t render anything: it builds a command line and launches a detached process. All rendering lives in Python, in the domain repo — and one domain can point to another domain’s engine with motor_repo, instead of copying the scripts.

src/fila/tarefas/reel.ts montarComando() — only builds the string --avatar --ws --alvo --textos --saida --flow --cta highlighted bash -c echo $$ > .pid → o /cancelar kills the right process || touch .err → fails in seconds scripts/montar-reel.py — in the DOMAIN repo the bot doesn’t know what a reel is; it only knows how to run a script and watch the output file motor_repo in flow.json lets one domain use another domain's engine without copying Where to change it 1/6 prepare media, transcription, images, template, HTML 2/6 gate 1 lint + visual rhythm before spending on rendering 3/6 render HTML → frames → mp4 4/6 reviewer render audio, silence, pacing 5/6 CTA closing clip + deliverable 6/6 QC gates 2 and 3 INSIDE STEP 1 — scripts/preparar.py, a single call 1. creates the workspace 2. reads duration/fps (ffprobe) 3. extracts the audio and transcribes it 4. detects repetitions 5. GENERATES THE IMAGES from the ## IMAGENS section of the script 6. write manifesto.json 7. call montar.py and deliver motion/index.html It exists because the reel used 38 shell commands per video—none of them required a decision. HOW THE LAYOUT IS CHOSEN The text phase records `Formato escolhido:` for each target. templates/mapa.json translates editorial format → layout. --template > alvo.template > mapa[formato] > raiz No one chooses a layout at render time. AVAILABLE TEMPLATES stacked-cover (image at top + avatar + text panel) · diptych (half and half) · full-image (avatar cropped in the top right) · explanatory-stack (base = explanatory video, muted and looped)
The prompt receives {{publicos}} with ALL targets
7 · Recipes

I want to change X—where do I edit it?

The table that answers 90% of questions from people who arrive at the system. The right-hand column says whether the process needs to be restarted.

I want to…I editRestart?
Change the text the AI writes<dominio>/prompts/fase1-*.mdno
Add an audience / target<dominio>/flow.json → alvosno
Change the audience's channel<dominio>/flow.json → alvos.<alvo>.canalno
Create a new channel (lives7)create the folder ~/projetos/yt-pub-lives7/imports/videosno
Change the avatar or voice<dominio>/flow.json → avatar_id, voice_id, engineno
Change the reel’s look<dominio>/templates/*.jsonno
Change which layout each format uses<dominio>/templates/mapa.jsonno
Lock in a layout for an audienceflow.json → alvos.<alvo>.templateno
Change the closing clip<dominio>/cta/*.mp4 + flow.json → ctano
Create a prompt variantflow.json → fases[texto].variantes + the new one .mdno
Add a phase to the pipelineflow.json → fases (the task must already exist in the bot)no
Add a task newsrc/fila/tarefas/ + register in tarefas/index.tsyes
Register a new domainconfig/fluxos.jsonyes
Register a step skillconfig/skills.json + prompts/<nome>.mdyes
Change a queue’s concurrencysrc/fila/filas.ts → CONCORRENCIASyes
Change the HeyGen key / token.envyes
Change the help shown in chat<dominio>/HELP.mdno

A new domain, from scratch, without touching the bot

This is the test that the boundary is in the right place. If your new domain requires editing TypeScript, either it needs a task that doesn’t exist or the boundary has leaked.

# 1. the domain repo, alongside the bot
mkdir ~/projetos/meudominio && cd ~/projetos/meudominio

# 2. flow.json — targets, phases, and the engine for another repo
cat > flow.json <<'JSON'
{
  "nome": "meudominio", "prefixo": "M", "versao_def": 1,
  "motor_repo": "promoavatar3",
  "templates_dir": "templates", "template": "empilhado-capa",
  "alvos": { "ep01": { "canal": "lives2", "fecho": "..." } },
  "fases": [
    { "id": "texto", "escopo": "fluxo", "fila": "texto",
      "kind": "agent", "tarefa": "fluxo-agente",
      "prompt": "prompts/fase1.md", "pausa_apos": true },
    { "id": "reel", "escopo": "alvo", "fila": "render",
      "kind": "function", "tarefa": "reel.montar" }
  ]
}
JSON

# 3. register it in the bot — the ONLY step that requires a restart
#    config/fluxos.json:  {"command":"meudominio","repo":"meudominio",...}

# 4. test WITHOUT spending an avatar or render
/meudominio assunto de teste | sombra
Before restarting the service: check /fila and check whether there’s a job running. Restart kills an in-flight render — and a reel with a 6h timeout starts over from scratch.
8 · For anyone copying the design

The decisions that make this system work

These aren’t implementation details—they’re the choices that prevented entire classes of bugs and apply to any agent-based queue system.

1 · The orchestrator doesn't do the work

The worker runs a job and marks it done/failed. The runtime decides the next phase inside the same transaction of the ack. Separating these is what eliminated the duplicate dispatch from the previous version.

2 · Freeze the definition

The worker runs and marks /refazer reproduces exactly what ran.

3 · Closed catalog

The agent that interprets free text may hallucinate a command. Its output is validated against the registry before to become a job: a command that doesn’t exist is a refusal, not an attempt.

4 · Instructions aren't a gate

Pipeline with state per phase and per target, definition frozen at creation, pause for you to do your part ( IMAGENS and made up its own prompts, the fix wasn’t to write the rule in bold — it was to move image generation inside the preparar.py. Only a script, exit code, or removed path changes behavior.

5 · Gate before spending

pausa_apos exists where the next step costs money or hours. Reviewing 36 scripts in chat is cheap; redoing 36 avatars isn’t.

6 · Name, not path

The original domain. lives2; only the bot knows this is ~/projetos/yt-pub-lives2/imports/videos. A channel list duplicated N times drifts; in one place, it doesn't.

What needs to be in place

Phase barrier, job preemption, global agent limit, and multi-user support. Each omission has a documented trigger for reconsideration. A system that starts with everything is a system no one finishes.